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
- -ST1003
- -ST1016
# go-zero conf uses `json:",optional"` for optional fields; staticcheck
# mis-reports it as an unknown json option, so suppress globally.
- -SA5008
exclusions:
generated: lax

View File

@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
.DEFAULT_GOAL := help
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test setup-dev run-local
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local
help: ## 顯示可用指令
@echo "Gateway Makefile"
@ -112,5 +112,11 @@ notify-test: setup-dev ## 通知測試METHOD 必填;例: make notify-test M
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證Google Authenticator需 Redis
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
$(if $(CODE),-code "$(CODE)",)
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v

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)")
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config")
os.Exit(1)
return fmt.Errorf("mongo-index: Mongo.Host is empty in config")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@ -34,13 +40,12 @@ func main() {
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
if err := notifRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err)
os.Exit(1)
return fmt.Errorf("mongo-index: notifications: %w", err)
}
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err)
os.Exit(1)
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
}
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
return nil
}

View File

@ -15,6 +15,7 @@ import (
"gateway/internal/config"
redislib "gateway/internal/library/redis"
memberenum "gateway/internal/model/member/domain/enum"
dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
notifconfig "gateway/internal/model/notification/config"
@ -28,14 +29,14 @@ import (
)
const (
methodEmailSend = "email-send"
methodEmailEnqueue = "email-enqueue"
methodEmailIdempotency = "email-idempotency"
methodSMSSend = "sms-send"
methodSMSEnqueue = "sms-enqueue"
methodMemberEmail = "member-email"
methodMemberPhone = "member-phone"
methodAdminDLQ = "admin-dlq"
methodEmailSend = "email-send"
methodEmailEnqueue = "email-enqueue"
methodEmailIdempotency = "email-idempotency"
methodSMSSend = "sms-send"
methodSMSEnqueue = "sms-enqueue"
methodMemberEmail = "member-email"
methodMemberPhone = "member-phone"
methodAdminDLQ = "admin-dlq"
)
var validMethods = []string{
@ -61,15 +62,17 @@ var (
)
type env struct {
ctx context.Context
tenant string
uid string
to string
phone string
locale string
notifier domusecase.NotifierUseCase
verification dommember.VerificationUseCase
admin domusecase.AdminNotifierUseCase
ctx context.Context
tenant string
uid string
to string
phone string
locale string
notifier domusecase.NotifierUseCase
// otp is the atomic primitive; this CLI plays the role of the future
// logic layer and orchestrates OTP.Generate + Notifier.Send inline.
otp dommember.OTPUseCase
admin domusecase.AdminNotifierUseCase
}
func main() {
@ -90,32 +93,41 @@ func main() {
}
flag.Parse()
code, err := run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
if code != 0 {
os.Exit(code)
}
}
// run wires the requested method and returns (exitCode, error). Deferred
// cleanups inside run always execute before main calls os.Exit.
func run() (int, error) {
m := strings.TrimSpace(*method)
if m == "" {
fmt.Fprintln(os.Stderr, "notify-test: -method is required")
flag.Usage()
os.Exit(2)
return 2, fmt.Errorf("notify-test: -method is required")
}
if !isValidMethod(m) {
fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m)
flag.Usage()
os.Exit(2)
return 2, fmt.Errorf("notify-test: unknown method %q", m)
}
if err := validateArgs(m); err != nil {
fmt.Fprintf(os.Stderr, "notify-test: %v\n", err)
os.Exit(2)
return 2, fmt.Errorf("notify-test: %w", err)
}
var c config.Config
conf.MustLoad(*configFile, &c)
if c.Mongo.Host == "" {
fail("Mongo.Host is empty")
return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
}
if c.Redis.Host == "" {
fail("Redis.Host is empty")
return 1, fmt.Errorf("notify-test: Redis.Host is empty")
}
if c.Notification.Email.From == "" && needsEmailFrom(m) {
fail("Notification.Email.From is empty")
return 1, fmt.Errorf("notify-test: Notification.Email.From is empty")
}
if *mockOnly {
forceMock(&c.Notification)
@ -126,7 +138,7 @@ func main() {
rds, err := redislib.NewClient(c.Redis)
if err != nil {
fail("redis: %v", err)
return 1, fmt.Errorf("notify-test: redis: %w", err)
}
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
@ -135,37 +147,36 @@ func main() {
Config: c.Notification,
})
if err != nil {
fail("notification: %v", err)
return 1, fmt.Errorf("notify-test: notification: %w", err)
}
var verification dommember.VerificationUseCase
var otpUC dommember.OTPUseCase
if m == methodMemberEmail || m == methodMemberPhone {
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Notifier: mod.Notifier,
Config: c.Member,
memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Config: c.Member,
})
if err != nil {
fail("member: %v", err)
if memErr != nil {
return 1, fmt.Errorf("notify-test: member: %w", memErr)
}
verification = memberMod.Verification
otpUC = memberMod.OTP
}
e := &env{
ctx: ctx,
tenant: *tenantID,
uid: *uid,
to: *toEmail,
phone: *phone,
locale: c.Notification.DefaultLocale,
notifier: mod.Notifier,
verification: verification,
admin: mod.Admin,
ctx: ctx,
tenant: *tenantID,
uid: *uid,
to: *toEmail,
phone: *phone,
locale: c.Notification.DefaultLocale,
notifier: mod.Notifier,
otp: otpUC,
admin: mod.Admin,
}
if m == methodEmailEnqueue || m == methodSMSEnqueue {
if mod.RetryWorker == nil {
fail("retry worker not configured (need Redis)")
return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)")
}
workerCtx, stop := context.WithCancel(context.Background())
go mod.RetryWorker.Run(workerCtx)
@ -174,11 +185,11 @@ func main() {
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
if err := runMethod(e, m); err != nil {
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
os.Exit(1)
if runErr := runMethod(e, m); runErr != nil {
return 1, fmt.Errorf("FAIL: %w", runErr)
}
fmt.Println("OK")
return 0, nil
}
func runMethod(e *env, m string) error {
@ -320,21 +331,52 @@ func (e *env) smsEnqueue() error {
return nil
}
// memberEmail demonstrates the logic-layer orchestration: generate an OTP
// challenge (atomic) and dispatch the verification email through Notifier
// (atomic). usecases never call each other — this driver is what the real
// logic handler will look like.
func (e *env) memberEmail() error {
ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw")
if err != nil {
return err
}
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
return nil
return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
}
func (e *env) memberPhone() error {
ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw")
return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone)
}
func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error {
if e.otp == nil {
return fmt.Errorf("member OTP usecase not configured")
}
if target == "" {
return fmt.Errorf("target is empty")
}
dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{
TenantID: e.tenant,
UID: e.uid,
Purpose: purpose,
Target: target,
})
if err != nil {
return err
}
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
TenantID: e.tenant,
UID: e.uid,
Channel: channel,
Kind: kind,
Target: target,
Locale: e.locale,
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
IdempotencyKey: dto.ChallengeID,
DoNotPersistBody: true,
Severity: enum.SeverityInfo,
}); err != nil {
if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil {
fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr)
}
return err
}
fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn)
return nil
}
@ -443,8 +485,3 @@ func smsProviders(cfg *notifconfig.Config) []string {
}
return []string{"mock"}
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...)
os.Exit(1)
}

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 可任意組合,跨流程共用。
> 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、流程編排目前**不實作**;先固化介面契約。

