add member totp

This commit is contained in:
王性驊 2026-05-20 21:03:59 +08:00
parent 3afe3f9502
commit 240fa92f6f
37 changed files with 2513 additions and 341 deletions

View File

@ -107,6 +107,9 @@ linters:
- -ST1000 - -ST1000
- -ST1003 - -ST1003
- -ST1016 - -ST1016
# go-zero conf uses `json:",optional"` for optional fields; staticcheck
# mis-reports it as an unknown json option, so suppress globally.
- -SA5008
exclusions: exclusions:
generated: lax generated: lax

View File

@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ .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: ## 顯示可用指令 help: ## 顯示可用指令
@echo "Gateway Makefile" @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)" \ $(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,) $(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 可載入 config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v

View File

@ -18,13 +18,19 @@ import (
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)") var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)")
func main() { func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse() flag.Parse()
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c) conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" { if c.Mongo.Host == "" {
fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config") return fmt.Errorf("mongo-index: Mongo.Host is empty in config")
os.Exit(1)
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@ -34,13 +40,12 @@ func main() {
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo}) dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
if err := notifRepo.Index20260520001UP(ctx); err != nil { if err := notifRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err) return fmt.Errorf("mongo-index: notifications: %w", err)
os.Exit(1)
} }
if err := dlqRepo.Index20260520001UP(ctx); err != nil { if err := dlqRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err) return fmt.Errorf("mongo-index: notification_dlq: %w", err)
os.Exit(1)
} }
fmt.Println("mongo-index: notifications + notification_dlq indexes OK") fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
return nil
} }

View File

@ -15,6 +15,7 @@ import (
"gateway/internal/config" "gateway/internal/config"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
memberenum "gateway/internal/model/member/domain/enum"
dommember "gateway/internal/model/member/domain/usecase" dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase" memberusecase "gateway/internal/model/member/usecase"
notifconfig "gateway/internal/model/notification/config" notifconfig "gateway/internal/model/notification/config"
@ -68,7 +69,9 @@ type env struct {
phone string phone string
locale string locale string
notifier domusecase.NotifierUseCase notifier domusecase.NotifierUseCase
verification dommember.VerificationUseCase // 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 admin domusecase.AdminNotifierUseCase
} }
@ -90,32 +93,41 @@ func main() {
} }
flag.Parse() 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) m := strings.TrimSpace(*method)
if m == "" { if m == "" {
fmt.Fprintln(os.Stderr, "notify-test: -method is required")
flag.Usage() flag.Usage()
os.Exit(2) return 2, fmt.Errorf("notify-test: -method is required")
} }
if !isValidMethod(m) { if !isValidMethod(m) {
fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m)
flag.Usage() flag.Usage()
os.Exit(2) return 2, fmt.Errorf("notify-test: unknown method %q", m)
} }
if err := validateArgs(m); err != nil { if err := validateArgs(m); err != nil {
fmt.Fprintf(os.Stderr, "notify-test: %v\n", err) return 2, fmt.Errorf("notify-test: %w", err)
os.Exit(2)
} }
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c) conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" { if c.Mongo.Host == "" {
fail("Mongo.Host is empty") return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
} }
if c.Redis.Host == "" { 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) { 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 { if *mockOnly {
forceMock(&c.Notification) forceMock(&c.Notification)
@ -126,7 +138,7 @@ func main() {
rds, err := redislib.NewClient(c.Redis) rds, err := redislib.NewClient(c.Redis)
if err != nil { if err != nil {
fail("redis: %v", err) return 1, fmt.Errorf("notify-test: redis: %w", err)
} }
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
@ -135,20 +147,19 @@ func main() {
Config: c.Notification, Config: c.Notification,
}) })
if err != nil { 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 { if m == methodMemberEmail || m == methodMemberPhone {
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds, Redis: rds,
Notifier: mod.Notifier,
Config: c.Member, Config: c.Member,
}) })
if err != nil { if memErr != nil {
fail("member: %v", err) return 1, fmt.Errorf("notify-test: member: %w", memErr)
} }
verification = memberMod.Verification otpUC = memberMod.OTP
} }
e := &env{ e := &env{
@ -159,13 +170,13 @@ func main() {
phone: *phone, phone: *phone,
locale: c.Notification.DefaultLocale, locale: c.Notification.DefaultLocale,
notifier: mod.Notifier, notifier: mod.Notifier,
verification: verification, otp: otpUC,
admin: mod.Admin, admin: mod.Admin,
} }
if m == methodEmailEnqueue || m == methodSMSEnqueue { if m == methodEmailEnqueue || m == methodSMSEnqueue {
if mod.RetryWorker == nil { 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()) workerCtx, stop := context.WithCancel(context.Background())
go mod.RetryWorker.Run(workerCtx) 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), ",")) 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 { if runErr := runMethod(e, m); runErr != nil {
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) return 1, fmt.Errorf("FAIL: %w", runErr)
os.Exit(1)
} }
fmt.Println("OK") fmt.Println("OK")
return 0, nil
} }
func runMethod(e *env, m string) error { func runMethod(e *env, m string) error {
@ -320,21 +331,52 @@ func (e *env) smsEnqueue() error {
return nil 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 { func (e *env) memberEmail() error {
ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw") return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
if err != nil {
return err
}
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
return nil
} }
func (e *env) memberPhone() error { 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 { if err != nil {
return err 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 return nil
} }
@ -443,8 +485,3 @@ func smsProviders(cfg *notifconfig.Config) []string {
} }
return []string{"mock"} return []string{"mock"}
} }
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...)
os.Exit(1)
}

