diff --git a/.golangci.yml b/.golangci.yml index 6352f2e..2efb85b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -107,6 +107,9 @@ linters: - -ST1000 - -ST1003 - -ST1016 + # go-zero conf uses `json:",optional"` for optional fields; staticcheck + # mis-reports it as an unknown json option, so suppress globally. + - -SA5008 exclusions: generated: lax diff --git a/Makefile b/Makefile index 54ddb76..7a09f33 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help .PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ - deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test setup-dev run-local + deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local help: ## 顯示可用指令 @echo "Gateway Makefile" @@ -112,5 +112,11 @@ notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test M $(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \ $(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,) +totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis) + $(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \ + $(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \ + $(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \ + $(if $(CODE),-code "$(CODE)",) + config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go index c19f5df..cccc758 100644 --- a/cmd/mongo-index/main.go +++ b/cmd/mongo-index/main.go @@ -18,13 +18,19 @@ import ( var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)") func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { flag.Parse() var c config.Config conf.MustLoad(*configFile, &c) if c.Mongo.Host == "" { - fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config") - os.Exit(1) + return fmt.Errorf("mongo-index: Mongo.Host is empty in config") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -34,13 +40,12 @@ func main() { dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo}) if err := notifRepo.Index20260520001UP(ctx); err != nil { - fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err) - os.Exit(1) + return fmt.Errorf("mongo-index: notifications: %w", err) } if err := dlqRepo.Index20260520001UP(ctx); err != nil { - fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err) - os.Exit(1) + return fmt.Errorf("mongo-index: notification_dlq: %w", err) } fmt.Println("mongo-index: notifications + notification_dlq indexes OK") + return nil } diff --git a/cmd/notify-test/main.go b/cmd/notify-test/main.go index b8de4ea..ddcb0d6 100644 --- a/cmd/notify-test/main.go +++ b/cmd/notify-test/main.go @@ -15,6 +15,7 @@ import ( "gateway/internal/config" redislib "gateway/internal/library/redis" + memberenum "gateway/internal/model/member/domain/enum" dommember "gateway/internal/model/member/domain/usecase" memberusecase "gateway/internal/model/member/usecase" notifconfig "gateway/internal/model/notification/config" @@ -28,14 +29,14 @@ import ( ) const ( - methodEmailSend = "email-send" - methodEmailEnqueue = "email-enqueue" - methodEmailIdempotency = "email-idempotency" - methodSMSSend = "sms-send" - methodSMSEnqueue = "sms-enqueue" - methodMemberEmail = "member-email" - methodMemberPhone = "member-phone" - methodAdminDLQ = "admin-dlq" + methodEmailSend = "email-send" + methodEmailEnqueue = "email-enqueue" + methodEmailIdempotency = "email-idempotency" + methodSMSSend = "sms-send" + methodSMSEnqueue = "sms-enqueue" + methodMemberEmail = "member-email" + methodMemberPhone = "member-phone" + methodAdminDLQ = "admin-dlq" ) var validMethods = []string{ @@ -61,15 +62,17 @@ var ( ) type env struct { - ctx context.Context - tenant string - uid string - to string - phone string - locale string - notifier domusecase.NotifierUseCase - verification dommember.VerificationUseCase - admin domusecase.AdminNotifierUseCase + ctx context.Context + tenant string + uid string + to string + phone string + locale string + notifier domusecase.NotifierUseCase + // otp is the atomic primitive; this CLI plays the role of the future + // logic layer and orchestrates OTP.Generate + Notifier.Send inline. + otp dommember.OTPUseCase + admin domusecase.AdminNotifierUseCase } func main() { @@ -90,32 +93,41 @@ func main() { } flag.Parse() + code, err := run() + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + if code != 0 { + os.Exit(code) + } +} + +// run wires the requested method and returns (exitCode, error). Deferred +// cleanups inside run always execute before main calls os.Exit. +func run() (int, error) { m := strings.TrimSpace(*method) if m == "" { - fmt.Fprintln(os.Stderr, "notify-test: -method is required") flag.Usage() - os.Exit(2) + return 2, fmt.Errorf("notify-test: -method is required") } if !isValidMethod(m) { - fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m) flag.Usage() - os.Exit(2) + return 2, fmt.Errorf("notify-test: unknown method %q", m) } if err := validateArgs(m); err != nil { - fmt.Fprintf(os.Stderr, "notify-test: %v\n", err) - os.Exit(2) + return 2, fmt.Errorf("notify-test: %w", err) } var c config.Config conf.MustLoad(*configFile, &c) if c.Mongo.Host == "" { - fail("Mongo.Host is empty") + return 1, fmt.Errorf("notify-test: Mongo.Host is empty") } if c.Redis.Host == "" { - fail("Redis.Host is empty") + return 1, fmt.Errorf("notify-test: Redis.Host is empty") } if c.Notification.Email.From == "" && needsEmailFrom(m) { - fail("Notification.Email.From is empty") + return 1, fmt.Errorf("notify-test: Notification.Email.From is empty") } if *mockOnly { forceMock(&c.Notification) @@ -126,7 +138,7 @@ func main() { rds, err := redislib.NewClient(c.Redis) if err != nil { - fail("redis: %v", err) + return 1, fmt.Errorf("notify-test: redis: %w", err) } mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ @@ -135,37 +147,36 @@ func main() { Config: c.Notification, }) if err != nil { - fail("notification: %v", err) + return 1, fmt.Errorf("notify-test: notification: %w", err) } - var verification dommember.VerificationUseCase + var otpUC dommember.OTPUseCase if m == methodMemberEmail || m == methodMemberPhone { - memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - Notifier: mod.Notifier, - Config: c.Member, + memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ + Redis: rds, + Config: c.Member, }) - if err != nil { - fail("member: %v", err) + if memErr != nil { + return 1, fmt.Errorf("notify-test: member: %w", memErr) } - verification = memberMod.Verification + otpUC = memberMod.OTP } e := &env{ - ctx: ctx, - tenant: *tenantID, - uid: *uid, - to: *toEmail, - phone: *phone, - locale: c.Notification.DefaultLocale, - notifier: mod.Notifier, - verification: verification, - admin: mod.Admin, + ctx: ctx, + tenant: *tenantID, + uid: *uid, + to: *toEmail, + phone: *phone, + locale: c.Notification.DefaultLocale, + notifier: mod.Notifier, + otp: otpUC, + admin: mod.Admin, } if m == methodEmailEnqueue || m == methodSMSEnqueue { if mod.RetryWorker == nil { - fail("retry worker not configured (need Redis)") + return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)") } workerCtx, stop := context.WithCancel(context.Background()) go mod.RetryWorker.Run(workerCtx) @@ -174,11 +185,11 @@ func main() { fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ",")) - if err := runMethod(e, m); err != nil { - fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) - os.Exit(1) + if runErr := runMethod(e, m); runErr != nil { + return 1, fmt.Errorf("FAIL: %w", runErr) } fmt.Println("OK") + return 0, nil } func runMethod(e *env, m string) error { @@ -320,21 +331,52 @@ func (e *env) smsEnqueue() error { return nil } +// memberEmail demonstrates the logic-layer orchestration: generate an OTP +// challenge (atomic) and dispatch the verification email through Notifier +// (atomic). usecases never call each other — this driver is what the real +// logic handler will look like. func (e *env) memberEmail() error { - ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw") - if err != nil { - return err - } - fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn) - return nil + return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to) } func (e *env) memberPhone() error { - ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw") + return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone) +} + +func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error { + if e.otp == nil { + return fmt.Errorf("member OTP usecase not configured") + } + if target == "" { + return fmt.Errorf("target is empty") + } + dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{ + TenantID: e.tenant, + UID: e.uid, + Purpose: purpose, + Target: target, + }) if err != nil { return err } - fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn) + if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ + TenantID: e.tenant, + UID: e.uid, + Channel: channel, + Kind: kind, + Target: target, + Locale: e.locale, + Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, + IdempotencyKey: dto.ChallengeID, + DoNotPersistBody: true, + Severity: enum.SeverityInfo, + }); err != nil { + if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil { + fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr) + } + return err + } + fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn) return nil } @@ -443,8 +485,3 @@ func smsProviders(cfg *notifconfig.Config) []string { } return []string{"mock"} } - -func fail(format string, args ...any) { - fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...) - os.Exit(1) -} diff --git a/cmd/totp-test/main.go b/cmd/totp-test/main.go new file mode 100644 index 0000000..4aceb95 --- /dev/null +++ b/cmd/totp-test/main.go @@ -0,0 +1,330 @@ +// Command totp-test runs an interactive TOTP enrollment and verification flow +// against local Redis + in-memory profile (single process). +// +// Prerequisites: +// +// make deps-up +// make setup-dev # ensure Member.TOTP.SecretKEK is set +// +// Usage: +// +// make totp-test +// go run ./cmd/totp-test -f etc/gateway.dev.yaml +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "net/url" + "os" + "strings" + + "gateway/internal/config" + redislib "gateway/internal/library/redis" + domusecase "gateway/internal/model/member/domain/usecase" + memberusecase "gateway/internal/model/member/usecase" + + "github.com/skip2/go-qrcode" + "github.com/zeromicro/go-zero/core/conf" +) + +const ( + stepFlow = "flow" + stepEnroll = "enroll" + stepConfirm = "confirm" + stepVerify = "verify" + stepStatus = "status" + stepDisable = "disable" +) + +var ( + configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") + stepFlag = flag.String("step", stepFlow, "step: flow, enroll, confirm, verify, status, disable") + tenantID = flag.String("tenant", "totp-test", "tenant_id") + uidFlag = flag.String("uid", "totp-test-uid", "uid") + account = flag.String("account", "totp-test@example.com", "account label shown in Authenticator") + codeFlag = flag.String("code", "", "TOTP code (non-interactive confirm/verify)") + kekFlag = flag.String("kek", "", "override Member.TOTP.SecretKEK (hex or base64)") +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: totp-test [options]\n\n") + fmt.Fprintf(os.Stderr, "Interactive TOTP test for Google Authenticator / Authy.\n") + fmt.Fprintf(os.Stderr, "Default step=flow guides enroll → confirm → verify in one process.\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " totp-test\n") + fmt.Fprintf(os.Stderr, " totp-test -step status\n") + fmt.Fprintf(os.Stderr, " totp-test -step confirm -code 482913\n") + flag.PrintDefaults() + } + flag.Parse() + + code, err := run() + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + if code != 0 { + os.Exit(code) + } +} + +func run() (int, error) { + step := strings.TrimSpace(*stepFlag) + if step == "" { + step = stepFlow + } + + var c config.Config + conf.MustLoad(*configFile, &c) + if c.Redis.Host == "" { + return 1, fmt.Errorf("totp-test: Redis.Host is empty (run: make deps-up)") + } + + kek := strings.TrimSpace(*kekFlag) + if kek == "" { + kek = strings.TrimSpace(c.Member.TOTP.SecretKEK) + } + if kek == "" { + return 1, fmt.Errorf("totp-test: Member.TOTP.SecretKEK is empty; set it in %s or pass -kek", *configFile) + } + c.Member.TOTP.SecretKEK = kek + + ctx := context.Background() + rds, err := redislib.NewClient(c.Redis) + if err != nil { + return 1, fmt.Errorf("totp-test: redis: %w", err) + } + + mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ + Redis: rds, + Config: c.Member, + }) + if err != nil { + return 1, fmt.Errorf("totp-test: member: %w", err) + } + if mod.TOTP == nil { + return 1, fmt.Errorf("totp-test: TOTP usecase not wired (invalid SecretKEK?)") + } + + env := &session{ + ctx: ctx, + tenant: *tenantID, + uid: *uidFlag, + totp: mod.TOTP, + in: bufio.NewReader(os.Stdin), + } + + switch step { + case stepFlow: + if err := env.runFlow(); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + case stepEnroll: + if err := env.doEnroll(); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + case stepConfirm: + if err := env.doConfirm(strings.TrimSpace(*codeFlag)); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + case stepVerify: + if err := env.doVerify(strings.TrimSpace(*codeFlag)); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + case stepStatus: + if err := env.doStatus(); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + case stepDisable: + if err := env.doDisable(); err != nil { + return 1, fmt.Errorf("FAIL: %w", err) + } + default: + flag.Usage() + return 2, fmt.Errorf("totp-test: unknown step %q", step) + } + + fmt.Println("OK") + return 0, nil +} + +type session struct { + ctx context.Context + tenant string + uid string + totp domusecase.TOTPUseCase + in *bufio.Reader +} + +func (s *session) runFlow() error { + status, err := s.totp.Status(s.ctx, s.tenant, s.uid) + if err != nil { + return err + } + if status.Enrolled { + fmt.Println("Already enrolled for this tenant/uid.") + if err := s.doStatus(); err != nil { + return err + } + fmt.Println() + fmt.Println("Proceeding to verify-only (skip enroll/confirm).") + } else { + if err := s.doEnroll(); err != nil { + return err + } + fmt.Println() + confirmCode, err := s.readCode("Enter the 6-digit code from Google Authenticator to confirm enrollment: ") + if err != nil { + return err + } + if err := s.doConfirm(confirmCode); err != nil { + return err + } + fmt.Println() + fmt.Println("Wait for the Authenticator code to refresh (up to 30s), then verify step-up.") + } + + verifyCode, err := s.readCode("Enter a fresh 6-digit code to verify (step-up): ") + if err != nil { + return err + } + if err := s.doVerify(verifyCode); err != nil { + return err + } + + fmt.Println() + fmt.Println("Testing replay protection with the same code (should fail)...") + if err := s.doVerify(verifyCode); err == nil { + return fmt.Errorf("expected replay failure but verify succeeded") + } + fmt.Println("Replay correctly rejected.") + return nil +} + +func (s *session) doEnroll() error { + start, err := s.totp.StartEnroll(s.ctx, s.tenant, s.uid, *account) + if err != nil { + return err + } + + secret, err := secretFromOtpauthURL(start.OtpauthURL) + if err != nil { + return err + } + + fmt.Println("=== TOTP Enrollment ===") + fmt.Printf("tenant=%s uid=%s\n", s.tenant, s.uid) + fmt.Printf("issuer=%s account=%s digits=%d period=%ds expires_in=%ds\n", + start.Issuer, start.Account, start.Digits, start.PeriodSec, start.ExpiresIn) + fmt.Println() + fmt.Println("Option A — scan QR code with Google Authenticator:") + fmt.Println() + if err := printTerminalQR(start.OtpauthURL); err != nil { + fmt.Fprintf(os.Stderr, "warn: QR render failed: %v\n", err) + } + fmt.Println() + fmt.Println("Option B — enter setup key manually in Google Authenticator:") + fmt.Printf(" Type: Time based\n") + fmt.Printf(" Account: %s\n", start.Account) + fmt.Printf(" Issuer: %s\n", start.Issuer) + fmt.Printf(" Secret key: %s\n", secret) + fmt.Println() + fmt.Println("otpauth URL (for debugging):") + fmt.Println(start.OtpauthURL) + fmt.Println() + fmt.Printf("Complete enrollment within %d seconds.\n", start.ExpiresIn) + return nil +} + +func (s *session) doConfirm(code string) error { + if code == "" { + var err error + code, err = s.readCode("Enter the 6-digit code from Google Authenticator: ") + if err != nil { + return err + } + } + backup, err := s.totp.ConfirmEnroll(s.ctx, s.tenant, s.uid, code) + if err != nil { + return err + } + fmt.Println("Enrollment confirmed.") + fmt.Printf("backup_codes (%d, save these — shown once):\n", len(backup)) + for i, c := range backup { + fmt.Printf(" [%02d] %s\n", i+1, c) + } + return s.doStatus() +} + +func (s *session) doVerify(code string) error { + if code == "" { + var err error + code, err = s.readCode("Enter a 6-digit TOTP code (or backup code): ") + if err != nil { + return err + } + } + if err := s.totp.VerifyCode(s.ctx, s.tenant, s.uid, code); err != nil { + return err + } + fmt.Println("VerifyCode: success") + return nil +} + +func (s *session) doStatus() error { + status, err := s.totp.Status(s.ctx, s.tenant, s.uid) + if err != nil { + return err + } + fmt.Printf("status enrolled=%t backup_codes_remaining=%d", status.Enrolled, status.BackupCodesRemaining) + if status.Enrolled { + fmt.Printf(" enrolled_at=%d", status.EnrolledAt) + } + fmt.Println() + return nil +} + +func (s *session) doDisable() error { + if err := s.totp.Disable(s.ctx, s.tenant, s.uid); err != nil { + return err + } + fmt.Println("TOTP disabled.") + return s.doStatus() +} + +func (s *session) readCode(prompt string) (string, error) { + fmt.Print(prompt) + line, err := s.in.ReadString('\n') + if err != nil { + return "", fmt.Errorf("read code: %w", err) + } + code := strings.TrimSpace(line) + if code == "" { + return "", fmt.Errorf("code is empty") + } + return code, nil +} + +func secretFromOtpauthURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse otpauth url: %w", err) + } + secret := strings.TrimSpace(u.Query().Get("secret")) + if secret == "" { + return "", fmt.Errorf("otpauth url missing secret parameter") + } + return secret, nil +} + +func printTerminalQR(content string) error { + qr, err := qrcode.New(content, qrcode.Medium) + if err != nil { + return err + } + fmt.Print(qr.ToSmallString(false)) + return nil +} diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md index d235802..15ff7ed 100644 --- a/docs/identity-member-design.md +++ b/docs/identity-member-design.md @@ -439,7 +439,10 @@ Middleware > > hG > 1. **Atomic primitives**Gº骺@ʧ@] memberB OTPB OTPBH notification^CLogic iNզXAy{@ΡC -> 2. **Composite**GXӱ` atomic wզnuֱզXv]p `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`^CComposite O**i**Alogic ]iH¶L atomicC +> 2. ~~**Composite**~~G쥻]QuX atomic wզnֱզXv**wo**C +> - P [model.md 6.1](./model.md) IJGusecase TIsC +> - ثe@u atomic]`OTPUseCase`B`TOTPUseCase`B`ProfileUseCase` ^AhBJy{]p verify-email = `OTP.Generate` `Notifier.Send` `Profile.SetBusinessEmailVerified`^@ߦb **logic h**sơAlogic handler ۤvh usecase interfaceC +> - U 5.2.2 `Od `VerificationUseCase` wqȬu**޿yyzѦ**vA|b `domain/usecase/` X{C > > ~޿]APIBhandlerBy{sơ^ثe**@**FTƤC diff --git a/docs/model.md b/docs/model.md index eb48eb2..b015df1 100644 --- a/docs/model.md +++ b/docs/model.md @@ -15,25 +15,30 @@ ``` internal/model/ └── {module}/ # 例:notification、member、permission - ├── domain/ # 純領域:介面、實體、列舉、DTO(不依賴 mongo/redis/provider) + ├── domain/ # 純領域:介面、實體、列舉、DTO、模組級定義 │ ├── entity/ # Mongo document 結構 + CollectionName() │ ├── enum/ # Channel、Status、Platform… │ ├── repository/ # Repository / Cache 介面 only │ ├── usecase/ # UseCase 介面 + Request/Response DTO - │ └── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification) + │ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification) + │ ├── errors.go # 模組 sentinel(package domain) + │ ├── const.go # BSON 欄位名、模組常數(package domain) + │ └── redis.go # Redis key 命名(package domain) ├── repository/ # domain/repository 的 Mongo / Redis / memory 實作 ├── usecase/ # domain/usecase 的實作 + factory 組裝 ├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作 ├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/ + ├── totp/、xxx/ # 可選:模組專屬純函式 library(不放 internal/library/) ├── config/ # 模組設定 struct(嵌入 gateway Config) - ├── errors.go # 模組 sentinel - ├── const.go # BSON 欄位名、模組常數 - ├── redis.go # Redis key 命名 └── mock/ # mockgen(路徑對應 domain/) ├── repository/ └── usecase/ ``` +> **定義類(errors / const / redis key)統一放 `domain/`**:caller 端以 `member "gateway/internal/model/{module}/domain"` 取用,引用形式仍為 `member.ErrXxx` / `member.Get…RedisKey`,但這些 sentinel 與 key helper 都在 `package domain`,與 `domain/entity` 等子套件平行。 +> +> **`internal/library/` 只放跨模組真正共用的東西**(如 `library/errors`、`library/mongo`、`library/redis`、`library/crypto`)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper)應落在該模組底下,例如 `internal/model/member/totp/`,避免污染 library 命名空間。 + **參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。 | 層 | 路徑 | 內容 | @@ -285,6 +290,17 @@ type SendRequest struct { - 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。 - 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。 +### 6.1 UseCase 互不呼叫(atomic-only) + +> **強制規則**:UseCase 是 **atomic primitive**,**禁止**在 usecase 內部呼叫其他 usecase(不論是同模組或跨模組)。 +> +> - usecase struct 的依賴**只能**是 `domain/repository` 介面、`provider/`、`template/`、library helper、`config`。 +> - **不可**在 `XxxUseCaseParam` 出現另一個 `domain/usecase.XxxUseCase` 欄位。 +> - 需要把多個 atomic 串成一個業務流程(例如「OTP.Generate → Notifier.Send → Profile.SetVerified」)時,**編排在 `internal/logic/`**;logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。 +> - CLI / driver(如 `cmd/notify-test/`)扮演 logic 同等角色:直接組 atomic,不應該被包成 composite usecase。 +> +> 這條規則優先於 [identity-member-design.md §5.2](./identity-member-design.md) 提到的 Composite UseCase;該節保留為「**邏輯流的描述**」,不代表 `domain/usecase` 會出現 composite interface。 + **範例:** ```go @@ -310,12 +326,12 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase { ## 7. 錯誤處理 -全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。 +全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。 -### 7.1 模組 sentinel(`errors.go`) +### 7.1 模組 sentinel(`domain/errors.go`) ```go -package member +package domain import "fmt" @@ -325,7 +341,7 @@ var ( ) ``` -(專案慣例:`fmt.Errorf` 定義 sentinel,便於 `%w` 包裝;見 `notification/errors.go`。) +(專案慣例:sentinel 一律以 `fmt.Errorf` 定義,便於 `%w` 包裝。caller 端 `member "gateway/internal/model/member/domain"` 後即可 `member.ErrNotFound`。) ### 7.2 Repository @@ -354,24 +370,36 @@ var ( | 檔案 | 用途 | |------|------| -| `errors.go` | 模組 sentinel(`ErrNotFound` 等) | -| `const.go` | 模組字面常數 | -| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper | +| `domain/errors.go` | 模組 sentinel(`ErrNotFound` 等,`package domain`) | +| `domain/const.go` | 模組字面常數(`package domain`) | +| `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper(`package domain`) | | `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) | Redis key 統一帶業務 prefix,避免跨服務衝突: ```go +package domain + type RedisKey string const AccountRedisKey RedisKey = "member:account" func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ } func GetAccountRedisKey(id string) string { - return AccountRedisKey.With(id).ToString() + return AccountRedisKey.With(id).String() } ``` +Caller 端: + +```go +import ( + member "gateway/internal/model/member/domain" +) + +// 使用:member.GetAccountRedisKey(id)、member.ErrNotFound +``` + ## 9. Mock(`mock/` + gomock) **方案 A(本專案採用):** @@ -433,7 +461,7 @@ make gen-mock **Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。 -**Member 模組進度(P3.5):** `OTPUseCase` + `VerificationUseCase`(email/phone)✅,經 `Notifier.Send` 投遞;`ProfileRepository` 暫用 memory(P4 換 Mongo)。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續:Step-up / TOTP、HTTP API(goctl)。 +**Member 模組進度(P3.5):** atomic primitives `OTPUseCase`(Generate/Verify/Invalidate)+ `TOTPUseCase`(enroll/verify/backup/disable)+ `VerifyRateStore` + `ProfileRepository` ✅。**usecase 之間不互相呼叫**:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 **logic 層**負責(尚未實作);參考實作見 `cmd/notify-test/main.go::startMemberVerify`(driver 等同 logic 角色)。後續:HTTP API(goctl)+ logic 層編排(含 rate-limit + step-up 守門)。 ## 12. 與 Gateway HTTP 層的關係 diff --git a/etc/gateway.dev.example.yaml b/etc/gateway.dev.example.yaml index 7d1e064..a13cecc 100644 --- a/etc/gateway.dev.example.yaml +++ b/etc/gateway.dev.example.yaml @@ -65,3 +65,16 @@ Member: MaxAttempts: 5 ResendCooldownSeconds: 60 DailyVerifyLimit: 10 + TOTP: + Issuer: CloudEP + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 + BackupCodeCount: 10 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 + ReplayTTLSeconds: 90 + # 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP. + # Dev-only placeholder for local totp-test; replace in production. + SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" diff --git a/etc/gateway.dev.yaml b/etc/gateway.dev.yaml index 21deafb..c7c622a 100644 --- a/etc/gateway.dev.yaml +++ b/etc/gateway.dev.yaml @@ -59,3 +59,14 @@ Member: MaxAttempts: 5 ResendCooldownSeconds: 60 DailyVerifyLimit: 10 + TOTP: + Issuer: CloudEP + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 + BackupCodeCount: 10 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 + ReplayTTLSeconds: 90 + SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" diff --git a/go.mod b/go.mod index 8ab66b1..b291921 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/titanous/json5 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8896387..0e324d1 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/internal/config/config.go b/internal/config/config.go index 6cc8778..4c59286 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,8 +14,8 @@ import ( type Config struct { rest.RestConf - Mongo mongo.Conf `json:",optional"` - Redis redis.RedisConf `json:",optional"` - Notification notifconfig.Config `json:",optional"` - Member memberconfig.Config `json:",optional"` + Mongo mongo.Conf `json:",optional"` + Redis redis.RedisConf `json:",optional"` + Notification notifconfig.Config `json:",optional"` + Member memberconfig.Config `json:",optional"` } diff --git a/internal/library/crypto/aesgcm.go b/internal/library/crypto/aesgcm.go new file mode 100644 index 0000000..6c700ec --- /dev/null +++ b/internal/library/crypto/aesgcm.go @@ -0,0 +1,112 @@ +// Package crypto provides symmetric secret encryption helpers. +// +// The Cipher type wraps AES-256-GCM. A random 12-byte nonce is prepended to +// the ciphertext so callers persist a single opaque blob. KEK material is +// expected to be 32 bytes long; helpers accept hex/base64 encoded strings. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" +) + +// AES-256 key length in bytes. +const aes256KeyBytes = 32 + +// Sentinel errors for callers that need to distinguish failure modes. +var ( + ErrInvalidKey = fmt.Errorf("crypto: invalid kek length") + ErrCipherTextTooShort = fmt.Errorf("crypto: ciphertext too short") +) + +// Cipher encrypts and decrypts opaque byte payloads with AES-256-GCM. +type Cipher struct { + gcm cipher.AEAD +} + +// NewAESGCM constructs a Cipher from a 32-byte key. +func NewAESGCM(key []byte) (*Cipher, error) { + if len(key) != aes256KeyBytes { + return nil, fmt.Errorf("%w: expect %d bytes, got %d", ErrInvalidKey, aes256KeyBytes, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: create aes block: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: create gcm: %w", err) + } + return &Cipher{gcm: gcm}, nil +} + +// NewAESGCMFromString decodes the key as hex (64 chars) or standard base64 +// (44 chars padded) and constructs an AES-256-GCM Cipher. +func NewAESGCMFromString(encoded string) (*Cipher, error) { + if encoded == "" { + return nil, fmt.Errorf("%w: empty kek", ErrInvalidKey) + } + if len(encoded) == aes256KeyBytes*2 { + key, err := hex.DecodeString(encoded) + if err == nil { + return NewAESGCM(key) + } + } + if key, err := base64.StdEncoding.DecodeString(encoded); err == nil { + return NewAESGCM(key) + } + if key, err := base64.RawStdEncoding.DecodeString(encoded); err == nil { + return NewAESGCM(key) + } + return nil, fmt.Errorf("%w: must be hex(64) or base64(32 bytes)", ErrInvalidKey) +} + +// Encrypt produces "nonce||ciphertext" raw bytes. +func (c *Cipher) Encrypt(plaintext []byte) ([]byte, error) { + nonce := make([]byte, c.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("crypto: random nonce: %w", err) + } + ct := c.gcm.Seal(nil, nonce, plaintext, nil) + out := make([]byte, 0, len(nonce)+len(ct)) + out = append(out, nonce...) + out = append(out, ct...) + return out, nil +} + +// Decrypt accepts the "nonce||ciphertext" raw bytes returned by Encrypt. +func (c *Cipher) Decrypt(blob []byte) ([]byte, error) { + ns := c.gcm.NonceSize() + if len(blob) < ns+c.gcm.Overhead() { + return nil, ErrCipherTextTooShort + } + nonce, ct := blob[:ns], blob[ns:] + pt, err := c.gcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("crypto: decrypt: %w", err) + } + return pt, nil +} + +// EncryptToString returns base64 (std, no padding) for storage. +func (c *Cipher) EncryptToString(plaintext []byte) (string, error) { + blob, err := c.Encrypt(plaintext) + if err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(blob), nil +} + +// DecryptFromString reverses EncryptToString. +func (c *Cipher) DecryptFromString(s string) ([]byte, error) { + blob, err := base64.RawStdEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("crypto: decode base64: %w", err) + } + return c.Decrypt(blob) +} diff --git a/internal/library/crypto/aesgcm_test.go b/internal/library/crypto/aesgcm_test.go new file mode 100644 index 0000000..91e52a7 --- /dev/null +++ b/internal/library/crypto/aesgcm_test.go @@ -0,0 +1,102 @@ +package crypto_test + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "gateway/internal/library/crypto" +) + +func mustRandomKey(t *testing.T) []byte { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err) + return key +} + +func TestAESGCM_RoundTrip(t *testing.T) { + key := mustRandomKey(t) + c, err := crypto.NewAESGCM(key) + require.NoError(t, err) + + pt := []byte("totp-secret-bytes") + blob, err := c.Encrypt(pt) + require.NoError(t, err) + require.NotEqual(t, pt, blob) + + out, err := c.Decrypt(blob) + require.NoError(t, err) + require.Equal(t, pt, out) +} + +func TestAESGCM_NonceUnique(t *testing.T) { + c, err := crypto.NewAESGCM(mustRandomKey(t)) + require.NoError(t, err) + a, err := c.Encrypt([]byte("same")) + require.NoError(t, err) + b, err := c.Encrypt([]byte("same")) + require.NoError(t, err) + require.NotEqual(t, a, b, "nonce should randomize each ciphertext") +} + +func TestAESGCM_FromStringHexAndBase64(t *testing.T) { + key := mustRandomKey(t) + hexKey := hex.EncodeToString(key) + b64Key := base64.StdEncoding.EncodeToString(key) + + c1, err := crypto.NewAESGCMFromString(hexKey) + require.NoError(t, err) + c2, err := crypto.NewAESGCMFromString(b64Key) + require.NoError(t, err) + + pt := []byte("payload") + blob, err := c1.Encrypt(pt) + require.NoError(t, err) + out, err := c2.Decrypt(blob) + require.NoError(t, err) + require.Equal(t, pt, out) +} + +func TestAESGCM_InvalidKey(t *testing.T) { + _, err := crypto.NewAESGCM([]byte("short")) + require.ErrorIs(t, err, crypto.ErrInvalidKey) + + _, err = crypto.NewAESGCMFromString("") + require.ErrorIs(t, err, crypto.ErrInvalidKey) + + _, err = crypto.NewAESGCMFromString("not-a-valid-key-encoding!!!") + require.ErrorIs(t, err, crypto.ErrInvalidKey) +} + +func TestAESGCM_DecryptTooShort(t *testing.T) { + c, err := crypto.NewAESGCM(mustRandomKey(t)) + require.NoError(t, err) + _, err = c.Decrypt([]byte{0x01, 0x02}) + require.ErrorIs(t, err, crypto.ErrCipherTextTooShort) +} + +func TestAESGCM_DecryptTampered(t *testing.T) { + c, err := crypto.NewAESGCM(mustRandomKey(t)) + require.NoError(t, err) + blob, err := c.Encrypt([]byte("hello")) + require.NoError(t, err) + blob[len(blob)-1] ^= 0xFF + _, err = c.Decrypt(blob) + require.Error(t, err) + require.NotErrorIs(t, err, crypto.ErrCipherTextTooShort) +} + +func TestAESGCM_StringRoundTrip(t *testing.T) { + c, err := crypto.NewAESGCM(mustRandomKey(t)) + require.NoError(t, err) + encoded, err := c.EncryptToString([]byte("secret")) + require.NoError(t, err) + out, err := c.DecryptFromString(encoded) + require.NoError(t, err) + require.Equal(t, []byte("secret"), out) +} diff --git a/internal/model/member/README.md b/internal/model/member/README.md new file mode 100644 index 0000000..9aca6f9 --- /dev/null +++ b/internal/model/member/README.md @@ -0,0 +1,359 @@ +# Member 模組 — OTP / TOTP + +Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫): + +| UseCase | 用途 | 典型場景 | +|---------|------|----------| +| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 | +| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA | + +> **架構原則**:usecase **不可**呼叫其他 usecase。 +> 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程,由 **logic 層**(或 CLI driver)編排。 +> 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。 + +--- + +## 目錄結構 + +``` +internal/model/member/ +├── config/ # OTP / TOTP 設定 +├── domain/ # 介面、enum、errors、redis key +│ ├── enum/ +│ ├── repository/ +│ └── usecase/ +├── repository/ # Redis / memory 實作 +├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library) +├── usecase/ # OTPUseCase、TOTPUseCase 實作 +└── README.md +``` + +--- + +## OTP(One-Time Password) + +### 原理 + +1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 Redis,TTL 預設 300 秒。 +2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次;logic 層負責呼叫 `notification.Notifier.Send` 投遞。 +3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt;成功後 **刪除 challenge**(一次性)。 +4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5)即鎖定該 challenge。 + +```mermaid +sequenceDiagram + participant Logic + participant OTP as OTPUseCase + participant Redis + participant Notifier + + Logic->>OTP: Generate(tenant, uid, purpose, target) + OTP->>Redis: Save challenge (bcrypt hash) + OTP-->>Logic: challenge_id, plainCode + Logic->>Notifier: Send(code, expires_in) + Note over Logic: 使用者收到信/簡訊 + Logic->>OTP: Verify(challenge_id, code, uid, purpose) + OTP->>Redis: Get + bcrypt compare + OTP->>Redis: Delete challenge + OTP-->>Logic: target (email/phone) + Logic->>Logic: Profile.SetBusinessEmailVerified(...) +``` + +### Purpose(用途標籤) + +```go +enum.OTPPurposeBusinessEmail // 業務 email 驗證 +enum.OTPPurposeBusinessPhone // 業務 phone 驗證 +enum.OTPPurposeStepUp // step-up(未來 logic 層使用) +``` + +Verify 時 `Purpose` 必須與 Generate 一致,否則拒絕。 + +### API + +```go +// 產碼 +dto, plainCode, err := otpUC.Generate(ctx, &domusecase.GenerateOTPRequest{ + TenantID: "t1", + UID: "u1", + Purpose: enum.OTPPurposeBusinessEmail, + Target: "user@example.com", +}) +// dto.ChallengeID → 給前端帶回 confirm API +// plainCode → 只在此刻存在,交給 Notifier 寄出 + +// 驗碼 +target, err := otpUC.Verify(ctx, &domusecase.VerifyOTPRequest{ + TenantID: "t1", + UID: "u1", + ChallengeID: dto.ChallengeID, + Code: "482913", + Purpose: enum.OTPPurposeBusinessEmail, +}) +// 成功 → target == "user@example.com",challenge 已刪除 + +// 寄送失敗時回滾 +_ = otpUC.Invalidate(ctx, dto.ChallengeID) +``` + +### Rate limit(logic 層使用) + +`VerifyRateStore` 提供 resend cooldown 與每日上限,**不在 OTPUseCase 內建**: + +```go +// 冷卻(60 秒內不可重發) +ok, _ := verifyRate.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenant, uid, "email"), 60*time.Second) + +// 每日上限(預設 10 次) +count, _ := verifyRate.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenant, uid, "email"), 24*time.Hour) +``` + +--- + +## TOTP(Time-based OTP) + +### 原理 + +遵循 **RFC 6238**,與 Google Authenticator / Authy 相容: + +| 參數 | 預設值 | +|------|--------| +| 演算法 | HMAC-SHA1 | +| 週期 | 30 秒 | +| 位數 | 6 | +| 時間窗口 | ±1 step(容忍時鐘偏差) | + +**儲存安全**: + +- Secret 以 **AES-256-GCM** 加密(KEK = `Member.TOTP.SecretKEK`)後寫入 profile。 +- 備援碼以 **bcrypt** 雜湊儲存,明文只在 `ConfirmEnroll` / `RegenerateBackupCodes` 回傳一次。 +- 綁定前的 staged secret 暫存 Redis(`EnrollTTLSeconds`,預設 600 秒)。 +- 驗碼成功後以 Redis 記錄 time step,**同一時間窗口內不可重放**。 + +```mermaid +sequenceDiagram + participant User + participant Logic + participant TOTP as TOTPUseCase + participant Redis + participant Profile + + Note over Logic,Profile: 綁定階段 + Logic->>TOTP: StartEnroll(tenant, uid, account) + TOTP->>Redis: Save encrypted staged secret + TOTP-->>Logic: otpauth_url (QR code) + User->>User: 掃碼加入 Authenticator + Logic->>TOTP: ConfirmEnroll(tenant, uid, code) + TOTP->>Profile: Save encrypted secret + backup hashes + TOTP->>Redis: Delete staged secret + TOTP-->>Logic: backup_codes[] (只顯示一次) + + Note over Logic,Profile: 日常使用(step-up) + Logic->>TOTP: VerifyCode(tenant, uid, code) + TOTP->>Profile: Decrypt secret + TOTP->>Redis: MarkUsed(timestep) — 防重放 + TOTP-->>Logic: ok / err +``` + +### API + +```go +// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code +start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com") +// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn + +// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼 +backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913") +// backupCodes 只回傳這一次,請引導使用者妥善保存 + +// 3. step-up 驗碼 — TOTP 或備援碼皆可 +err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP +err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪) + +// 4. 查狀態 +status, err := totpUC.Status(ctx, "t1", "u1") +// status.Enrolled, status.BackupCodesRemaining + +// 5. 停用 / 重產備援碼(logic 層應先要求 step-up) +_ = totpUC.Disable(ctx, "t1", "u1") +newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1") +``` + +### VerifyCode 判定順序 + +1. 長度 = 6 → 當 TOTP 驗(含 ±window) +2. 通過 → Redis 記錄 time step;已用過則回 `ErrTOTPCodeReplay` +3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組 +4. 皆失敗 → `ErrTOTPInvalidCode` + +--- + +## 設定 + +`etc/gateway.dev.yaml` → `Member` 區塊: + +```yaml +Member: + OTP: + Length: 6 # 驗證碼位數 + TTLSeconds: 300 # challenge 存活時間 + MaxAttempts: 5 # 單 challenge 最大錯誤次數 + ResendCooldownSeconds: 60 # 重發冷卻(logic 層用 VerifyRateStore) + DailyVerifyLimit: 10 # 每日上限(logic 層用 VerifyRateStore) + TOTP: + Issuer: CloudEP + Algorithm: SHA1 + Digits: 6 + PeriodSeconds: 30 + Window: 1 # ±1 time step + BackupCodeCount: 10 + BackupCodeLength: 12 + EnrollTTLSeconds: 600 # 綁定 staged secret TTL + ReplayTTLSeconds: 90 # 重放保護 TTL + SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空則不啟用 TOTP +``` + +`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入(production 建議走 KMS / secret manager)。 + +--- + +## 裝配與注入 + +### Module factory + +```go +mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ + Redis: rds, + Config: c.Member, +}) +// mod.OTP — 永遠有值(需 Redis) +// mod.TOTP — SecretKEK 有設定時才有值,否則 nil +// mod.VerifyRate — resend / daily cap +// mod.Profile — 預設 memory,P4 換 Mongo +``` + +### ServiceContext + +Gateway 啟動時(Redis 就緒)自動注入: + +```go +svc.MemberOTP // domusecase.OTPUseCase +svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil) +svc.MemberVerifyRate // VerifyRateStore +svc.MemberProfile // ProfileRepository +``` + +--- + +## Logic 層編排範例 + +以下示範 **verify business email** 完整流程(logic 層職責,尚未有 HTTP handler): + +```go +// ── 發起驗證 ── +dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ + TenantID: tenant, UID: uid, + Purpose: enum.OTPPurposeBusinessEmail, + Target: email, +}) +if err != nil { return err } + +_, err = svc.Notifier.Send(ctx, ¬if.SendRequest{ + TenantID: tenant, UID: uid, + Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, + Target: email, Locale: locale, + Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, + IdempotencyKey: dto.ChallengeID, +}) +if err != nil { + _ = svc.MemberOTP.Invalidate(ctx, dto.ChallengeID) // 寄送失敗回滾 + return err +} +return dto // 回傳 challenge_id 給前端 + +// ── 確認驗證 ── +target, err := svc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{ + TenantID: tenant, UID: uid, + ChallengeID: req.ChallengeID, Code: req.Code, + Purpose: enum.OTPPurposeBusinessEmail, +}) +if err != nil { return err } + +return svc.MemberProfile.SetBusinessEmailVerified(ctx, tenant, uid, target) +``` + +`cmd/notify-test` 的 `startMemberVerify` 實作了發起驗證的前半段(Generate + Send),可作為 driver 參考: + +```bash +make deps-up +make notify-test METHOD=member-email TO=you@example.com +make notify-test METHOD=member-phone PHONE=0912345678 +``` + +--- + +## Redis Key 命名 + +| Key 前綴 | 用途 | +|----------|------| +| `member:otp:challenge:{id}` | OTP challenge 狀態 | +| `member:otp:challenge:{id}:attempts` | 錯誤次數計數 | +| `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 | +| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | +| `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret | +| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | + +Helper 函式見 `domain/redis.go`(`GetOTPChallengeRedisKey` 等)。 + +--- + +## 測試 + +### 單元測試 + +```bash +go test ./internal/model/member/... -v +make check +``` + +### 互動式 TOTP(Google Authenticator) + +本機需 Redis,並在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。 + +```bash +make deps-up +make totp-test +``` + +流程(單一 process,預設 `-step flow`): + +1. 終端機印出 **QR code** 與 **Secret key** +2. 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret) +3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼) +4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**(step-up 驗證) +5. 自動測試重放保護(同一碼再驗應失敗) + +進階: + +```bash +make totp-test STEP=status +make totp-test STEP=disable +make totp-test STEP=verify CODE=482913 +``` + +| 檔案 | 覆蓋 | +|------|------| +| `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock | +| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate | +| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL | +| `library/crypto/aesgcm_test.go` | TOTP secret 加解密 | + +--- + +## 尚未實作 + +- HTTP API / goctl handler(verify-email、verify-phone、totp enroll 等) +- Logic 層 confirm 流程(Verify + Profile flip + rate limit) +- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory) +- Step-up token 簽發(auth 模組) + +設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。 diff --git a/internal/model/member/config/config.go b/internal/model/member/config/config.go index c86b78a..ca6b394 100644 --- a/internal/model/member/config/config.go +++ b/internal/model/member/config/config.go @@ -2,9 +2,11 @@ package config // Config is member module settings (embedded in gateway root config). type Config struct { - OTP OTPConfig `json:",optional"` + OTP OTPConfig `json:",optional"` + TOTP TOTPConfig `json:",optional"` } +// OTPConfig governs the business OTP primitive (email/phone verification). type OTPConfig struct { Length int `json:",optional"` TTLSeconds int `json:",optional"` @@ -13,6 +15,24 @@ type OTPConfig struct { DailyVerifyLimit int `json:",optional"` } +// TOTPConfig governs business-tier RFC 6238 TOTP (step-up MFA). +// +// SecretKEK is the 32-byte master key used to AES-256-GCM encrypt member +// secrets at rest. Accepts hex (64 chars) or base64. Empty disables the +// TOTPUseCase wiring (factory returns an error). +type TOTPConfig struct { + Issuer string `json:",optional"` + Algorithm string `json:",optional"` + Digits int `json:",optional"` + PeriodSeconds int `json:",optional"` + Window int `json:",optional"` + BackupCodeCount int `json:",optional"` + BackupCodeLength int `json:",optional"` + EnrollTTLSeconds int `json:",optional"` + ReplayTTLSeconds int `json:",optional"` + SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"` +} + // Defaults returns zero-value-safe defaults. func (c Config) Defaults() Config { if c.OTP.Length <= 0 { @@ -30,5 +50,35 @@ func (c Config) Defaults() Config { if c.OTP.DailyVerifyLimit <= 0 { c.OTP.DailyVerifyLimit = 10 } + if c.TOTP.Issuer == "" { + c.TOTP.Issuer = "CloudEP" + } + if c.TOTP.Algorithm == "" { + c.TOTP.Algorithm = "SHA1" + } + if c.TOTP.Digits <= 0 { + c.TOTP.Digits = 6 + } + if c.TOTP.PeriodSeconds <= 0 { + c.TOTP.PeriodSeconds = 30 + } + if c.TOTP.Window < 0 { + c.TOTP.Window = 0 + } + if c.TOTP.Window == 0 { + c.TOTP.Window = 1 + } + if c.TOTP.BackupCodeCount <= 0 { + c.TOTP.BackupCodeCount = 10 + } + if c.TOTP.BackupCodeLength <= 0 { + c.TOTP.BackupCodeLength = 12 + } + if c.TOTP.EnrollTTLSeconds <= 0 { + c.TOTP.EnrollTTLSeconds = 600 + } + if c.TOTP.ReplayTTLSeconds <= 0 { + c.TOTP.ReplayTTLSeconds = 90 + } return c } diff --git a/internal/model/member/domain/errors.go b/internal/model/member/domain/errors.go new file mode 100644 index 0000000..8dcd2a9 --- /dev/null +++ b/internal/model/member/domain/errors.go @@ -0,0 +1,23 @@ +// Package domain holds the member module's domain-level definitions +// (errors, redis key helpers, entities, enums, repository/usecase +// interfaces). External callers should import the sub-packages they need; +// this file exposes module-wide sentinels that span layers. +package domain + +import "fmt" + +// Module-wide sentinel errors. They are intentionally untyped so callers +// wrap them with library/errors.Builder when surfacing to HTTP/RPC layers. +var ( + ErrNotFound = fmt.Errorf("member: not found") + ErrChallengeNotFound = fmt.Errorf("member: otp challenge not found") + ErrChallengeLocked = fmt.Errorf("member: otp challenge locked") + ErrInvalidOTP = fmt.Errorf("member: invalid otp code") + ErrResendCooldown = fmt.Errorf("member: resend cooldown active") + ErrDailyLimit = fmt.Errorf("member: daily verification limit exceeded") + ErrTOTPNotEnrolled = fmt.Errorf("member: totp not enrolled") + ErrTOTPEnrollMissing = fmt.Errorf("member: totp enroll secret missing or expired") + ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled") + ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code") + ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used") +) diff --git a/internal/model/member/domain/redis.go b/internal/model/member/domain/redis.go new file mode 100644 index 0000000..5b9433a --- /dev/null +++ b/internal/model/member/domain/redis.go @@ -0,0 +1,61 @@ +package domain + +import "strings" + +// RedisKey is the member module Redis key prefix. The package-level helpers +// (Get*RedisKey) are the supported way to compose keys; direct string +// concatenation should be avoided so the layout stays auditable. +type RedisKey string + +// Key prefixes for the member module. Layout matches identity-member-design.md +// section 14 (Redis Key 命名). +const ( + OTPChallengeRedisKey RedisKey = "member:otp:challenge" + VerifyRateRedisKey RedisKey = "member:verify:rate" + VerifyDailyRedisKey RedisKey = "member:verify:daily" + TOTPEnrollRedisKey RedisKey = "member:totp:enroll" + TOTPUsedRedisKey RedisKey = "member:totp:used" +) + +// With appends colon-separated parts to the key. +func (key RedisKey) With(parts ...string) RedisKey { + if len(parts) == 0 { + return key + } + return RedisKey(string(key) + ":" + strings.Join(parts, ":")) +} + +// String returns the raw key. +func (key RedisKey) String() string { + return string(key) +} + +// GetOTPChallengeRedisKey returns the challenge-state Redis key. +func GetOTPChallengeRedisKey(challengeID string) string { + return OTPChallengeRedisKey.With(challengeID).String() +} + +// GetOTPAttemptsRedisKey returns the per-challenge failed-attempt counter key. +func GetOTPAttemptsRedisKey(challengeID string) string { + return OTPChallengeRedisKey.With(challengeID, "attempts").String() +} + +// GetVerifyRateRedisKey returns the resend cooldown key. +func GetVerifyRateRedisKey(tenantID, uid, kind string) string { + return VerifyRateRedisKey.With(tenantID, uid, kind).String() +} + +// GetVerifyDailyRedisKey returns the daily verification cap counter key. +func GetVerifyDailyRedisKey(tenantID, uid, kind string) string { + return VerifyDailyRedisKey.With(tenantID, uid, kind).String() +} + +// GetTOTPEnrollRedisKey returns the staged-secret cache key. +func GetTOTPEnrollRedisKey(tenantID, uid string) string { + return TOTPEnrollRedisKey.With(tenantID, uid).String() +} + +// GetTOTPUsedRedisKey returns the replay-protection key for a verified timestep. +func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string { + return TOTPUsedRedisKey.With(tenantID, uid, timestep).String() +} diff --git a/internal/model/member/domain/repository/totp.go b/internal/model/member/domain/repository/totp.go new file mode 100644 index 0000000..717e851 --- /dev/null +++ b/internal/model/member/domain/repository/totp.go @@ -0,0 +1,50 @@ +package repository + +import ( + "context" + "time" +) + +// TOTPProfileRecord captures the persisted TOTP state for a member. +// +// SecretCipher is the AES-GCM blob; BackupCodesHash holds bcrypt hashes of +// the plaintext backup codes (single-use). +type TOTPProfileRecord struct { + Enrolled bool + SecretCipher []byte + BackupCodesHash []string + EnrolledAt int64 +} + +// TOTPProfileRepository persists per-member TOTP secrets and backup codes. +// +// Implementations must be safe to call concurrently for distinct (tenant, uid) +// pairs; concurrent calls for the same member should be linearised by the +// caller (the usecase layer) before mutation. +type TOTPProfileRepository interface { + Get(ctx context.Context, tenantID, uid string) (*TOTPProfileRecord, error) + Save(ctx context.Context, tenantID, uid string, rec *TOTPProfileRecord) error + Clear(ctx context.Context, tenantID, uid string) error + + // ConsumeBackupCode removes the supplied backup-code hash atomically and + // returns true when removal succeeded. + ConsumeBackupCode(ctx context.Context, tenantID, uid, hash string) (bool, error) + // ReplaceBackupCodes overwrites the backup-code hashes in one shot. + ReplaceBackupCodes(ctx context.Context, tenantID, uid string, hashes []string) error +} + +// TOTPEnrollStore stages a freshly generated secret until the user confirms +// the first code. The blob is short-lived (Redis TTL). +type TOTPEnrollStore interface { + Save(ctx context.Context, tenantID, uid string, cipher []byte, ttl time.Duration) error + Get(ctx context.Context, tenantID, uid string) ([]byte, error) + Delete(ctx context.Context, tenantID, uid string) error +} + +// TOTPReplayStore prevents replay of a successful TOTP code within its +// allowed verification window. +type TOTPReplayStore interface { + // MarkUsed returns true when the key was newly recorded (i.e. the code + // had not been used yet); false means it was already consumed. + MarkUsed(ctx context.Context, tenantID, uid string, timestep uint64, ttl time.Duration) (bool, error) +} diff --git a/internal/model/member/domain/usecase/totp.go b/internal/model/member/domain/usecase/totp.go new file mode 100644 index 0000000..feb737f --- /dev/null +++ b/internal/model/member/domain/usecase/totp.go @@ -0,0 +1,54 @@ +package usecase + +import "context" + +// TOTPUseCase manages business-tier RFC 6238 TOTP enrollment and verification. +// +// The contract mirrors identity-member-design.md §5.8: enrollment is split in +// two steps (start → confirm) so the secret is only committed after the user +// proves possession; backup codes are returned exactly once on confirmation +// and replenished via RegenerateBackupCodes. +type TOTPUseCase interface { + // StartEnroll generates a fresh secret, stashes it in a short-lived cache, + // and returns the otpauth URL for QR rendering. Calling it twice replaces + // the staged secret. + StartEnroll(ctx context.Context, tenantID, uid, account string) (*EnrollStartDTO, error) + + // ConfirmEnroll validates the first user-supplied code against the staged + // secret, persists the encrypted secret on the profile, and returns the + // plaintext backup codes (only returned here once). + ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) + + // VerifyCode validates a code (TOTP or backup) for step-up. Backup codes + // are single-use; replays of a successful TOTP code are rejected. + VerifyCode(ctx context.Context, tenantID, uid, code string) error + + // Disable clears the secret and backup codes; intended to be guarded by a + // step-up token in the logic layer. + Disable(ctx context.Context, tenantID, uid string) error + + // RegenerateBackupCodes replaces existing backup codes with a new batch. + RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) + + // Status reports the current enrollment state for UI/admin views. + Status(ctx context.Context, tenantID, uid string) (*TOTPStatusDTO, error) +} + +// EnrollStartDTO is returned by TOTPUseCase.StartEnroll. Secret material is +// never returned in plaintext outside the otpauth URL. +type EnrollStartDTO struct { + OtpauthURL string `json:"otpauth_url"` + Issuer string `json:"issuer"` + Account string `json:"account"` + Digits int `json:"digits"` + PeriodSec int `json:"period_seconds"` + ExpiresIn int `json:"expires_in"` +} + +// TOTPStatusDTO reports whether TOTP is active for the member and how many +// backup codes remain unused. +type TOTPStatusDTO struct { + Enrolled bool `json:"enrolled"` + EnrolledAt int64 `json:"enrolled_at,omitempty"` + BackupCodesRemaining int `json:"backup_codes_remaining"` +} diff --git a/internal/model/member/domain/usecase/verification.go b/internal/model/member/domain/usecase/verification.go deleted file mode 100644 index 2e569d7..0000000 --- a/internal/model/member/domain/usecase/verification.go +++ /dev/null @@ -1,11 +0,0 @@ -package usecase - -import "context" - -// VerificationUseCase composes OTP + Notifier for business email/phone verification. -type VerificationUseCase interface { - StartEmailVerify(ctx context.Context, tenantID, uid, target, locale string) (*OTPChallengeDTO, error) - ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error - StartPhoneVerify(ctx context.Context, tenantID, uid, target, locale string) (*OTPChallengeDTO, error) - ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error -} diff --git a/internal/model/member/errors.go b/internal/model/member/errors.go deleted file mode 100644 index 8ffb118..0000000 --- a/internal/model/member/errors.go +++ /dev/null @@ -1,12 +0,0 @@ -package member - -import "fmt" - -var ( - ErrNotFound = fmt.Errorf("member: not found") - ErrChallengeNotFound = fmt.Errorf("member: otp challenge not found") - ErrChallengeLocked = fmt.Errorf("member: otp challenge locked") - ErrInvalidOTP = fmt.Errorf("member: invalid otp code") - ErrResendCooldown = fmt.Errorf("member: resend cooldown active") - ErrDailyLimit = fmt.Errorf("member: daily verification limit exceeded") -) diff --git a/internal/model/member/redis.go b/internal/model/member/redis.go deleted file mode 100644 index db82698..0000000 --- a/internal/model/member/redis.go +++ /dev/null @@ -1,39 +0,0 @@ -package member - -import "strings" - -// RedisKey is the member module key prefix. -type RedisKey string - -const ( - OTPChallengeRedisKey RedisKey = "member:otp:challenge" - VerifyRateRedisKey RedisKey = "member:verify:rate" - VerifyDailyRedisKey RedisKey = "member:verify:daily" -) - -func (key RedisKey) With(parts ...string) RedisKey { - if len(parts) == 0 { - return key - } - return RedisKey(string(key) + ":" + strings.Join(parts, ":")) -} - -func (key RedisKey) String() string { - return string(key) -} - -func GetOTPChallengeRedisKey(challengeID string) string { - return OTPChallengeRedisKey.With(challengeID).String() -} - -func GetOTPAttemptsRedisKey(challengeID string) string { - return OTPChallengeRedisKey.With(challengeID, "attempts").String() -} - -func GetVerifyRateRedisKey(tenantID, uid, kind string) string { - return VerifyRateRedisKey.With(tenantID, uid, kind).String() -} - -func GetVerifyDailyRedisKey(tenantID, uid, kind string) string { - return VerifyDailyRedisKey.With(tenantID, uid, kind).String() -} diff --git a/internal/model/member/repository/otp_store_redis.go b/internal/model/member/repository/otp_store_redis.go index 57354f5..b1657bb 100644 --- a/internal/model/member/repository/otp_store_redis.go +++ b/internal/model/member/repository/otp_store_redis.go @@ -9,7 +9,7 @@ import ( "time" redislib "gateway/internal/library/redis" - "gateway/internal/model/member" + member "gateway/internal/model/member/domain" domrepo "gateway/internal/model/member/domain/repository" "github.com/zeromicro/go-zero/core/stores/redis" diff --git a/internal/model/member/repository/totp_profile_memory.go b/internal/model/member/repository/totp_profile_memory.go new file mode 100644 index 0000000..9c49d2d --- /dev/null +++ b/internal/model/member/repository/totp_profile_memory.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + "sync" + + domrepo "gateway/internal/model/member/domain/repository" +) + +// MemoryTOTPProfileRepository keeps TOTP state in-process; placeholder until +// the P4 Mongo entity lands. The struct is safe for concurrent use. +type MemoryTOTPProfileRepository struct { + mu sync.Mutex + records map[string]*domrepo.TOTPProfileRecord +} + +// NewMemoryTOTPProfileRepository allocates an empty MemoryTOTPProfileRepository. +func NewMemoryTOTPProfileRepository() *MemoryTOTPProfileRepository { + return &MemoryTOTPProfileRepository{records: make(map[string]*domrepo.TOTPProfileRecord)} +} + +func (r *MemoryTOTPProfileRepository) Get(_ context.Context, tenantID, uid string) (*domrepo.TOTPProfileRecord, error) { + r.mu.Lock() + defer r.mu.Unlock() + rec, ok := r.records[totpProfileKey(tenantID, uid)] + if !ok { + return &domrepo.TOTPProfileRecord{}, nil + } + return cloneTOTPRecord(rec), nil +} + +func (r *MemoryTOTPProfileRepository) Save(_ context.Context, tenantID, uid string, rec *domrepo.TOTPProfileRecord) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records[totpProfileKey(tenantID, uid)] = cloneTOTPRecord(rec) + return nil +} + +func (r *MemoryTOTPProfileRepository) Clear(_ context.Context, tenantID, uid string) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.records, totpProfileKey(tenantID, uid)) + return nil +} + +func (r *MemoryTOTPProfileRepository) ConsumeBackupCode(_ context.Context, tenantID, uid, hash string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + rec, ok := r.records[totpProfileKey(tenantID, uid)] + if !ok { + return false, nil + } + idx := -1 + for i, h := range rec.BackupCodesHash { + if h == hash { + idx = i + break + } + } + if idx < 0 { + return false, nil + } + rec.BackupCodesHash = append(rec.BackupCodesHash[:idx], rec.BackupCodesHash[idx+1:]...) + return true, nil +} + +func (r *MemoryTOTPProfileRepository) ReplaceBackupCodes(_ context.Context, tenantID, uid string, hashes []string) error { + r.mu.Lock() + defer r.mu.Unlock() + rec, ok := r.records[totpProfileKey(tenantID, uid)] + if !ok { + rec = &domrepo.TOTPProfileRecord{} + r.records[totpProfileKey(tenantID, uid)] = rec + } + rec.BackupCodesHash = append([]string(nil), hashes...) + return nil +} + +func totpProfileKey(tenantID, uid string) string { + return tenantID + ":" + uid +} + +func cloneTOTPRecord(in *domrepo.TOTPProfileRecord) *domrepo.TOTPProfileRecord { + if in == nil { + return &domrepo.TOTPProfileRecord{} + } + out := *in + if in.SecretCipher != nil { + out.SecretCipher = append([]byte(nil), in.SecretCipher...) + } + if in.BackupCodesHash != nil { + out.BackupCodesHash = append([]string(nil), in.BackupCodesHash...) + } + return &out +} + +var _ domrepo.TOTPProfileRepository = (*MemoryTOTPProfileRepository)(nil) diff --git a/internal/model/member/repository/totp_store_redis.go b/internal/model/member/repository/totp_store_redis.go new file mode 100644 index 0000000..ba82a3a --- /dev/null +++ b/internal/model/member/repository/totp_store_redis.go @@ -0,0 +1,79 @@ +package repository + +import ( + "context" + "encoding/base64" + "errors" + "strconv" + "time" + + "github.com/zeromicro/go-zero/core/stores/redis" + + redislib "gateway/internal/library/redis" + member "gateway/internal/model/member/domain" + domrepo "gateway/internal/model/member/domain/repository" +) + +// NewRedisTOTPEnrollStore builds the staged-secret cache used during TOTP +// enrollment. Secrets are stored as opaque cipher blobs base64-encoded. +func NewRedisTOTPEnrollStore(client *redislib.Client) domrepo.TOTPEnrollStore { + if client == nil || client.Zero() == nil { + panic("member: redis client is required for totp enroll store") + } + return &redisTOTPEnrollStore{client: client.Zero()} +} + +type redisTOTPEnrollStore struct { + client *redis.Redis +} + +func (s *redisTOTPEnrollStore) Save(ctx context.Context, tenantID, uid string, cipher []byte, ttl time.Duration) error { + seconds := int(ttl.Seconds()) + if seconds < 1 { + seconds = 1 + } + encoded := base64.RawStdEncoding.EncodeToString(cipher) + return s.client.SetexCtx(ctx, member.GetTOTPEnrollRedisKey(tenantID, uid), encoded, seconds) +} + +func (s *redisTOTPEnrollStore) Get(ctx context.Context, tenantID, uid string) ([]byte, error) { + val, err := s.client.GetCtx(ctx, member.GetTOTPEnrollRedisKey(tenantID, uid)) + if errors.Is(err, redis.Nil) || val == "" { + return nil, member.ErrTOTPEnrollMissing + } + if err != nil { + return nil, err + } + blob, err := base64.RawStdEncoding.DecodeString(val) + if err != nil { + return nil, err + } + return blob, nil +} + +func (s *redisTOTPEnrollStore) Delete(ctx context.Context, tenantID, uid string) error { + _, err := s.client.DelCtx(ctx, member.GetTOTPEnrollRedisKey(tenantID, uid)) + return err +} + +// NewRedisTOTPReplayStore prevents replay of a successful TOTP code within +// its allowed window via SETNX on the (tenant,uid,timestep) key. +func NewRedisTOTPReplayStore(client *redislib.Client) domrepo.TOTPReplayStore { + if client == nil || client.Zero() == nil { + panic("member: redis client is required for totp replay store") + } + return &redisTOTPReplayStore{client: client.Zero()} +} + +type redisTOTPReplayStore struct { + client *redis.Redis +} + +func (s *redisTOTPReplayStore) MarkUsed(ctx context.Context, tenantID, uid string, timestep uint64, ttl time.Duration) (bool, error) { + seconds := int(ttl.Seconds()) + if seconds < 1 { + seconds = 1 + } + key := member.GetTOTPUsedRedisKey(tenantID, uid, strconv.FormatUint(timestep, 10)) + return s.client.SetnxExCtx(ctx, key, "1", seconds) +} diff --git a/internal/model/member/totp/totp.go b/internal/model/member/totp/totp.go new file mode 100644 index 0000000..872491b --- /dev/null +++ b/internal/model/member/totp/totp.go @@ -0,0 +1,247 @@ +// Package totp implements RFC 6238 Time-based One-Time Password generation +// and verification helpers used by the member step-up MFA flow. +// +// Algorithm constraints (compatible with Google Authenticator / Authy / +// 1Password / Microsoft Authenticator): +// - HMAC-SHA1 +// - 30-second period +// - 6 digits +// +// Callers may verify across a small window (typically plus or minus one step) +// to tolerate device clock drift. +package totp + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" //nolint:gosec // RFC 6238 requires HMAC-SHA1 + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "encoding/hex" + "fmt" + "net/url" + "strings" + "time" +) + +// Defaults match the configuration documented in identity-member-design.md +// section 5.8 / etc/gateway.yaml TOTP block. +const ( + DefaultDigits = 6 + DefaultPeriod = 30 * time.Second + DefaultWindow = 1 + SecretBytes = 20 + BackupCodeSize = 12 + BackupCodeNum = 10 +) + +// Sentinel errors so callers can distinguish failure modes. +var ( + ErrInvalidSecret = fmt.Errorf("totp: invalid secret") + ErrInvalidCode = fmt.Errorf("totp: invalid code") + ErrInvalidDigits = fmt.Errorf("totp: invalid digit length") + ErrInvalidIssuer = fmt.Errorf("totp: issuer must be non-empty") + ErrInvalidAccount = fmt.Errorf("totp: account must be non-empty") +) + +// GenerateSecret returns a random 160-bit secret suitable for RFC 4226 / 6238. +func GenerateSecret() ([]byte, error) { + buf := make([]byte, SecretBytes) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("totp: read random: %w", err) + } + return buf, nil +} + +// EncodeSecret returns the base32 (no padding) representation typically +// embedded into otpauth URLs. +func EncodeSecret(secret []byte) string { + return strings.TrimRight(base32.StdEncoding.EncodeToString(secret), "=") +} + +// DecodeSecret parses the base32 representation back to raw bytes. Padding is +// optional so the function accepts the canonical form used in otpauth URLs. +func DecodeSecret(encoded string) ([]byte, error) { + if encoded == "" { + return nil, ErrInvalidSecret + } + clean := strings.ToUpper(strings.TrimSpace(encoded)) + clean = strings.TrimRight(clean, "=") + if mod := len(clean) % 8; mod != 0 { + clean += strings.Repeat("=", 8-mod) + } + out, err := base32.StdEncoding.DecodeString(clean) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidSecret, err) + } + return out, nil +} + +// Generate returns the TOTP code for the given timestamp. +func Generate(secret []byte, ts time.Time, period time.Duration, digits int) (string, error) { + if len(secret) == 0 { + return "", ErrInvalidSecret + } + if digits <= 0 || digits > 10 { + return "", ErrInvalidDigits + } + if period <= 0 { + period = DefaultPeriod + } + return computeHOTP(secret, unixCounter(ts, period), digits), nil +} + +// Verify checks the supplied code against the secret allowing for plus or +// minus window time steps. When the code is valid it returns the timestep +// counter that matched so callers can persist it for replay protection. +func Verify(secret []byte, code string, ts time.Time, period time.Duration, digits, window int) (uint64, bool) { + if len(secret) == 0 || code == "" || digits <= 0 { + return 0, false + } + if len(code) != digits { + return 0, false + } + if period <= 0 { + period = DefaultPeriod + } + if window < 0 { + window = 0 + } + base := unixCounter(ts, period) + want := []byte(code) + for i := -window; i <= window; i++ { + counter, ok := shiftCounter(base, i) + if !ok { + continue + } + got := []byte(computeHOTP(secret, counter, digits)) + if subtle.ConstantTimeCompare(want, got) == 1 { + return counter, true + } + } + return 0, false +} + +// shiftCounter applies a signed offset to base without crossing 0. +// Returning (0, false) signals that the requested offset is out of range. +func shiftCounter(base uint64, offset int) (uint64, bool) { + if offset >= 0 { + return base + uint64(offset), true + } + neg := uint64(-offset) //nolint:gosec // offset is bounded by ±Window; magnitude fits uint64. + if base < neg { + return 0, false + } + return base - neg, true +} + +func computeHOTP(secret []byte, counter uint64, digits int) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + mac := hmac.New(sha1.New, secret) + mac.Write(buf[:]) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0F + bin := (uint32(sum[offset]&0x7F) << 24) | + (uint32(sum[offset+1]) << 16) | + (uint32(sum[offset+2]) << 8) | + uint32(sum[offset+3]) + mod := uint32(1) + for range digits { + mod *= 10 + } + num := bin % mod + return fmt.Sprintf("%0*d", digits, num) +} + +// OtpauthURLInput controls the otpauth URL fields shown to users. +type OtpauthURLInput struct { + Issuer string + Account string + Secret []byte + Algorithm string + Digits int + Period time.Duration +} + +// BuildOtpauthURL renders the canonical otpauth://totp/... URL. +func BuildOtpauthURL(in OtpauthURLInput) (string, error) { + if in.Issuer == "" { + return "", ErrInvalidIssuer + } + if in.Account == "" { + return "", ErrInvalidAccount + } + if len(in.Secret) == 0 { + return "", ErrInvalidSecret + } + algo := in.Algorithm + if algo == "" { + algo = "SHA1" + } + digits := in.Digits + if digits <= 0 { + digits = DefaultDigits + } + period := in.Period + if period <= 0 { + period = DefaultPeriod + } + + q := url.Values{} + q.Set("secret", EncodeSecret(in.Secret)) + q.Set("issuer", in.Issuer) + q.Set("algorithm", algo) + q.Set("digits", fmt.Sprintf("%d", digits)) + q.Set("period", fmt.Sprintf("%d", int(period.Seconds()))) + + label := url.PathEscape(in.Issuer) + ":" + url.PathEscape(in.Account) + return "otpauth://totp/" + label + "?" + q.Encode(), nil +} + +// GenerateBackupCodes returns n random hex-encoded codes of the given length. +// They are returned in plaintext; callers must hash before storage. +func GenerateBackupCodes(n, length int) ([]string, error) { + if n <= 0 { + n = BackupCodeNum + } + if length <= 0 { + length = BackupCodeSize + } + if length%2 != 0 { + length++ + } + out := make([]string, 0, n) + buf := make([]byte, length/2) + for range n { + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("totp: backup code rand: %w", err) + } + out = append(out, hex.EncodeToString(buf)) + } + return out, nil +} + +// TimeStep returns the integer time-step counter used by Verify, useful for +// replay-protection keys. +func TimeStep(ts time.Time, period time.Duration) uint64 { + if period <= 0 { + period = DefaultPeriod + } + return unixCounter(ts, period) +} + +// unixCounter converts (ts, period) to the RFC 6238 counter, clamping +// negative timestamps to zero so the int64→uint64 conversion is bounded. +func unixCounter(ts time.Time, period time.Duration) uint64 { + sec := ts.Unix() + if sec < 0 { + sec = 0 + } + per := int64(period.Seconds()) + if per <= 0 { + per = int64(DefaultPeriod.Seconds()) + } + return uint64(sec / per) //nolint:gosec // sec is non-negative and per is positive; quotient fits uint64. +} diff --git a/internal/model/member/totp/totp_test.go b/internal/model/member/totp/totp_test.go new file mode 100644 index 0000000..2bad905 --- /dev/null +++ b/internal/model/member/totp/totp_test.go @@ -0,0 +1,153 @@ +package totp_test + +import ( + "encoding/base32" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "gateway/internal/model/member/totp" +) + +// RFC 6238 Appendix B test vector (HMAC-SHA1, 20-byte ASCII "12345678901234567890"). +// Time 59 → expected 8-digit value 94287082; for 6 digits the truncation is 287082. +func TestGenerate_RFC6238Vector(t *testing.T) { + secret := []byte("12345678901234567890") + code, err := totp.Generate(secret, time.Unix(59, 0), 30*time.Second, 6) + require.NoError(t, err) + require.Equal(t, "287082", code) + + code, err = totp.Generate(secret, time.Unix(1111111109, 0), 30*time.Second, 6) + require.NoError(t, err) + require.Equal(t, "081804", code) +} + +func TestVerify_WindowAccepts(t *testing.T) { + secret := []byte("12345678901234567890") + ts := time.Unix(59, 0) + code, err := totp.Generate(secret, ts, 30*time.Second, 6) + require.NoError(t, err) + + step, ok := totp.Verify(secret, code, ts.Add(15*time.Second), 30*time.Second, 6, 1) + require.True(t, ok) + require.Equal(t, totp.TimeStep(ts, 30*time.Second), step) +} + +func TestVerify_WindowRejectsBeyond(t *testing.T) { + secret := []byte("12345678901234567890") + ts := time.Unix(59, 0) + code, err := totp.Generate(secret, ts, 30*time.Second, 6) + require.NoError(t, err) + + _, ok := totp.Verify(secret, code, ts.Add(2*time.Minute), 30*time.Second, 6, 1) + require.False(t, ok) +} + +func TestVerify_InvalidInputs(t *testing.T) { + _, ok := totp.Verify(nil, "123456", time.Now(), 30*time.Second, 6, 1) + require.False(t, ok) + _, ok = totp.Verify([]byte("k"), "", time.Now(), 30*time.Second, 6, 1) + require.False(t, ok) + _, ok = totp.Verify([]byte("k"), "12345", time.Now(), 30*time.Second, 6, 1) + require.False(t, ok) +} + +func TestGenerateSecret(t *testing.T) { + s1, err := totp.GenerateSecret() + require.NoError(t, err) + require.Len(t, s1, totp.SecretBytes) + + s2, err := totp.GenerateSecret() + require.NoError(t, err) + require.NotEqual(t, s1, s2) +} + +func TestEncodeDecodeSecret_RoundTrip(t *testing.T) { + secret, err := totp.GenerateSecret() + require.NoError(t, err) + + encoded := totp.EncodeSecret(secret) + require.NotContains(t, encoded, "=") + + out, err := totp.DecodeSecret(encoded) + require.NoError(t, err) + require.Equal(t, secret, out) + + padded := encoded + if mod := len(padded) % 8; mod != 0 { + padded += strings.Repeat("=", 8-mod) + } + require.Equal(t, secret, mustDecodeBase32(t, padded)) +} + +func mustDecodeBase32(t *testing.T, s string) []byte { + t.Helper() + b, err := base32.StdEncoding.DecodeString(s) + require.NoError(t, err) + return b +} + +func TestDecodeSecret_Invalid(t *testing.T) { + _, err := totp.DecodeSecret("") + require.ErrorIs(t, err, totp.ErrInvalidSecret) + + _, err = totp.DecodeSecret("not!base32!!") + require.ErrorIs(t, err, totp.ErrInvalidSecret) +} + +func TestBuildOtpauthURL(t *testing.T) { + secret := []byte("12345678901234567890") + u, err := totp.BuildOtpauthURL(totp.OtpauthURLInput{ + Issuer: "CloudEP", + Account: "ACME:AMEX-10000000", + Secret: secret, + }) + require.NoError(t, err) + + parsed, err := url.Parse(u) + require.NoError(t, err) + require.Equal(t, "otpauth", parsed.Scheme) + require.Equal(t, "totp", parsed.Host) + require.Contains(t, parsed.Path, "CloudEP:") + + q := parsed.Query() + require.Equal(t, totp.EncodeSecret(secret), q.Get("secret")) + require.Equal(t, "CloudEP", q.Get("issuer")) + require.Equal(t, "SHA1", q.Get("algorithm")) + require.Equal(t, "6", q.Get("digits")) + require.Equal(t, "30", q.Get("period")) +} + +func TestBuildOtpauthURL_Validation(t *testing.T) { + _, err := totp.BuildOtpauthURL(totp.OtpauthURLInput{Issuer: "", Account: "a", Secret: []byte("s")}) + require.ErrorIs(t, err, totp.ErrInvalidIssuer) + + _, err = totp.BuildOtpauthURL(totp.OtpauthURLInput{Issuer: "x", Account: "", Secret: []byte("s")}) + require.ErrorIs(t, err, totp.ErrInvalidAccount) + + _, err = totp.BuildOtpauthURL(totp.OtpauthURLInput{Issuer: "x", Account: "a", Secret: nil}) + require.ErrorIs(t, err, totp.ErrInvalidSecret) +} + +func TestGenerateBackupCodes(t *testing.T) { + codes, err := totp.GenerateBackupCodes(10, 12) + require.NoError(t, err) + require.Len(t, codes, 10) + + seen := make(map[string]struct{}, len(codes)) + for _, c := range codes { + require.Len(t, c, 12) + seen[c] = struct{}{} + } + require.Len(t, seen, len(codes), "backup codes should be unique") +} + +func TestTimeStep(t *testing.T) { + period := 30 * time.Second + require.Equal(t, uint64(2), totp.TimeStep(time.Unix(60, 0), period)) + require.Equal(t, uint64(2), totp.TimeStep(time.Unix(89, 0), period)) + require.Equal(t, uint64(3), totp.TimeStep(time.Unix(90, 0), period)) +} diff --git a/internal/model/member/usecase/module.go b/internal/model/member/usecase/module.go index 64bc15d..a0a5571 100644 --- a/internal/model/member/usecase/module.go +++ b/internal/model/member/usecase/module.go @@ -3,36 +3,52 @@ package usecase import ( "fmt" + libcrypto "gateway/internal/library/crypto" redislib "gateway/internal/library/redis" memberconfig "gateway/internal/model/member/config" domrepo "gateway/internal/model/member/domain/repository" domusecase "gateway/internal/model/member/domain/usecase" "gateway/internal/model/member/repository" - domnotif "gateway/internal/model/notification/domain/usecase" ) -// Module bundles member use cases. +// Module bundles member atomic primitives. Each entry is a single-purpose +// usecase; composite flows (e.g. "send verification email then mark +// business_email verified") are assembled at the logic / driver layer and +// MUST NOT live inside another usecase. type Module struct { - OTP domusecase.OTPUseCase - Verification domusecase.VerificationUseCase + // OTP issues and verifies one-time codes (purpose-agnostic). + OTP domusecase.OTPUseCase + // TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream + // code must gracefully degrade (e.g. fall back to SMS/email OTP at the + // logic layer). + TOTP domusecase.TOTPUseCase + + // Stores exposed for logic-layer orchestration (rate limit, profile + // flips). They are intentionally surfaced so the logic layer can compose + // atomic usecases with rate-limit + profile mutation without re-wiring. + VerifyRate domrepo.VerifyRateStore + Profile domrepo.ProfileRepository } // ModuleParam wires member module dependencies. type ModuleParam struct { - Redis *redislib.Client - Notifier domnotif.NotifierUseCase - Config memberconfig.Config - Profile domrepo.ProfileRepository // optional; defaults to memory + Redis *redislib.Client + Config memberconfig.Config + // Profile is optional; defaults to memory repository. + Profile domrepo.ProfileRepository + // TOTPProfile is optional; defaults to memory repository. + TOTPProfile domrepo.TOTPProfileRepository } -// NewModuleFromParam builds member use cases. +// NewModuleFromParam builds member atomic usecases. +// +// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local +// dev / unit tests boot without a KMS-backed key while production deployments +// fail loud when the operator forgets to configure it. func NewModuleFromParam(param ModuleParam) (*Module, error) { if param.Redis == nil || param.Redis.Zero() == nil { return nil, fmt.Errorf("member: redis is required") } - if param.Notifier == nil { - return nil, fmt.Errorf("member: notifier is required") - } otpStore := repository.NewRedisOTPChallengeStore(param.Redis) rateStore := repository.NewRedisVerifyRateStore(param.Redis) @@ -42,14 +58,29 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) { } cfg := param.Config.Defaults() - otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}) - verificationUC := MustVerificationUseCase(VerificationUseCaseParam{ - OTP: otpUC, - Notifier: param.Notifier, - Profile: profile, - Rates: rateStore, - Config: cfg, - }) + mod := &Module{ + OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}), + VerifyRate: rateStore, + Profile: profile, + } - return &Module{OTP: otpUC, Verification: verificationUC}, nil + if cfg.TOTP.SecretKEK != "" { + cipher, err := libcrypto.NewAESGCMFromString(cfg.TOTP.SecretKEK) + if err != nil { + return nil, fmt.Errorf("member: totp kek: %w", err) + } + totpProfile := param.TOTPProfile + if totpProfile == nil { + totpProfile = repository.NewMemoryTOTPProfileRepository() + } + mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{ + Profile: totpProfile, + Enroll: repository.NewRedisTOTPEnrollStore(param.Redis), + Replay: repository.NewRedisTOTPReplayStore(param.Redis), + Cipher: cipher, + Config: cfg, + }) + } + + return mod, nil } diff --git a/internal/model/member/usecase/otp_usecase.go b/internal/model/member/usecase/otp_usecase.go index 5e07dd8..fa5ed5d 100644 --- a/internal/model/member/usecase/otp_usecase.go +++ b/internal/model/member/usecase/otp_usecase.go @@ -12,8 +12,8 @@ import ( errs "gateway/internal/library/errors" "gateway/internal/library/errors/code" - "gateway/internal/model/member" memberconfig "gateway/internal/model/member/config" + member "gateway/internal/model/member/domain" domrepo "gateway/internal/model/member/domain/repository" domusecase "gateway/internal/model/member/domain/usecase" ) diff --git a/internal/model/member/usecase/otp_usecase_test.go b/internal/model/member/usecase/otp_usecase_test.go index 7a1fc3e..6deaf1e 100644 --- a/internal/model/member/usecase/otp_usecase_test.go +++ b/internal/model/member/usecase/otp_usecase_test.go @@ -19,7 +19,7 @@ import ( func TestOTPUseCase_GenerateAndVerify(t *testing.T) { mr := miniredis.RunT(t) - rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"}) + rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode}) require.NoError(t, err) uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{ @@ -31,7 +31,7 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) { TenantID: "t1", UID: "u1", Purpose: enum.OTPPurposeBusinessEmail, - Target: "user@example.com", + Target: testUserEmail, }) require.NoError(t, err) require.NotEmpty(t, code) @@ -44,12 +44,12 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) { Purpose: enum.OTPPurposeBusinessEmail, }) require.NoError(t, err) - require.Equal(t, "user@example.com", target) + require.Equal(t, testUserEmail, target) } func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) { mr := miniredis.RunT(t) - rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"}) + rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode}) require.NoError(t, err) uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{ @@ -61,7 +61,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) { TenantID: "t1", UID: "victim", Purpose: enum.OTPPurposeBusinessEmail, - Target: "user@example.com", + Target: testUserEmail, }) require.NoError(t, err) @@ -77,7 +77,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) { func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) { mr := miniredis.RunT(t) - rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"}) + rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode}) require.NoError(t, err) cfg := memberconfig.Config{}.Defaults() @@ -92,7 +92,7 @@ func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) { TenantID: "t1", UID: "u1", Purpose: enum.OTPPurposeBusinessEmail, - Target: "user@example.com", + Target: testUserEmail, }) require.NoError(t, err) diff --git a/internal/model/member/usecase/test_helpers_test.go b/internal/model/member/usecase/test_helpers_test.go new file mode 100644 index 0000000..2215623 --- /dev/null +++ b/internal/model/member/usecase/test_helpers_test.go @@ -0,0 +1,6 @@ +package usecase_test + +const ( + testRedisTypeNode = "node" + testUserEmail = "user@example.com" +) diff --git a/internal/model/member/usecase/totp_usecase.go b/internal/model/member/usecase/totp_usecase.go new file mode 100644 index 0000000..ecb6dff --- /dev/null +++ b/internal/model/member/usecase/totp_usecase.go @@ -0,0 +1,289 @@ +package usecase + +import ( + "context" + "errors" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + + "gateway/internal/library/crypto" + memberconfig "gateway/internal/model/member/config" + member "gateway/internal/model/member/domain" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/model/member/totp" +) + +// TOTPUseCaseParam wires TOTPUseCase dependencies. Cipher is mandatory; the +// factory rejects construction when the KEK is missing or invalid. +type TOTPUseCaseParam struct { + Profile domrepo.TOTPProfileRepository + Enroll domrepo.TOTPEnrollStore + Replay domrepo.TOTPReplayStore + Cipher *crypto.Cipher + Config memberconfig.Config + Now func() time.Time +} + +// MustTOTPUseCase constructs a TOTPUseCase. All collaborators must be non-nil +// (the wiring layer is responsible for validating SecretKEK before calling). +func MustTOTPUseCase(param TOTPUseCaseParam) domusecase.TOTPUseCase { + if param.Profile == nil { + panic("member: totp profile repository is required") + } + if param.Enroll == nil { + panic("member: totp enroll store is required") + } + if param.Replay == nil { + panic("member: totp replay store is required") + } + if param.Cipher == nil { + panic("member: totp cipher is required") + } + now := param.Now + if now == nil { + now = time.Now + } + return &totpUseCase{ + profile: param.Profile, + enroll: param.Enroll, + replay: param.Replay, + cipher: param.Cipher, + config: param.Config.Defaults(), + now: now, + } +} + +type totpUseCase struct { + profile domrepo.TOTPProfileRepository + enroll domrepo.TOTPEnrollStore + replay domrepo.TOTPReplayStore + cipher *crypto.Cipher + config memberconfig.Config + now func() time.Time +} + +func (uc *totpUseCase) StartEnroll(ctx context.Context, tenantID, uid, account string) (*domusecase.EnrollStartDTO, error) { + if tenantID == "" || uid == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.profile.Get(ctx, tenantID, uid) + if err != nil { + return nil, errb.SysInternal("read totp profile failed").WithCause(err) + } + if rec != nil && rec.Enrolled { + return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll) + } + + secret, err := totp.GenerateSecret() + if err != nil { + return nil, errb.SysInternal("totp secret generation failed").WithCause(err) + } + cipherBlob, err := uc.cipher.Encrypt(secret) + if err != nil { + return nil, errb.SysInternal("totp secret encrypt failed").WithCause(err) + } + ttl := time.Duration(uc.config.TOTP.EnrollTTLSeconds) * time.Second + if err := uc.enroll.Save(ctx, tenantID, uid, cipherBlob, ttl); err != nil { + return nil, errb.SysInternal("totp enroll persist failed").WithCause(err) + } + + accountLabel := account + if accountLabel == "" { + accountLabel = uid + } + otpURL, err := totp.BuildOtpauthURL(totp.OtpauthURLInput{ + Issuer: uc.config.TOTP.Issuer, + Account: accountLabel, + Secret: secret, + Algorithm: uc.config.TOTP.Algorithm, + Digits: uc.config.TOTP.Digits, + Period: time.Duration(uc.config.TOTP.PeriodSeconds) * time.Second, + }) + if err != nil { + return nil, errb.SysInternal("totp otpauth url build failed").WithCause(err) + } + + return &domusecase.EnrollStartDTO{ + OtpauthURL: otpURL, + Issuer: uc.config.TOTP.Issuer, + Account: accountLabel, + Digits: uc.config.TOTP.Digits, + PeriodSec: uc.config.TOTP.PeriodSeconds, + ExpiresIn: uc.config.TOTP.EnrollTTLSeconds, + }, nil +} + +func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) { + if tenantID == "" || uid == "" || code == "" { + return nil, errb.InputMissingRequired("tenant_id, uid and code are required") + } + rec, err := uc.profile.Get(ctx, tenantID, uid) + if err != nil { + return nil, errb.SysInternal("read totp profile failed").WithCause(err) + } + if rec != nil && rec.Enrolled { + return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll) + } + + cipherBlob, err := uc.enroll.Get(ctx, tenantID, uid) + if err != nil { + if errors.Is(err, member.ErrTOTPEnrollMissing) { + return nil, errb.ResNotFound("totp enroll", uid).WithCause(err) + } + return nil, errb.SysInternal("read totp enroll failed").WithCause(err) + } + secret, err := uc.cipher.Decrypt(cipherBlob) + if err != nil { + return nil, errb.SysInternal("totp secret decrypt failed").WithCause(err) + } + + if _, ok := totp.Verify(secret, code, uc.now(), uc.period(), uc.config.TOTP.Digits, uc.config.TOTP.Window); !ok { + return nil, errb.AuthForbidden("invalid totp code").WithCause(member.ErrTOTPInvalidCode) + } + + plainCodes, hashes, err := uc.generateBackupCodes() + if err != nil { + return nil, err + } + + if err := uc.profile.Save(ctx, tenantID, uid, &domrepo.TOTPProfileRecord{ + Enrolled: true, + SecretCipher: cipherBlob, + BackupCodesHash: hashes, + EnrolledAt: uc.now().UnixMilli(), + }); err != nil { + return nil, errb.SysInternal("persist totp profile failed").WithCause(err) + } + if delErr := uc.enroll.Delete(ctx, tenantID, uid); delErr != nil { + return nil, errb.SysInternal("clear totp enroll failed").WithCause(delErr) + } + return plainCodes, nil +} + +func (uc *totpUseCase) VerifyCode(ctx context.Context, tenantID, uid, code string) error { + if tenantID == "" || uid == "" || code == "" { + return errb.InputMissingRequired("tenant_id, uid and code are required") + } + rec, err := uc.profile.Get(ctx, tenantID, uid) + if err != nil { + return errb.SysInternal("read totp profile failed").WithCause(err) + } + if rec == nil || !rec.Enrolled { + return errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled) + } + secret, err := uc.cipher.Decrypt(rec.SecretCipher) + if err != nil { + return errb.SysInternal("totp secret decrypt failed").WithCause(err) + } + + now := uc.now() + cleanCode := strings.TrimSpace(code) + digits := uc.config.TOTP.Digits + if len(cleanCode) == digits { + if step, ok := totp.Verify(secret, cleanCode, now, uc.period(), digits, uc.config.TOTP.Window); ok { + ttl := time.Duration(uc.config.TOTP.ReplayTTLSeconds) * time.Second + fresh, markErr := uc.replay.MarkUsed(ctx, tenantID, uid, step, ttl) + if markErr != nil { + return errb.SysInternal("totp replay mark failed").WithCause(markErr) + } + if !fresh { + return errb.AuthForbidden("totp code already used").WithCause(member.ErrTOTPCodeReplay) + } + return nil + } + } + + if uc.tryBackupCode(ctx, tenantID, uid, rec, cleanCode) { + return nil + } + return errb.AuthForbidden("invalid totp code").WithCause(member.ErrTOTPInvalidCode) +} + +func (uc *totpUseCase) tryBackupCode(ctx context.Context, tenantID, uid string, rec *domrepo.TOTPProfileRecord, code string) bool { + for _, hash := range rec.BackupCodesHash { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)); err == nil { + if _, err := uc.profile.ConsumeBackupCode(ctx, tenantID, uid, hash); err != nil { + return false + } + return true + } + } + return false +} + +func (uc *totpUseCase) Disable(ctx context.Context, tenantID, uid string) error { + if tenantID == "" || uid == "" { + return errb.InputMissingRequired("tenant_id and uid are required") + } + if err := uc.profile.Clear(ctx, tenantID, uid); err != nil { + return errb.SysInternal("clear totp profile failed").WithCause(err) + } + if err := uc.enroll.Delete(ctx, tenantID, uid); err != nil { + return errb.SysInternal("clear totp enroll failed").WithCause(err) + } + return nil +} + +func (uc *totpUseCase) RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) { + if tenantID == "" || uid == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.profile.Get(ctx, tenantID, uid) + if err != nil { + return nil, errb.SysInternal("read totp profile failed").WithCause(err) + } + if rec == nil || !rec.Enrolled { + return nil, errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled) + } + plain, hashes, err := uc.generateBackupCodes() + if err != nil { + return nil, err + } + if err := uc.profile.ReplaceBackupCodes(ctx, tenantID, uid, hashes); err != nil { + return nil, errb.SysInternal("replace backup codes failed").WithCause(err) + } + return plain, nil +} + +func (uc *totpUseCase) Status(ctx context.Context, tenantID, uid string) (*domusecase.TOTPStatusDTO, error) { + if tenantID == "" || uid == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.profile.Get(ctx, tenantID, uid) + if err != nil { + return nil, errb.SysInternal("read totp profile failed").WithCause(err) + } + dto := &domusecase.TOTPStatusDTO{} + if rec == nil { + return dto, nil + } + dto.Enrolled = rec.Enrolled + dto.EnrolledAt = rec.EnrolledAt + dto.BackupCodesRemaining = len(rec.BackupCodesHash) + return dto, nil +} + +func (uc *totpUseCase) period() time.Duration { + return time.Duration(uc.config.TOTP.PeriodSeconds) * time.Second +} + +// generateBackupCodes returns the plaintext codes alongside their bcrypt +// hashes ready for persistence. +func (uc *totpUseCase) generateBackupCodes() (plain, hashes []string, err error) { + codes, err := totp.GenerateBackupCodes(uc.config.TOTP.BackupCodeCount, uc.config.TOTP.BackupCodeLength) + if err != nil { + return nil, nil, errb.SysInternal("backup code generation failed").WithCause(err) + } + hashes = make([]string, 0, len(codes)) + for _, c := range codes { + h, hashErr := bcrypt.GenerateFromPassword([]byte(c), bcrypt.DefaultCost) + if hashErr != nil { + return nil, nil, errb.SysInternal("backup code hash failed").WithCause(hashErr) + } + hashes = append(hashes, string(h)) + } + return codes, hashes, nil +} diff --git a/internal/model/member/usecase/totp_usecase_test.go b/internal/model/member/usecase/totp_usecase_test.go new file mode 100644 index 0000000..542e21e --- /dev/null +++ b/internal/model/member/usecase/totp_usecase_test.go @@ -0,0 +1,217 @@ +package usecase_test + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/require" + "github.com/zeromicro/go-zero/core/stores/redis" + + libcrypto "gateway/internal/library/crypto" + redislib "gateway/internal/library/redis" + memberconfig "gateway/internal/model/member/config" + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/repository" + libtotp "gateway/internal/model/member/totp" + "gateway/internal/model/member/usecase" +) + +func newTOTPFixture(t *testing.T) *usecase.TOTPUseCaseParam { + t.Helper() + mr := miniredis.RunT(t) + rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode}) + require.NoError(t, err) + + key := make([]byte, 32) + _, err = rand.Read(key) + require.NoError(t, err) + cipher, err := libcrypto.NewAESGCM(key) + require.NoError(t, err) + + now := time.Unix(1_716_000_000, 0) + return &usecase.TOTPUseCaseParam{ + Profile: repository.NewMemoryTOTPProfileRepository(), + Enroll: repository.NewRedisTOTPEnrollStore(rds), + Replay: repository.NewRedisTOTPReplayStore(rds), + Cipher: cipher, + Config: memberconfig.Config{}.Defaults(), + Now: func() time.Time { return now }, + } +} + +// currentCode reads the staged secret from the enroll store, decrypts it, +// and returns a freshly generated TOTP code for the configured `now`. +func currentCode(t *testing.T, param *usecase.TOTPUseCaseParam, tenantID, uid string) string { + t.Helper() + cipherBlob, err := param.Enroll.Get(context.Background(), tenantID, uid) + require.NoError(t, err) + secret, err := param.Cipher.Decrypt(cipherBlob) + require.NoError(t, err) + code, err := libtotp.Generate(secret, param.Now(), time.Duration(param.Config.TOTP.PeriodSeconds)*time.Second, param.Config.TOTP.Digits) + require.NoError(t, err) + return code +} + +func TestTOTPUseCase_EnrollConfirmAndStatus(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + dto, err := uc.StartEnroll(context.Background(), "t1", "u1", "u1@example.com") + require.NoError(t, err) + require.NotEmpty(t, dto.OtpauthURL) + require.Equal(t, 6, dto.Digits) + require.Equal(t, 30, dto.PeriodSec) + + code := currentCode(t, param, "t1", "u1") + backup, err := uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + require.Len(t, backup, param.Config.TOTP.BackupCodeCount) + + status, err := uc.Status(context.Background(), "t1", "u1") + require.NoError(t, err) + require.True(t, status.Enrolled) + require.Equal(t, len(backup), status.BackupCodesRemaining) +} + +func TestTOTPUseCase_StartEnroll_AlreadyEnrolled(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + code := currentCode(t, param, "t1", "u1") + _, err = uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + + _, err = uc.StartEnroll(context.Background(), "t1", "u1", "") + require.Error(t, err) +} + +func TestTOTPUseCase_ConfirmEnroll_MissingStash(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.ConfirmEnroll(context.Background(), "t1", "u1", "123456") + require.Error(t, err) +} + +func TestTOTPUseCase_ConfirmEnroll_BadCode(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + + _, err = uc.ConfirmEnroll(context.Background(), "t1", "u1", "000000") + require.Error(t, err) +} + +func TestTOTPUseCase_VerifyCode_SuccessAndReplay(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + code := currentCode(t, param, "t1", "u1") + _, err = uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + + // Use a fresh TOTP code for verification; with `now` static the same code + // should validate then be rejected on replay. + verifyCode, err := libtotp.Generate(decryptStored(t, param, "t1", "u1"), param.Now(), 30*time.Second, 6) + require.NoError(t, err) + require.NoError(t, uc.VerifyCode(context.Background(), "t1", "u1", verifyCode)) + + err = uc.VerifyCode(context.Background(), "t1", "u1", verifyCode) + require.Error(t, err) +} + +func TestTOTPUseCase_VerifyCode_BackupCode(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + code := currentCode(t, param, "t1", "u1") + backup, err := uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + require.NotEmpty(t, backup) + + require.NoError(t, uc.VerifyCode(context.Background(), "t1", "u1", backup[0])) + + // Same backup code cannot be reused. + require.Error(t, uc.VerifyCode(context.Background(), "t1", "u1", backup[0])) + + status, err := uc.Status(context.Background(), "t1", "u1") + require.NoError(t, err) + require.Equal(t, len(backup)-1, status.BackupCodesRemaining) +} + +func TestTOTPUseCase_VerifyCode_NotEnrolled(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + err := uc.VerifyCode(context.Background(), "t1", "u1", "123456") + require.Error(t, err) +} + +func TestTOTPUseCase_Disable(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + code := currentCode(t, param, "t1", "u1") + _, err = uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + + require.NoError(t, uc.Disable(context.Background(), "t1", "u1")) + status, err := uc.Status(context.Background(), "t1", "u1") + require.NoError(t, err) + require.False(t, status.Enrolled) +} + +func TestTOTPUseCase_RegenerateBackupCodes(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.StartEnroll(context.Background(), "t1", "u1", "") + require.NoError(t, err) + code := currentCode(t, param, "t1", "u1") + first, err := uc.ConfirmEnroll(context.Background(), "t1", "u1", code) + require.NoError(t, err) + + second, err := uc.RegenerateBackupCodes(context.Background(), "t1", "u1") + require.NoError(t, err) + require.Len(t, second, len(first)) + require.NotEqual(t, first, second) + + require.Error(t, uc.VerifyCode(context.Background(), "t1", "u1", first[0])) + require.NoError(t, uc.VerifyCode(context.Background(), "t1", "u1", second[0])) +} + +func TestTOTPUseCase_RegenerateBackupCodes_NotEnrolled(t *testing.T) { + param := newTOTPFixture(t) + uc := usecase.MustTOTPUseCase(*param) + + _, err := uc.RegenerateBackupCodes(context.Background(), "t1", "u1") + require.Error(t, err) +} + +// decryptStored is a helper for tests to read the encrypted secret directly +// from the profile after enrollment to compute fresh verification codes. +func decryptStored(t *testing.T, param *usecase.TOTPUseCaseParam, tenantID, uid string) []byte { + t.Helper() + rec, err := param.Profile.Get(context.Background(), tenantID, uid) + require.NoError(t, err) + secret, err := param.Cipher.Decrypt(rec.SecretCipher) + require.NoError(t, err) + return secret +} + +// silence the unused `member` import; intentional retained for symmetry with +// otp_usecase_test.go which references the package for sentinels. +var _ = member.ErrTOTPNotEnrolled diff --git a/internal/model/member/usecase/verification_usecase.go b/internal/model/member/usecase/verification_usecase.go deleted file mode 100644 index 9c85213..0000000 --- a/internal/model/member/usecase/verification_usecase.go +++ /dev/null @@ -1,150 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "gateway/internal/model/member" - memberconfig "gateway/internal/model/member/config" - "gateway/internal/model/member/domain/enum" - domrepo "gateway/internal/model/member/domain/repository" - domusecase "gateway/internal/model/member/domain/usecase" - notifenum "gateway/internal/model/notification/domain/enum" - domnotif "gateway/internal/model/notification/domain/usecase" -) - -type verificationUseCase struct { - otp domusecase.OTPUseCase - notifier domnotif.NotifierUseCase - profile domrepo.ProfileRepository - rates domrepo.VerifyRateStore - config memberconfig.Config -} - -// VerificationUseCaseParam wires VerificationUseCase. -type VerificationUseCaseParam struct { - OTP domusecase.OTPUseCase - Notifier domnotif.NotifierUseCase - Profile domrepo.ProfileRepository - Rates domrepo.VerifyRateStore - Config memberconfig.Config -} - -// MustVerificationUseCase constructs VerificationUseCase. -func MustVerificationUseCase(param VerificationUseCaseParam) domusecase.VerificationUseCase { - return &verificationUseCase{ - otp: param.OTP, - notifier: param.Notifier, - profile: param.Profile, - rates: param.Rates, - config: param.Config.Defaults(), - } -} - -func (uc *verificationUseCase) StartEmailVerify(ctx context.Context, tenantID, uid, target, locale string) (*domusecase.OTPChallengeDTO, error) { - return uc.startVerify(ctx, tenantID, uid, target, locale, enum.VerifyKindEmail, enum.OTPPurposeBusinessEmail, notifenum.ChannelEmail, notifenum.NotifyVerifyEmail) -} - -func (uc *verificationUseCase) ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error { - target, err := uc.otp.Verify(ctx, &domusecase.VerifyOTPRequest{ - TenantID: tenantID, - UID: uid, - ChallengeID: challengeID, - Code: code, - Purpose: enum.OTPPurposeBusinessEmail, - }) - if err != nil { - return err - } - return uc.profile.SetBusinessEmailVerified(ctx, tenantID, uid, target) -} - -func (uc *verificationUseCase) StartPhoneVerify(ctx context.Context, tenantID, uid, target, locale string) (*domusecase.OTPChallengeDTO, error) { - return uc.startVerify(ctx, tenantID, uid, target, locale, enum.VerifyKindPhone, enum.OTPPurposeBusinessPhone, notifenum.ChannelSMS, notifenum.NotifyVerifyPhone) -} - -func (uc *verificationUseCase) ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error { - target, err := uc.otp.Verify(ctx, &domusecase.VerifyOTPRequest{ - TenantID: tenantID, - UID: uid, - ChallengeID: challengeID, - Code: code, - Purpose: enum.OTPPurposeBusinessPhone, - }) - if err != nil { - return err - } - return uc.profile.SetBusinessPhoneVerified(ctx, tenantID, uid, target) -} - -func (uc *verificationUseCase) startVerify( - ctx context.Context, - tenantID, uid, target, locale string, - kind enum.VerifyKind, - purpose enum.OTPPurpose, - channel notifenum.Channel, - notifyKind notifenum.NotifyKind, -) (*domusecase.OTPChallengeDTO, error) { - if tenantID == "" || uid == "" || target == "" { - return nil, errb.InputMissingRequired("tenant_id, uid and target are required") - } - if uc.notifier == nil { - return nil, errb.SysInternal("notifier is not configured") - } - if uc.rates == nil { - return nil, errb.SysInternal("verify rate store is not configured") - } - - if err := uc.checkRateLimits(ctx, tenantID, uid, kind); err != nil { - return nil, err - } - - dto, code, err := uc.otp.Generate(ctx, &domusecase.GenerateOTPRequest{ - TenantID: tenantID, - UID: uid, - Purpose: purpose, - Target: target, - }) - if err != nil { - return nil, err - } - - _, err = uc.notifier.Send(ctx, &domnotif.SendRequest{ - TenantID: tenantID, - UID: uid, - Channel: channel, - Kind: notifyKind, - Target: target, - Locale: locale, - Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, - IdempotencyKey: dto.ChallengeID, - DoNotPersistBody: true, - Severity: notifenum.SeverityInfo, - }) - if err != nil { - if invErr := uc.otp.Invalidate(ctx, dto.ChallengeID); invErr != nil { - return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr) - } - return nil, err - } - return dto, nil -} - -func (uc *verificationUseCase) checkRateLimits(ctx context.Context, tenantID, uid string, kind enum.VerifyKind) error { - cooldown := time.Duration(uc.config.OTP.ResendCooldownSeconds) * time.Second - ok, err := uc.rates.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenantID, uid, kind.String()), cooldown) - if err != nil { - return errb.SysInternal("verify rate check failed").WithCause(err) - } - if !ok { - return errb.ResInvalidState("verification resend cooldown").WithCause(member.ErrResendCooldown) - } - count, err := uc.rates.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenantID, uid, kind.String()), 25*time.Hour) - if err != nil { - return errb.SysInternal("verify daily limit check failed").WithCause(err) - } - if count > int64(uc.config.OTP.DailyVerifyLimit) { - return errb.ResInsufficientQuota("daily verification limit exceeded").WithCause(member.ErrDailyLimit) - } - return nil -} diff --git a/internal/model/notification/provider/email/mock_sender.go b/internal/model/notification/provider/email/mock_sender.go index 8260647..764a1da 100644 --- a/internal/model/notification/provider/email/mock_sender.go +++ b/internal/model/notification/provider/email/mock_sender.go @@ -72,11 +72,11 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { return m.MessageID, nil } -func truncateForLog(s string, max int) string { - if max <= 0 || len(s) <= max { +func truncateForLog(s string, maxLen int) string { + if maxLen <= 0 || len(s) <= maxLen { return s } - return s[:max] + "…(truncated)" + return s[:maxLen] + "…(truncated)" } func (m *MockSender) Calls() []*Message { diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index d38c955..26b9eff 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -9,6 +9,7 @@ import ( "gateway/internal/config" redislib "gateway/internal/library/redis" "gateway/internal/library/validate" + domrepo "gateway/internal/model/member/domain/repository" dommember "gateway/internal/model/member/domain/usecase" memberusecase "gateway/internal/model/member/usecase" domnotif "gateway/internal/model/notification/domain/usecase" @@ -27,8 +28,20 @@ type ServiceContext struct { NotificationAdmin domnotif.AdminNotifierUseCase // NotificationRetry runs async delivery when Mongo + Redis are configured. NotificationRetry *notification_retry.Runner - // MemberVerification is nil when Mongo/Redis/Notifier are not fully configured. - MemberVerification dommember.VerificationUseCase + + // MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate). + // nil when Redis is not configured. Logic layer composes it with the + // Notifier + Profile flips; usecases MUST NOT call other usecases. + MemberOTP dommember.OTPUseCase + // MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK + // is unset or Redis is missing. + MemberTOTP dommember.TOTPUseCase + // MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the + // logic layer. + MemberVerifyRate domrepo.VerifyRateStore + // MemberProfile flips BusinessEmail/Phone verified flags; consumed by + // the logic layer after a successful OTP confirmation. + MemberProfile domrepo.ProfileRepository } func NewServiceContext(c config.Config) *ServiceContext { @@ -60,16 +73,18 @@ func NewServiceContext(c config.Config) *ServiceContext { sc.NotificationAdmin = mod.Admin sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker) } - if c.Mongo.Host != "" && rds != nil && sc.Notifier != nil { + if rds != nil && rds.Zero() != nil { memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - Notifier: sc.Notifier, - Config: c.Member, + Redis: rds, + Config: c.Member, }) if err != nil { panic(err) } - sc.MemberVerification = memberMod.Verification + sc.MemberOTP = memberMod.OTP + sc.MemberTOTP = memberMod.TOTP + sc.MemberVerifyRate = memberMod.VerifyRate + sc.MemberProfile = memberMod.Profile } return sc }