View File

@ -15,25 +15,30 @@
```
internal/model/
└── {module}/ # 例notification、member、permission
├── domain/ # 純領域介面、實體、列舉、DTO(不依賴 mongo/redis/provider
├── domain/ # 純領域介面、實體、列舉、DTO、模組級定義
│ ├── entity/ # Mongo document 結構 + CollectionName()
│ ├── enum/ # Channel、Status、Platform…
│ ├── repository/ # Repository / Cache 介面 only
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面notification
│ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面notification
│ ├── errors.go # 模組 sentinelpackage domain
│ ├── const.go # BSON 欄位名、模組常數package domain
│ └── redis.go # Redis key 命名package domain
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
├── usecase/ # domain/usecase 的實作 + factory 組裝
├── template/ # 可選go:embed、DefaultRegistry、Renderer 實作
├── provider/ # 可選:僅本模組用的 Senderemail/sms不放 library/
├── totp/、xxx/ # 可選:模組專屬純函式 library不放 internal/library/
├── config/ # 模組設定 struct嵌入 gateway Config
├── errors.go # 模組 sentinel
├── const.go # BSON 欄位名、模組常數
├── redis.go # Redis key 命名
└── mock/ # mockgen路徑對應 domain/
├── repository/
└── usecase/
```
> **定義類errors / const / redis key統一放 `domain/`**caller 端以 `member "gateway/internal/model/{module}/domain"` 取用,引用形式仍為 `member.ErrXxx` / `member.Get…RedisKey`,但這些 sentinel 與 key helper 都在 `package domain`,與 `domain/entity` 等子套件平行。
>
> **`internal/library/` 只放跨模組真正共用的東西**(如 `library/errors`、`library/mongo`、`library/redis`、`library/crypto`)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper應落在該模組底下例如 `internal/model/member/totp/`,避免污染 library 命名空間。
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)N0N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
| 層 | 路徑 | 內容 |
@ -285,6 +290,17 @@ type SendRequest struct {
- 錯誤一律回傳 `gateway/internal/library/errors``*errs.Error`(見第 7 節)。
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
### 6.1 UseCase 互不呼叫atomic-only
> **強制規則**UseCase 是 **atomic primitive****禁止**在 usecase 內部呼叫其他 usecase不論是同模組或跨模組
>
> - usecase struct 的依賴**只能**是 `domain/repository` 介面、`provider/`、`template/`、library helper、`config`。
> - **不可**在 `XxxUseCaseParam` 出現另一個 `domain/usecase.XxxUseCase` 欄位。
> - 需要把多個 atomic 串成一個業務流程例如「OTP.Generate → Notifier.Send → Profile.SetVerified」**編排在 `internal/logic/`**logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。
> - CLI / driver`cmd/notify-test/`)扮演 logic 同等角色:直接組 atomic不應該被包成 composite usecase。
>
> 這條規則優先於 [identity-member-design.md §5.2](./identity-member-design.md) 提到的 Composite UseCase該節保留為「**邏輯流的描述**」,不代表 `domain/usecase` 會出現 composite interface。
**範例:**
```go
@ -310,12 +326,12 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
## 7. 錯誤處理
全專案對外只使用 `gateway/internal/library/errors``var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。
全專案對外只使用 `gateway/internal/library/errors``var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。
### 7.1 模組 sentinel`errors.go`
### 7.1 模組 sentinel`domain/errors.go`
```go
package member
package domain
import "fmt"
@ -325,7 +341,7 @@ var (
)
```
(專案慣例:`fmt.Errorf` 定義 sentinel便於 `%w` 包裝;見 `notification/errors.go`。)
(專案慣例:sentinel 一律以 `fmt.Errorf` 定義,便於 `%w` 包裝。caller 端 `member "gateway/internal/model/member/domain"` 後即可 `member.ErrNotFound`。)
### 7.2 Repository
@ -354,24 +370,36 @@ var (
| 檔案 | 用途 |
|------|------|
| `errors.go` | 模組 sentinel`ErrNotFound` 等) |
| `const.go` | 模組字面常數 |
| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper |
| `domain/errors.go` | 模組 sentinel`ErrNotFound` 等`package domain` |
| `domain/const.go` | 模組字面常數`package domain` |
| `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper`package domain` |
| `config/config.go` | UseCase 需要的設定 struct不含 go-zero RestConf |
Redis key 統一帶業務 prefix避免跨服務衝突
```go
package domain
type RedisKey string
const AccountRedisKey RedisKey = "member:account"
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
func GetAccountRedisKey(id string) string {
return AccountRedisKey.With(id).ToString()
return AccountRedisKey.With(id).String()
}
```
Caller 端:
```go
import (
member "gateway/internal/model/member/domain"
)
// 使用member.GetAccountRedisKey(id)、member.ErrNotFound
```
## 9. Mock`mock/` + gomock
**方案 A本專案採用**
@ -433,7 +461,7 @@ make gen-mock
**Notification 模組進度(參考):** 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 層的關係