330
cmd/totp-test/main.go Normal file
View File

@ -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
}

View File

@ -439,7 +439,10 @@ Middleware
> >
> 介面分兩層: > 介面分兩層:
> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification。Logic 可任意組合,跨流程共用。 > 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification。Logic 可任意組合,跨流程共用。
> 2. **Composite**:把幾個常用 atomic 預先組好的「快捷組合」(如 `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`。Composite 是**可選**logic 也可以繞過直接組 atomic。 > 2. ~~**Composite**~~:原本設想的「把幾個 atomic 預先組好的快捷組合」**已廢棄**。
> - 與 [model.md §6.1](./model.md) 抵觸usecase 之間禁止互呼叫。
> - 目前實作只提供 atomic`OTPUseCase`、`TOTPUseCase`、`ProfileUseCase` 等),多步驟流程(如 verify-email = `OTP.Generate``Notifier.Send``Profile.SetBusinessEmailVerified`)一律在 **logic 層**編排logic handler 自己持有多個 usecase interface。
> - 下方第 5.2.2 節保留 `VerificationUseCase` 介面定義僅為「**邏輯流的描述參考**」,不會在 `domain/usecase/` 出現。
> >
> 業務邏輯API、handler、流程編排目前**不實作**;先固化介面契約。 > 業務邏輯API、handler、流程編排目前**不實作**;先固化介面契約。

View File