View File

@ -65,3 +65,16 @@ Member:
MaxAttempts: 5
ResendCooldownSeconds: 60
DailyVerifyLimit: 10
TOTP:
Issuer: CloudEP
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
# 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP.
# Dev-only placeholder for local totp-test; replace in production.
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"

View File

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

1
go.mod
View File

@ -51,6 +51,7 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/titanous/json5 v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect

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

View File

@ -14,8 +14,8 @@ import (
type Config struct {
rest.RestConf
Mongo mongo.Conf `json:",optional"`
Redis redis.RedisConf `json:",optional"`
Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"`
Mongo mongo.Conf `json:",optional"`
Redis redis.RedisConf `json:",optional"`
Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"`
}

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

@ -2,9 +2,11 @@ package config
// Config is member module settings (embedded in gateway root config).
type Config struct {
OTP OTPConfig `json:",optional"`
OTP OTPConfig `json:",optional"`
TOTP TOTPConfig `json:",optional"`
}
// OTPConfig governs the business OTP primitive (email/phone verification).
type OTPConfig struct {
Length int `json:",optional"`
TTLSeconds int `json:",optional"`
@ -13,6 +15,24 @@ type OTPConfig struct {
DailyVerifyLimit int `json:",optional"`
}
// TOTPConfig governs business-tier RFC 6238 TOTP (step-up MFA).
//
// SecretKEK is the 32-byte master key used to AES-256-GCM encrypt member
// secrets at rest. Accepts hex (64 chars) or base64. Empty disables the
// TOTPUseCase wiring (factory returns an error).
type TOTPConfig struct {
Issuer string `json:",optional"`
Algorithm string `json:",optional"`
Digits int `json:",optional"`
PeriodSeconds int `json:",optional"`
Window int `json:",optional"`
BackupCodeCount int `json:",optional"`
BackupCodeLength int `json:",optional"`
EnrollTTLSeconds int `json:",optional"`
ReplayTTLSeconds int `json:",optional"`
SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"`
}
// Defaults returns zero-value-safe defaults.
func (c Config) Defaults() Config {
if c.OTP.Length <= 0 {
@ -30,5 +50,35 @@ func (c Config) Defaults() Config {
if c.OTP.DailyVerifyLimit <= 0 {
c.OTP.DailyVerifyLimit = 10
}
if c.TOTP.Issuer == "" {
c.TOTP.Issuer = "CloudEP"
}
if c.TOTP.Algorithm == "" {
c.TOTP.Algorithm = "SHA1"
}
if c.TOTP.Digits <= 0 {
c.TOTP.Digits = 6
}
if c.TOTP.PeriodSeconds <= 0 {
c.TOTP.PeriodSeconds = 30
}
if c.TOTP.Window < 0 {
c.TOTP.Window = 0
}
if c.TOTP.Window == 0 {
c.TOTP.Window = 1
}
if c.TOTP.BackupCodeCount <= 0 {
c.TOTP.BackupCodeCount = 10
}
if c.TOTP.BackupCodeLength <= 0 {
c.TOTP.BackupCodeLength = 12
}
if c.TOTP.EnrollTTLSeconds <= 0 {
c.TOTP.EnrollTTLSeconds = 600
}
if c.TOTP.ReplayTTLSeconds <= 0 {
c.TOTP.ReplayTTLSeconds = 90
}
return c
}

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"
redislib "gateway/internal/library/redis"
"gateway/internal/model/member"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
"github.com/zeromicro/go-zero/core/stores/redis"

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 (
"fmt"
libcrypto "gateway/internal/library/crypto"
redislib "gateway/internal/library/redis"
memberconfig "gateway/internal/model/member/config"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/model/member/repository"
domnotif "gateway/internal/model/notification/domain/usecase"
)
// Module bundles member use cases.
// Module bundles member atomic primitives. Each entry is a single-purpose
// usecase; composite flows (e.g. "send verification email then mark
// business_email verified") are assembled at the logic / driver layer and
// MUST NOT live inside another usecase.
type Module struct {
OTP domusecase.OTPUseCase
Verification domusecase.VerificationUseCase
// OTP issues and verifies one-time codes (purpose-agnostic).
OTP domusecase.OTPUseCase
// TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream
// code must gracefully degrade (e.g. fall back to SMS/email OTP at the
// logic layer).
TOTP domusecase.TOTPUseCase
// Stores exposed for logic-layer orchestration (rate limit, profile
// flips). They are intentionally surfaced so the logic layer can compose
// atomic usecases with rate-limit + profile mutation without re-wiring.
VerifyRate domrepo.VerifyRateStore
Profile domrepo.ProfileRepository
}
// ModuleParam wires member module dependencies.
type ModuleParam struct {
Redis *redislib.Client
Notifier domnotif.NotifierUseCase
Config memberconfig.Config
Profile domrepo.ProfileRepository // optional; defaults to memory
Redis *redislib.Client
Config memberconfig.Config
// Profile is optional; defaults to memory repository.
Profile domrepo.ProfileRepository
// TOTPProfile is optional; defaults to memory repository.
TOTPProfile domrepo.TOTPProfileRepository
}
// NewModuleFromParam builds member use cases.
// NewModuleFromParam builds member atomic usecases.
//
// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local
// dev / unit tests boot without a KMS-backed key while production deployments
// fail loud when the operator forgets to configure it.
func NewModuleFromParam(param ModuleParam) (*Module, error) {
if param.Redis == nil || param.Redis.Zero() == nil {
return nil, fmt.Errorf("member: redis is required")
}
if param.Notifier == nil {
return nil, fmt.Errorf("member: notifier is required")
}
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
@ -42,14 +58,29 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
}
cfg := param.Config.Defaults()
otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg})
verificationUC := MustVerificationUseCase(VerificationUseCaseParam{
OTP: otpUC,
Notifier: param.Notifier,
Profile: profile,
Rates: rateStore,
Config: cfg,
})
mod := &Module{
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
VerifyRate: rateStore,
Profile: profile,
}
return &Module{OTP: otpUC, Verification: verificationUC}, nil
if cfg.TOTP.SecretKEK != "" {
cipher, err := libcrypto.NewAESGCMFromString(cfg.TOTP.SecretKEK)
if err != nil {
return nil, fmt.Errorf("member: totp kek: %w", err)
}
totpProfile := param.TOTPProfile
if totpProfile == nil {
totpProfile = repository.NewMemoryTOTPProfileRepository()
}
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
Profile: totpProfile,
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
Replay: repository.NewRedisTOTPReplayStore(param.Redis),
Cipher: cipher,
Config: cfg,
})
}
return mod, nil
}