@ -15,25 +15,30 @@
``` ```
internal/model/ internal/model/
└── {module}/ # 例notification、member、permission └── {module}/ # 例notification、member、permission
├── domain/ # 純領域介面、實體、列舉、DTO(不依賴 mongo/redis/provider ├── domain/ # 純領域介面、實體、列舉、DTO、模組級定義
│ ├── entity/ # Mongo document 結構 + CollectionName() │ ├── entity/ # Mongo document 結構 + CollectionName()
│ ├── enum/ # Channel、Status、Platform… │ ├── enum/ # Channel、Status、Platform…
│ ├── repository/ # Repository / Cache 介面 only │ ├── repository/ # Repository / Cache 介面 only
│ ├── usecase/ # UseCase 介面 + Request/Response DTO │ ├── usecase/ # UseCase 介面 + Request/Response DTO
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面notification │ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面notification
│ ├── errors.go # 模組 sentinelpackage domain
│ ├── const.go # BSON 欄位名、模組常數package domain
│ └── redis.go # Redis key 命名package domain
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作 ├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
├── usecase/ # domain/usecase 的實作 + factory 組裝 ├── usecase/ # domain/usecase 的實作 + factory 組裝
├── template/ # 可選go:embed、DefaultRegistry、Renderer 實作 ├── template/ # 可選go:embed、DefaultRegistry、Renderer 實作
├── provider/ # 可選:僅本模組用的 Senderemail/sms不放 library/ ├── provider/ # 可選:僅本模組用的 Senderemail/sms不放 library/
├── totp/、xxx/ # 可選:模組專屬純函式 library不放 internal/library/
├── config/ # 模組設定 struct嵌入 gateway Config ├── config/ # 模組設定 struct嵌入 gateway Config
├── errors.go # 模組 sentinel
├── const.go # BSON 欄位名、模組常數
├── redis.go # Redis key 命名
└── mock/ # mockgen路徑對應 domain/ └── mock/ # mockgen路徑對應 domain/
├── repository/ ├── repository/
└── usecase/ └── 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/)N0N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。 **參考實作:** [`internal/model/notification/`](../internal/model/notification/)N0N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
| 層 | 路徑 | 內容 | | 層 | 路徑 | 內容 |
@ -285,6 +290,17 @@ type SendRequest struct {
- 錯誤一律回傳 `gateway/internal/library/errors``*errs.Error`(見第 7 節)。 - 錯誤一律回傳 `gateway/internal/library/errors``*errs.Error`(見第 7 節)。
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。 - 可測性:將難 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 ```go
@ -310,12 +326,12 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
## 7. 錯誤處理 ## 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 ```go
package member package domain
import "fmt" 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 ### 7.2 Repository
@ -354,24 +370,36 @@ var (
| 檔案 | 用途 | | 檔案 | 用途 |
|------|------| |------|------|
| `errors.go` | 模組 sentinel`ErrNotFound` 等) | | `domain/errors.go` | 模組 sentinel`ErrNotFound` 等`package domain` |
| `const.go` | 模組字面常數 | | `domain/const.go` | 模組字面常數`package domain` |
| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper | | `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper`package domain` |
| `config/config.go` | UseCase 需要的設定 struct不含 go-zero RestConf | | `config/config.go` | UseCase 需要的設定 struct不含 go-zero RestConf |
Redis key 統一帶業務 prefix避免跨服務衝突 Redis key 統一帶業務 prefix避免跨服務衝突
```go ```go
package domain
type RedisKey string type RedisKey string
const AccountRedisKey RedisKey = "member:account" const AccountRedisKey RedisKey = "member:account"
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ } func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
func GetAccountRedisKey(id string) string { 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 ## 9. Mock`mock/` + gomock
**方案 A本專案採用** **方案 A本專案採用**
@ -433,7 +461,7 @@ make gen-mock
**Notification 模組進度(參考):** N0N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做HTTP admin APIgoctl **Notification 模組進度(參考):** N0N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做HTTP admin APIgoctl
**Member 模組進度P3.5** `OTPUseCase` + `VerificationUseCase`email/phone`Notifier.Send` 投遞;`ProfileRepository` 暫用 memoryP4 換 Mongo。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續Step-up / TOTP、HTTP APIgoctl)。 **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 APIgoctl+ logic 層編排(含 rate-limit + step-up 守門)。
## 12. 與 Gateway HTTP 層的關係 ## 12. 與 Gateway HTTP 層的關係

View File

@ -65,3 +65,16 @@ Member:
MaxAttempts: 5 MaxAttempts: 5
ResendCooldownSeconds: 60 ResendCooldownSeconds: 60
DailyVerifyLimit: 10 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"

View File

@ -59,3 +59,14 @@ Member:
MaxAttempts: 5 MaxAttempts: 5
ResendCooldownSeconds: 60 ResendCooldownSeconds: 60
DailyVerifyLimit: 10 DailyVerifyLimit: 10
TOTP:
Issuer: CloudEP
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"

1
go.mod
View File

@ -51,6 +51,7 @@ require (
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // 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/spaolacci/murmur3 v1.1.0 // indirect
github.com/titanous/json5 v1.0.0 // indirect github.com/titanous/json5 v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect

2
go.sum
View File

@ -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/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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 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 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
```
---
## OTPOne-Time Password
### 原理
1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 RedisTTL 預設 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 limitlogic 層使用)
`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)
```
---
## TOTPTime-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 keyhex 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 — 預設 memoryP4 換 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, &notif.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
```
### 互動式 TOTPGoogle 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 handlerverify-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。

View File

@ -3,8 +3,10 @@ package config
// Config is member module settings (embedded in gateway root config). // Config is member module settings (embedded in gateway root config).
type Config struct { 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 { type OTPConfig struct {
Length int `json:",optional"` Length int `json:",optional"`
TTLSeconds int `json:",optional"` TTLSeconds int `json:",optional"`
@ -13,6 +15,24 @@ type OTPConfig struct {
DailyVerifyLimit int `json:",optional"` 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. // Defaults returns zero-value-safe defaults.
func (c Config) Defaults() Config { func (c Config) Defaults() Config {
if c.OTP.Length <= 0 { if c.OTP.Length <= 0 {
@ -30,5 +50,35 @@ func (c Config) Defaults() Config {
if c.OTP.DailyVerifyLimit <= 0 { if c.OTP.DailyVerifyLimit <= 0 {
c.OTP.DailyVerifyLimit = 10 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 return c
} }

View File

@ -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")
)

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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")
)

View File

@ -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()
}

View File

@ -9,7 +9,7 @@ import (
"time" "time"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
"gateway/internal/model/member" member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository" domrepo "gateway/internal/model/member/domain/repository"
"github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/redis"

View File

@ -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)

View File

@ -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)
}

View File

@ -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.
}

View File

@ -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))
}

View File

@ -3,36 +3,52 @@ package usecase
import ( import (
"fmt" "fmt"
libcrypto "gateway/internal/library/crypto"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
memberconfig "gateway/internal/model/member/config" memberconfig "gateway/internal/model/member/config"
domrepo "gateway/internal/model/member/domain/repository" domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase" domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/model/member/repository" "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 { type Module struct {
// OTP issues and verifies one-time codes (purpose-agnostic).
OTP domusecase.OTPUseCase OTP domusecase.OTPUseCase
Verification domusecase.VerificationUseCase // 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. // ModuleParam wires member module dependencies.
type ModuleParam struct { type ModuleParam struct {
Redis *redislib.Client Redis *redislib.Client
Notifier domnotif.NotifierUseCase
Config memberconfig.Config Config memberconfig.Config
Profile domrepo.ProfileRepository // optional; defaults to memory // 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) { func NewModuleFromParam(param ModuleParam) (*Module, error) {
if param.Redis == nil || param.Redis.Zero() == nil { if param.Redis == nil || param.Redis.Zero() == nil {
return nil, fmt.Errorf("member: redis is required") 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) otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
rateStore := repository.NewRedisVerifyRateStore(param.Redis) rateStore := repository.NewRedisVerifyRateStore(param.Redis)
@ -42,14 +58,29 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
} }
cfg := param.Config.Defaults() cfg := param.Config.Defaults()
otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}) mod := &Module{
verificationUC := MustVerificationUseCase(VerificationUseCaseParam{ OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
OTP: otpUC, VerifyRate: rateStore,
Notifier: param.Notifier,
Profile: profile, Profile: profile,
Rates: rateStore, }
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, Config: cfg,
}) })
}
return &Module{OTP: otpUC, Verification: verificationUC}, nil return mod, nil
} }

View File

@ -12,8 +12,8 @@ import (
errs "gateway/internal/library/errors" errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code" "gateway/internal/library/errors/code"
"gateway/internal/model/member"
memberconfig "gateway/internal/model/member/config" memberconfig "gateway/internal/model/member/config"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository" domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase" domusecase "gateway/internal/model/member/domain/usecase"
) )

View File

@ -19,7 +19,7 @@ import (
func TestOTPUseCase_GenerateAndVerify(t *testing.T) { func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
mr := miniredis.RunT(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) require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{ uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
@ -31,7 +31,7 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
TenantID: "t1", TenantID: "t1",
UID: "u1", UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail, Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com", Target: testUserEmail,
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, code) require.NotEmpty(t, code)
@ -44,12 +44,12 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
Purpose: enum.OTPPurposeBusinessEmail, Purpose: enum.OTPPurposeBusinessEmail,
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "user@example.com", target) require.Equal(t, testUserEmail, target)
} }
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) { func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
mr := miniredis.RunT(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) require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{ uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
@ -61,7 +61,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
TenantID: "t1", TenantID: "t1",
UID: "victim", UID: "victim",
Purpose: enum.OTPPurposeBusinessEmail, Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com", Target: testUserEmail,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -77,7 +77,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) { func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
mr := miniredis.RunT(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) require.NoError(t, err)
cfg := memberconfig.Config{}.Defaults() cfg := memberconfig.Config{}.Defaults()
@ -92,7 +92,7 @@ func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
TenantID: "t1", TenantID: "t1",
UID: "u1", UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail, Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com", Target: testUserEmail,
}) })
require.NoError(t, err) require.NoError(t, err)

View File

@ -0,0 +1,6 @@
package usecase_test
const (
testRedisTypeNode = "node"
testUserEmail = "user@example.com"
)

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -72,11 +72,11 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
return m.MessageID, nil return m.MessageID, nil
} }
func truncateForLog(s string, max int) string { func truncateForLog(s string, maxLen int) string {
if max <= 0 || len(s) <= max { if maxLen <= 0 || len(s) <= maxLen {
return s return s
} }
return s[:max] + "…(truncated)" return s[:maxLen] + "…(truncated)"
} }
func (m *MockSender) Calls() []*Message { func (m *MockSender) Calls() []*Message {

View File

@ -9,6 +9,7 @@ import (
"gateway/internal/config" "gateway/internal/config"
redislib "gateway/internal/library/redis" redislib "gateway/internal/library/redis"
"gateway/internal/library/validate" "gateway/internal/library/validate"
domrepo "gateway/internal/model/member/domain/repository"
dommember "gateway/internal/model/member/domain/usecase" dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase" memberusecase "gateway/internal/model/member/usecase"
domnotif "gateway/internal/model/notification/domain/usecase" domnotif "gateway/internal/model/notification/domain/usecase"
@ -27,8 +28,20 @@ type ServiceContext struct {
NotificationAdmin domnotif.AdminNotifierUseCase NotificationAdmin domnotif.AdminNotifierUseCase
// NotificationRetry runs async delivery when Mongo + Redis are configured. // NotificationRetry runs async delivery when Mongo + Redis are configured.
NotificationRetry *notification_retry.Runner 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 { func NewServiceContext(c config.Config) *ServiceContext {
@ -60,16 +73,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.NotificationAdmin = mod.Admin sc.NotificationAdmin = mod.Admin
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker) 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{ memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds, Redis: rds,
Notifier: sc.Notifier,
Config: c.Member, Config: c.Member,
}) })
if err != nil { if err != nil {
panic(err) panic(err)
} }
sc.MemberVerification = memberMod.Verification sc.MemberOTP = memberMod.OTP
sc.MemberTOTP = memberMod.TOTP
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberProfile = memberMod.Profile
} }
return sc return sc
} }