View File

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

View File

@ -19,7 +19,7 @@ import (
func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
@ -31,7 +31,7 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
TenantID: "t1",
UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
Target: testUserEmail,
})
require.NoError(t, err)
require.NotEmpty(t, code)
@ -44,12 +44,12 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
Purpose: enum.OTPPurposeBusinessEmail,
})
require.NoError(t, err)
require.Equal(t, "user@example.com", target)
require.Equal(t, testUserEmail, target)
}
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
require.NoError(t, err)
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
@ -61,7 +61,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
TenantID: "t1",
UID: "victim",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
Target: testUserEmail,
})
require.NoError(t, err)
@ -77,7 +77,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
mr := miniredis.RunT(t)
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
require.NoError(t, err)
cfg := memberconfig.Config{}.Defaults()
@ -92,7 +92,7 @@ func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
TenantID: "t1",
UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
Target: testUserEmail,
})
require.NoError(t, err)

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
}
func truncateForLog(s string, max int) string {
if max <= 0 || len(s) <= max {
func truncateForLog(s string, maxLen int) string {
if maxLen <= 0 || len(s) <= maxLen {
return s
}
return s[:max] + "…(truncated)"
return s[:maxLen] + "…(truncated)"
}
func (m *MockSender) Calls() []*Message {

View File

@ -9,6 +9,7 @@ import (
"gateway/internal/config"
redislib "gateway/internal/library/redis"
"gateway/internal/library/validate"
domrepo "gateway/internal/model/member/domain/repository"
dommember "gateway/internal/model/member/domain/usecase"
memberusecase "gateway/internal/model/member/usecase"
domnotif "gateway/internal/model/notification/domain/usecase"
@ -27,8 +28,20 @@ type ServiceContext struct {
NotificationAdmin domnotif.AdminNotifierUseCase
// NotificationRetry runs async delivery when Mongo + Redis are configured.
NotificationRetry *notification_retry.Runner
// MemberVerification is nil when Mongo/Redis/Notifier are not fully configured.
MemberVerification dommember.VerificationUseCase
// MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate).
// nil when Redis is not configured. Logic layer composes it with the
// Notifier + Profile flips; usecases MUST NOT call other usecases.
MemberOTP dommember.OTPUseCase
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
// is unset or Redis is missing.
MemberTOTP dommember.TOTPUseCase
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the
// logic layer.
MemberVerifyRate domrepo.VerifyRateStore
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by
// the logic layer after a successful OTP confirmation.
MemberProfile domrepo.ProfileRepository
}
func NewServiceContext(c config.Config) *ServiceContext {
@ -60,16 +73,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.NotificationAdmin = mod.Admin
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
}
if c.Mongo.Host != "" && rds != nil && sc.Notifier != nil {
if rds != nil && rds.Zero() != nil {
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Notifier: sc.Notifier,
Config: c.Member,
Redis: rds,
Config: c.Member,
})
if err != nil {
panic(err)
}
sc.MemberVerification = memberMod.Verification
sc.MemberOTP = memberMod.OTP
sc.MemberTOTP = memberMod.TOTP
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberProfile = memberMod.Profile
}
return sc
}