add member totp
This commit is contained in:
parent
3afe3f9502
commit
240fa92f6f
|
|
@ -107,6 +107,9 @@ linters:
|
||||||
- -ST1000
|
- -ST1000
|
||||||
- -ST1003
|
- -ST1003
|
||||||
- -ST1016
|
- -ST1016
|
||||||
|
# go-zero conf uses `json:",optional"` for optional fields; staticcheck
|
||||||
|
# mis-reports it as an unknown json option, so suppress globally.
|
||||||
|
- -SA5008
|
||||||
|
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
|
|
||||||
8
Makefile
8
Makefile
|
|
@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
||||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test setup-dev run-local
|
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local
|
||||||
|
|
||||||
help: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
|
|
@ -112,5 +112,11 @@ notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test M
|
||||||
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
|
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
|
||||||
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
|
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
|
||||||
|
|
||||||
|
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis)
|
||||||
|
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \
|
||||||
|
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \
|
||||||
|
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
||||||
|
$(if $(CODE),-code "$(CODE)",)
|
||||||
|
|
||||||
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
||||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,19 @@ import (
|
||||||
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)")
|
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)")
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c)
|
||||||
if c.Mongo.Host == "" {
|
if c.Mongo.Host == "" {
|
||||||
fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config")
|
return fmt.Errorf("mongo-index: Mongo.Host is empty in config")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
@ -34,13 +40,12 @@ func main() {
|
||||||
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
|
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
|
||||||
|
|
||||||
if err := notifRepo.Index20260520001UP(ctx); err != nil {
|
if err := notifRepo.Index20260520001UP(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err)
|
return fmt.Errorf("mongo-index: notifications: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err)
|
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
|
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
|
memberenum "gateway/internal/model/member/domain/enum"
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
memberusecase "gateway/internal/model/member/usecase"
|
memberusecase "gateway/internal/model/member/usecase"
|
||||||
notifconfig "gateway/internal/model/notification/config"
|
notifconfig "gateway/internal/model/notification/config"
|
||||||
|
|
@ -68,7 +69,9 @@ type env struct {
|
||||||
phone string
|
phone string
|
||||||
locale string
|
locale string
|
||||||
notifier domusecase.NotifierUseCase
|
notifier domusecase.NotifierUseCase
|
||||||
verification dommember.VerificationUseCase
|
// otp is the atomic primitive; this CLI plays the role of the future
|
||||||
|
// logic layer and orchestrates OTP.Generate + Notifier.Send inline.
|
||||||
|
otp dommember.OTPUseCase
|
||||||
admin domusecase.AdminNotifierUseCase
|
admin domusecase.AdminNotifierUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,32 +93,41 @@ func main() {
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
code, err := run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wires the requested method and returns (exitCode, error). Deferred
|
||||||
|
// cleanups inside run always execute before main calls os.Exit.
|
||||||
|
func run() (int, error) {
|
||||||
m := strings.TrimSpace(*method)
|
m := strings.TrimSpace(*method)
|
||||||
if m == "" {
|
if m == "" {
|
||||||
fmt.Fprintln(os.Stderr, "notify-test: -method is required")
|
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
os.Exit(2)
|
return 2, fmt.Errorf("notify-test: -method is required")
|
||||||
}
|
}
|
||||||
if !isValidMethod(m) {
|
if !isValidMethod(m) {
|
||||||
fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m)
|
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
os.Exit(2)
|
return 2, fmt.Errorf("notify-test: unknown method %q", m)
|
||||||
}
|
}
|
||||||
if err := validateArgs(m); err != nil {
|
if err := validateArgs(m); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "notify-test: %v\n", err)
|
return 2, fmt.Errorf("notify-test: %w", err)
|
||||||
os.Exit(2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var c config.Config
|
var c config.Config
|
||||||
conf.MustLoad(*configFile, &c)
|
conf.MustLoad(*configFile, &c)
|
||||||
if c.Mongo.Host == "" {
|
if c.Mongo.Host == "" {
|
||||||
fail("Mongo.Host is empty")
|
return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
|
||||||
}
|
}
|
||||||
if c.Redis.Host == "" {
|
if c.Redis.Host == "" {
|
||||||
fail("Redis.Host is empty")
|
return 1, fmt.Errorf("notify-test: Redis.Host is empty")
|
||||||
}
|
}
|
||||||
if c.Notification.Email.From == "" && needsEmailFrom(m) {
|
if c.Notification.Email.From == "" && needsEmailFrom(m) {
|
||||||
fail("Notification.Email.From is empty")
|
return 1, fmt.Errorf("notify-test: Notification.Email.From is empty")
|
||||||
}
|
}
|
||||||
if *mockOnly {
|
if *mockOnly {
|
||||||
forceMock(&c.Notification)
|
forceMock(&c.Notification)
|
||||||
|
|
@ -126,7 +138,7 @@ func main() {
|
||||||
|
|
||||||
rds, err := redislib.NewClient(c.Redis)
|
rds, err := redislib.NewClient(c.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("redis: %v", err)
|
return 1, fmt.Errorf("notify-test: redis: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
|
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
|
||||||
|
|
@ -135,20 +147,19 @@ func main() {
|
||||||
Config: c.Notification,
|
Config: c.Notification,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("notification: %v", err)
|
return 1, fmt.Errorf("notify-test: notification: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var verification dommember.VerificationUseCase
|
var otpUC dommember.OTPUseCase
|
||||||
if m == methodMemberEmail || m == methodMemberPhone {
|
if m == methodMemberEmail || m == methodMemberPhone {
|
||||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||||
Redis: rds,
|
Redis: rds,
|
||||||
Notifier: mod.Notifier,
|
|
||||||
Config: c.Member,
|
Config: c.Member,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if memErr != nil {
|
||||||
fail("member: %v", err)
|
return 1, fmt.Errorf("notify-test: member: %w", memErr)
|
||||||
}
|
}
|
||||||
verification = memberMod.Verification
|
otpUC = memberMod.OTP
|
||||||
}
|
}
|
||||||
|
|
||||||
e := &env{
|
e := &env{
|
||||||
|
|
@ -159,13 +170,13 @@ func main() {
|
||||||
phone: *phone,
|
phone: *phone,
|
||||||
locale: c.Notification.DefaultLocale,
|
locale: c.Notification.DefaultLocale,
|
||||||
notifier: mod.Notifier,
|
notifier: mod.Notifier,
|
||||||
verification: verification,
|
otp: otpUC,
|
||||||
admin: mod.Admin,
|
admin: mod.Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
if m == methodEmailEnqueue || m == methodSMSEnqueue {
|
if m == methodEmailEnqueue || m == methodSMSEnqueue {
|
||||||
if mod.RetryWorker == nil {
|
if mod.RetryWorker == nil {
|
||||||
fail("retry worker not configured (need Redis)")
|
return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)")
|
||||||
}
|
}
|
||||||
workerCtx, stop := context.WithCancel(context.Background())
|
workerCtx, stop := context.WithCancel(context.Background())
|
||||||
go mod.RetryWorker.Run(workerCtx)
|
go mod.RetryWorker.Run(workerCtx)
|
||||||
|
|
@ -174,11 +185,11 @@ func main() {
|
||||||
|
|
||||||
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
|
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
|
||||||
|
|
||||||
if err := runMethod(e, m); err != nil {
|
if runErr := runMethod(e, m); runErr != nil {
|
||||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
return 1, fmt.Errorf("FAIL: %w", runErr)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Println("OK")
|
fmt.Println("OK")
|
||||||
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMethod(e *env, m string) error {
|
func runMethod(e *env, m string) error {
|
||||||
|
|
@ -320,21 +331,52 @@ func (e *env) smsEnqueue() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memberEmail demonstrates the logic-layer orchestration: generate an OTP
|
||||||
|
// challenge (atomic) and dispatch the verification email through Notifier
|
||||||
|
// (atomic). usecases never call each other — this driver is what the real
|
||||||
|
// logic handler will look like.
|
||||||
func (e *env) memberEmail() error {
|
func (e *env) memberEmail() error {
|
||||||
ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw")
|
return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *env) memberPhone() error {
|
func (e *env) memberPhone() error {
|
||||||
ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw")
|
return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error {
|
||||||
|
if e.otp == nil {
|
||||||
|
return fmt.Errorf("member OTP usecase not configured")
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
return fmt.Errorf("target is empty")
|
||||||
|
}
|
||||||
|
dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Purpose: purpose,
|
||||||
|
Target: target,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||||
|
TenantID: e.tenant,
|
||||||
|
UID: e.uid,
|
||||||
|
Channel: channel,
|
||||||
|
Kind: kind,
|
||||||
|
Target: target,
|
||||||
|
Locale: e.locale,
|
||||||
|
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
|
||||||
|
IdempotencyKey: dto.ChallengeID,
|
||||||
|
DoNotPersistBody: true,
|
||||||
|
Severity: enum.SeverityInfo,
|
||||||
|
}); err != nil {
|
||||||
|
if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,8 +485,3 @@ func smsProviders(cfg *notifconfig.Config) []string {
|
||||||
}
|
}
|
||||||
return []string{"mock"}
|
return []string{"mock"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(format string, args ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -439,7 +439,10 @@ Middleware
|
||||||
>
|
>
|
||||||
> 介面分兩層:
|
> 介面分兩層:
|
||||||
> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。
|
> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。
|
||||||
> 2. **Composite**:把幾個常用 atomic 預先組好的「快捷組合」(如 `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`)。Composite 是**可選**,logic 也可以繞過直接組 atomic。
|
> 2. ~~**Composite**~~:原本設想的「把幾個 atomic 預先組好的快捷組合」**已廢棄**。
|
||||||
|
> - 與 [model.md §6.1](./model.md) 抵觸:usecase 之間禁止互呼叫。
|
||||||
|
> - 目前實作只提供 atomic(`OTPUseCase`、`TOTPUseCase`、`ProfileUseCase` 等),多步驟流程(如 verify-email = `OTP.Generate` → `Notifier.Send` → `Profile.SetBusinessEmailVerified`)一律在 **logic 層**編排,logic handler 自己持有多個 usecase interface。
|
||||||
|
> - 下方第 5.2.2 節保留 `VerificationUseCase` 介面定義僅為「**邏輯流的描述參考**」,不會在 `domain/usecase/` 出現。
|
||||||
>
|
>
|
||||||
> 業務邏輯(API、handler、流程編排)目前**不實作**;先固化介面契約。
|
> 業務邏輯(API、handler、流程編排)目前**不實作**;先固化介面契約。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,30 @@
|
||||||
```
|
```
|
||||||
internal/model/
|
internal/model/
|
||||||
└── {module}/ # 例:notification、member、permission
|
└── {module}/ # 例:notification、member、permission
|
||||||
├── domain/ # 純領域:介面、實體、列舉、DTO(不依賴 mongo/redis/provider)
|
├── domain/ # 純領域:介面、實體、列舉、DTO、模組級定義
|
||||||
│ ├── entity/ # Mongo document 結構 + CollectionName()
|
│ ├── entity/ # Mongo document 結構 + CollectionName()
|
||||||
│ ├── enum/ # Channel、Status、Platform…
|
│ ├── enum/ # Channel、Status、Platform…
|
||||||
│ ├── repository/ # Repository / Cache 介面 only
|
│ ├── repository/ # Repository / Cache 介面 only
|
||||||
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
|
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
|
||||||
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification)
|
│ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification)
|
||||||
|
│ ├── errors.go # 模組 sentinel(package domain)
|
||||||
|
│ ├── const.go # BSON 欄位名、模組常數(package domain)
|
||||||
|
│ └── redis.go # Redis key 命名(package domain)
|
||||||
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
|
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
|
||||||
├── usecase/ # domain/usecase 的實作 + factory 組裝
|
├── usecase/ # domain/usecase 的實作 + factory 組裝
|
||||||
├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作
|
├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作
|
||||||
├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/
|
├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/
|
||||||
|
├── totp/、xxx/ # 可選:模組專屬純函式 library(不放 internal/library/)
|
||||||
├── config/ # 模組設定 struct(嵌入 gateway Config)
|
├── config/ # 模組設定 struct(嵌入 gateway Config)
|
||||||
├── errors.go # 模組 sentinel
|
|
||||||
├── const.go # BSON 欄位名、模組常數
|
|
||||||
├── redis.go # Redis key 命名
|
|
||||||
└── mock/ # mockgen(路徑對應 domain/)
|
└── mock/ # mockgen(路徑對應 domain/)
|
||||||
├── repository/
|
├── repository/
|
||||||
└── usecase/
|
└── usecase/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **定義類(errors / const / redis key)統一放 `domain/`**:caller 端以 `member "gateway/internal/model/{module}/domain"` 取用,引用形式仍為 `member.ErrXxx` / `member.Get…RedisKey`,但這些 sentinel 與 key helper 都在 `package domain`,與 `domain/entity` 等子套件平行。
|
||||||
|
>
|
||||||
|
> **`internal/library/` 只放跨模組真正共用的東西**(如 `library/errors`、`library/mongo`、`library/redis`、`library/crypto`)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper)應落在該模組底下,例如 `internal/model/member/totp/`,避免污染 library 命名空間。
|
||||||
|
|
||||||
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
|
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
|
||||||
|
|
||||||
| 層 | 路徑 | 內容 |
|
| 層 | 路徑 | 內容 |
|
||||||
|
|
@ -285,6 +290,17 @@ type SendRequest struct {
|
||||||
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
||||||
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
||||||
|
|
||||||
|
### 6.1 UseCase 互不呼叫(atomic-only)
|
||||||
|
|
||||||
|
> **強制規則**:UseCase 是 **atomic primitive**,**禁止**在 usecase 內部呼叫其他 usecase(不論是同模組或跨模組)。
|
||||||
|
>
|
||||||
|
> - usecase struct 的依賴**只能**是 `domain/repository` 介面、`provider/`、`template/`、library helper、`config`。
|
||||||
|
> - **不可**在 `XxxUseCaseParam` 出現另一個 `domain/usecase.XxxUseCase` 欄位。
|
||||||
|
> - 需要把多個 atomic 串成一個業務流程(例如「OTP.Generate → Notifier.Send → Profile.SetVerified」)時,**編排在 `internal/logic/`**;logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。
|
||||||
|
> - CLI / driver(如 `cmd/notify-test/`)扮演 logic 同等角色:直接組 atomic,不應該被包成 composite usecase。
|
||||||
|
>
|
||||||
|
> 這條規則優先於 [identity-member-design.md §5.2](./identity-member-design.md) 提到的 Composite UseCase;該節保留為「**邏輯流的描述**」,不代表 `domain/usecase` 會出現 composite interface。
|
||||||
|
|
||||||
**範例:**
|
**範例:**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -310,12 +326,12 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
|
||||||
|
|
||||||
## 7. 錯誤處理
|
## 7. 錯誤處理
|
||||||
|
|
||||||
全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。
|
全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。
|
||||||
|
|
||||||
### 7.1 模組 sentinel(`errors.go`)
|
### 7.1 模組 sentinel(`domain/errors.go`)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package member
|
package domain
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
|
|
@ -325,7 +341,7 @@ var (
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
(專案慣例:`fmt.Errorf` 定義 sentinel,便於 `%w` 包裝;見 `notification/errors.go`。)
|
(專案慣例:sentinel 一律以 `fmt.Errorf` 定義,便於 `%w` 包裝。caller 端 `member "gateway/internal/model/member/domain"` 後即可 `member.ErrNotFound`。)
|
||||||
|
|
||||||
### 7.2 Repository
|
### 7.2 Repository
|
||||||
|
|
||||||
|
|
@ -354,24 +370,36 @@ var (
|
||||||
|
|
||||||
| 檔案 | 用途 |
|
| 檔案 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `errors.go` | 模組 sentinel(`ErrNotFound` 等) |
|
| `domain/errors.go` | 模組 sentinel(`ErrNotFound` 等,`package domain`) |
|
||||||
| `const.go` | 模組字面常數 |
|
| `domain/const.go` | 模組字面常數(`package domain`) |
|
||||||
| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper |
|
| `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper(`package domain`) |
|
||||||
| `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) |
|
| `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) |
|
||||||
|
|
||||||
Redis key 統一帶業務 prefix,避免跨服務衝突:
|
Redis key 統一帶業務 prefix,避免跨服務衝突:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
package domain
|
||||||
|
|
||||||
type RedisKey string
|
type RedisKey string
|
||||||
|
|
||||||
const AccountRedisKey RedisKey = "member:account"
|
const AccountRedisKey RedisKey = "member:account"
|
||||||
|
|
||||||
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
|
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
|
||||||
func GetAccountRedisKey(id string) string {
|
func GetAccountRedisKey(id string) string {
|
||||||
return AccountRedisKey.With(id).ToString()
|
return AccountRedisKey.With(id).String()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Caller 端:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
member "gateway/internal/model/member/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 使用:member.GetAccountRedisKey(id)、member.ErrNotFound
|
||||||
|
```
|
||||||
|
|
||||||
## 9. Mock(`mock/` + gomock)
|
## 9. Mock(`mock/` + gomock)
|
||||||
|
|
||||||
**方案 A(本專案採用):**
|
**方案 A(本專案採用):**
|
||||||
|
|
@ -433,7 +461,7 @@ make gen-mock
|
||||||
|
|
||||||
**Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。
|
**Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。
|
||||||
|
|
||||||
**Member 模組進度(P3.5):** `OTPUseCase` + `VerificationUseCase`(email/phone)✅,經 `Notifier.Send` 投遞;`ProfileRepository` 暫用 memory(P4 換 Mongo)。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續:Step-up / TOTP、HTTP API(goctl)。
|
**Member 模組進度(P3.5):** atomic primitives `OTPUseCase`(Generate/Verify/Invalidate)+ `TOTPUseCase`(enroll/verify/backup/disable)+ `VerifyRateStore` + `ProfileRepository` ✅。**usecase 之間不互相呼叫**:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 **logic 層**負責(尚未實作);參考實作見 `cmd/notify-test/main.go::startMemberVerify`(driver 等同 logic 角色)。後續:HTTP API(goctl)+ logic 層編排(含 rate-limit + step-up 守門)。
|
||||||
|
|
||||||
## 12. 與 Gateway HTTP 層的關係
|
## 12. 與 Gateway HTTP 層的關係
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,16 @@ Member:
|
||||||
MaxAttempts: 5
|
MaxAttempts: 5
|
||||||
ResendCooldownSeconds: 60
|
ResendCooldownSeconds: 60
|
||||||
DailyVerifyLimit: 10
|
DailyVerifyLimit: 10
|
||||||
|
TOTP:
|
||||||
|
Issuer: CloudEP
|
||||||
|
Algorithm: SHA1
|
||||||
|
Digits: 6
|
||||||
|
PeriodSeconds: 30
|
||||||
|
Window: 1
|
||||||
|
BackupCodeCount: 10
|
||||||
|
BackupCodeLength: 12
|
||||||
|
EnrollTTLSeconds: 600
|
||||||
|
ReplayTTLSeconds: 90
|
||||||
|
# 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP.
|
||||||
|
# Dev-only placeholder for local totp-test; replace in production.
|
||||||
|
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,14 @@ Member:
|
||||||
MaxAttempts: 5
|
MaxAttempts: 5
|
||||||
ResendCooldownSeconds: 60
|
ResendCooldownSeconds: 60
|
||||||
DailyVerifyLimit: 10
|
DailyVerifyLimit: 10
|
||||||
|
TOTP:
|
||||||
|
Issuer: CloudEP
|
||||||
|
Algorithm: SHA1
|
||||||
|
Digits: 6
|
||||||
|
PeriodSeconds: 30
|
||||||
|
Window: 1
|
||||||
|
BackupCodeCount: 10
|
||||||
|
BackupCodeLength: 12
|
||||||
|
EnrollTTLSeconds: 600
|
||||||
|
ReplayTTLSeconds: 90
|
||||||
|
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -51,6 +51,7 @@ require (
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/titanous/json5 v1.0.0 // indirect
|
github.com/titanous/json5 v1.0.0 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -104,6 +104,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Member 模組 — OTP / TOTP
|
||||||
|
|
||||||
|
Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫):
|
||||||
|
|
||||||
|
| UseCase | 用途 | 典型場景 |
|
||||||
|
|---------|------|----------|
|
||||||
|
| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 |
|
||||||
|
| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA |
|
||||||
|
|
||||||
|
> **架構原則**:usecase **不可**呼叫其他 usecase。
|
||||||
|
> 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程,由 **logic 層**(或 CLI driver)編排。
|
||||||
|
> 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目錄結構
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/model/member/
|
||||||
|
├── config/ # OTP / TOTP 設定
|
||||||
|
├── domain/ # 介面、enum、errors、redis key
|
||||||
|
│ ├── enum/
|
||||||
|
│ ├── repository/
|
||||||
|
│ └── usecase/
|
||||||
|
├── repository/ # Redis / memory 實作
|
||||||
|
├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library)
|
||||||
|
├── usecase/ # OTPUseCase、TOTPUseCase 實作
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OTP(One-Time Password)
|
||||||
|
|
||||||
|
### 原理
|
||||||
|
|
||||||
|
1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 Redis,TTL 預設 300 秒。
|
||||||
|
2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次;logic 層負責呼叫 `notification.Notifier.Send` 投遞。
|
||||||
|
3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt;成功後 **刪除 challenge**(一次性)。
|
||||||
|
4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5)即鎖定該 challenge。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Logic
|
||||||
|
participant OTP as OTPUseCase
|
||||||
|
participant Redis
|
||||||
|
participant Notifier
|
||||||
|
|
||||||
|
Logic->>OTP: Generate(tenant, uid, purpose, target)
|
||||||
|
OTP->>Redis: Save challenge (bcrypt hash)
|
||||||
|
OTP-->>Logic: challenge_id, plainCode
|
||||||
|
Logic->>Notifier: Send(code, expires_in)
|
||||||
|
Note over Logic: 使用者收到信/簡訊
|
||||||
|
Logic->>OTP: Verify(challenge_id, code, uid, purpose)
|
||||||
|
OTP->>Redis: Get + bcrypt compare
|
||||||
|
OTP->>Redis: Delete challenge
|
||||||
|
OTP-->>Logic: target (email/phone)
|
||||||
|
Logic->>Logic: Profile.SetBusinessEmailVerified(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purpose(用途標籤)
|
||||||
|
|
||||||
|
```go
|
||||||
|
enum.OTPPurposeBusinessEmail // 業務 email 驗證
|
||||||
|
enum.OTPPurposeBusinessPhone // 業務 phone 驗證
|
||||||
|
enum.OTPPurposeStepUp // step-up(未來 logic 層使用)
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify 時 `Purpose` 必須與 Generate 一致,否則拒絕。
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 產碼
|
||||||
|
dto, plainCode, err := otpUC.Generate(ctx, &domusecase.GenerateOTPRequest{
|
||||||
|
TenantID: "t1",
|
||||||
|
UID: "u1",
|
||||||
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
|
Target: "user@example.com",
|
||||||
|
})
|
||||||
|
// dto.ChallengeID → 給前端帶回 confirm API
|
||||||
|
// plainCode → 只在此刻存在,交給 Notifier 寄出
|
||||||
|
|
||||||
|
// 驗碼
|
||||||
|
target, err := otpUC.Verify(ctx, &domusecase.VerifyOTPRequest{
|
||||||
|
TenantID: "t1",
|
||||||
|
UID: "u1",
|
||||||
|
ChallengeID: dto.ChallengeID,
|
||||||
|
Code: "482913",
|
||||||
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
|
})
|
||||||
|
// 成功 → target == "user@example.com",challenge 已刪除
|
||||||
|
|
||||||
|
// 寄送失敗時回滾
|
||||||
|
_ = otpUC.Invalidate(ctx, dto.ChallengeID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate limit(logic 層使用)
|
||||||
|
|
||||||
|
`VerifyRateStore` 提供 resend cooldown 與每日上限,**不在 OTPUseCase 內建**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 冷卻(60 秒內不可重發)
|
||||||
|
ok, _ := verifyRate.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenant, uid, "email"), 60*time.Second)
|
||||||
|
|
||||||
|
// 每日上限(預設 10 次)
|
||||||
|
count, _ := verifyRate.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenant, uid, "email"), 24*time.Hour)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TOTP(Time-based OTP)
|
||||||
|
|
||||||
|
### 原理
|
||||||
|
|
||||||
|
遵循 **RFC 6238**,與 Google Authenticator / Authy 相容:
|
||||||
|
|
||||||
|
| 參數 | 預設值 |
|
||||||
|
|------|--------|
|
||||||
|
| 演算法 | HMAC-SHA1 |
|
||||||
|
| 週期 | 30 秒 |
|
||||||
|
| 位數 | 6 |
|
||||||
|
| 時間窗口 | ±1 step(容忍時鐘偏差) |
|
||||||
|
|
||||||
|
**儲存安全**:
|
||||||
|
|
||||||
|
- Secret 以 **AES-256-GCM** 加密(KEK = `Member.TOTP.SecretKEK`)後寫入 profile。
|
||||||
|
- 備援碼以 **bcrypt** 雜湊儲存,明文只在 `ConfirmEnroll` / `RegenerateBackupCodes` 回傳一次。
|
||||||
|
- 綁定前的 staged secret 暫存 Redis(`EnrollTTLSeconds`,預設 600 秒)。
|
||||||
|
- 驗碼成功後以 Redis 記錄 time step,**同一時間窗口內不可重放**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Logic
|
||||||
|
participant TOTP as TOTPUseCase
|
||||||
|
participant Redis
|
||||||
|
participant Profile
|
||||||
|
|
||||||
|
Note over Logic,Profile: 綁定階段
|
||||||
|
Logic->>TOTP: StartEnroll(tenant, uid, account)
|
||||||
|
TOTP->>Redis: Save encrypted staged secret
|
||||||
|
TOTP-->>Logic: otpauth_url (QR code)
|
||||||
|
User->>User: 掃碼加入 Authenticator
|
||||||
|
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
|
||||||
|
TOTP->>Profile: Save encrypted secret + backup hashes
|
||||||
|
TOTP->>Redis: Delete staged secret
|
||||||
|
TOTP-->>Logic: backup_codes[] (只顯示一次)
|
||||||
|
|
||||||
|
Note over Logic,Profile: 日常使用(step-up)
|
||||||
|
Logic->>TOTP: VerifyCode(tenant, uid, code)
|
||||||
|
TOTP->>Profile: Decrypt secret
|
||||||
|
TOTP->>Redis: MarkUsed(timestep) — 防重放
|
||||||
|
TOTP-->>Logic: ok / err
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code
|
||||||
|
start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com")
|
||||||
|
// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn
|
||||||
|
|
||||||
|
// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼
|
||||||
|
backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913")
|
||||||
|
// backupCodes 只回傳這一次,請引導使用者妥善保存
|
||||||
|
|
||||||
|
// 3. step-up 驗碼 — TOTP 或備援碼皆可
|
||||||
|
err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP
|
||||||
|
err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪)
|
||||||
|
|
||||||
|
// 4. 查狀態
|
||||||
|
status, err := totpUC.Status(ctx, "t1", "u1")
|
||||||
|
// status.Enrolled, status.BackupCodesRemaining
|
||||||
|
|
||||||
|
// 5. 停用 / 重產備援碼(logic 層應先要求 step-up)
|
||||||
|
_ = totpUC.Disable(ctx, "t1", "u1")
|
||||||
|
newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### VerifyCode 判定順序
|
||||||
|
|
||||||
|
1. 長度 = 6 → 當 TOTP 驗(含 ±window)
|
||||||
|
2. 通過 → Redis 記錄 time step;已用過則回 `ErrTOTPCodeReplay`
|
||||||
|
3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
|
||||||
|
4. 皆失敗 → `ErrTOTPInvalidCode`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 設定
|
||||||
|
|
||||||
|
`etc/gateway.dev.yaml` → `Member` 區塊:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Member:
|
||||||
|
OTP:
|
||||||
|
Length: 6 # 驗證碼位數
|
||||||
|
TTLSeconds: 300 # challenge 存活時間
|
||||||
|
MaxAttempts: 5 # 單 challenge 最大錯誤次數
|
||||||
|
ResendCooldownSeconds: 60 # 重發冷卻(logic 層用 VerifyRateStore)
|
||||||
|
DailyVerifyLimit: 10 # 每日上限(logic 層用 VerifyRateStore)
|
||||||
|
TOTP:
|
||||||
|
Issuer: CloudEP
|
||||||
|
Algorithm: SHA1
|
||||||
|
Digits: 6
|
||||||
|
PeriodSeconds: 30
|
||||||
|
Window: 1 # ±1 time step
|
||||||
|
BackupCodeCount: 10
|
||||||
|
BackupCodeLength: 12
|
||||||
|
EnrollTTLSeconds: 600 # 綁定 staged secret TTL
|
||||||
|
ReplayTTLSeconds: 90 # 重放保護 TTL
|
||||||
|
SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空則不啟用 TOTP
|
||||||
|
```
|
||||||
|
|
||||||
|
`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入(production 建議走 KMS / secret manager)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 裝配與注入
|
||||||
|
|
||||||
|
### Module factory
|
||||||
|
|
||||||
|
```go
|
||||||
|
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||||
|
Redis: rds,
|
||||||
|
Config: c.Member,
|
||||||
|
})
|
||||||
|
// mod.OTP — 永遠有值(需 Redis)
|
||||||
|
// mod.TOTP — SecretKEK 有設定時才有值,否則 nil
|
||||||
|
// mod.VerifyRate — resend / daily cap
|
||||||
|
// mod.Profile — 預設 memory,P4 換 Mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
### ServiceContext
|
||||||
|
|
||||||
|
Gateway 啟動時(Redis 就緒)自動注入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
svc.MemberOTP // domusecase.OTPUseCase
|
||||||
|
svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil)
|
||||||
|
svc.MemberVerifyRate // VerifyRateStore
|
||||||
|
svc.MemberProfile // ProfileRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logic 層編排範例
|
||||||
|
|
||||||
|
以下示範 **verify business email** 完整流程(logic 層職責,尚未有 HTTP handler):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ── 發起驗證 ──
|
||||||
|
dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
|
||||||
|
TenantID: tenant, UID: uid,
|
||||||
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
|
Target: email,
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
|
||||||
|
_, err = svc.Notifier.Send(ctx, ¬if.SendRequest{
|
||||||
|
TenantID: tenant, UID: uid,
|
||||||
|
Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail,
|
||||||
|
Target: email, Locale: locale,
|
||||||
|
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
|
||||||
|
IdempotencyKey: dto.ChallengeID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = svc.MemberOTP.Invalidate(ctx, dto.ChallengeID) // 寄送失敗回滾
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return dto // 回傳 challenge_id 給前端
|
||||||
|
|
||||||
|
// ── 確認驗證 ──
|
||||||
|
target, err := svc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{
|
||||||
|
TenantID: tenant, UID: uid,
|
||||||
|
ChallengeID: req.ChallengeID, Code: req.Code,
|
||||||
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
|
||||||
|
return svc.MemberProfile.SetBusinessEmailVerified(ctx, tenant, uid, target)
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmd/notify-test` 的 `startMemberVerify` 實作了發起驗證的前半段(Generate + Send),可作為 driver 參考:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deps-up
|
||||||
|
make notify-test METHOD=member-email TO=you@example.com
|
||||||
|
make notify-test METHOD=member-phone PHONE=0912345678
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis Key 命名
|
||||||
|
|
||||||
|
| Key 前綴 | 用途 |
|
||||||
|
|----------|------|
|
||||||
|
| `member:otp:challenge:{id}` | OTP challenge 狀態 |
|
||||||
|
| `member:otp:challenge:{id}:attempts` | 錯誤次數計數 |
|
||||||
|
| `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 |
|
||||||
|
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 |
|
||||||
|
| `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret |
|
||||||
|
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 |
|
||||||
|
|
||||||
|
Helper 函式見 `domain/redis.go`(`GetOTPChallengeRedisKey` 等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 測試
|
||||||
|
|
||||||
|
### 單元測試
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/model/member/... -v
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 互動式 TOTP(Google Authenticator)
|
||||||
|
|
||||||
|
本機需 Redis,並在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deps-up
|
||||||
|
make totp-test
|
||||||
|
```
|
||||||
|
|
||||||
|
流程(單一 process,預設 `-step flow`):
|
||||||
|
|
||||||
|
1. 終端機印出 **QR code** 與 **Secret key**
|
||||||
|
2. 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret)
|
||||||
|
3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼)
|
||||||
|
4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**(step-up 驗證)
|
||||||
|
5. 自動測試重放保護(同一碼再驗應失敗)
|
||||||
|
|
||||||
|
進階:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make totp-test STEP=status
|
||||||
|
make totp-test STEP=disable
|
||||||
|
make totp-test STEP=verify CODE=482913
|
||||||
|
```
|
||||||
|
|
||||||
|
| 檔案 | 覆蓋 |
|
||||||
|
|------|------|
|
||||||
|
| `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock |
|
||||||
|
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
|
||||||
|
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
|
||||||
|
| `library/crypto/aesgcm_test.go` | TOTP secret 加解密 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 尚未實作
|
||||||
|
|
||||||
|
- HTTP API / goctl handler(verify-email、verify-phone、totp enroll 等)
|
||||||
|
- Logic 層 confirm 流程(Verify + Profile flip + rate limit)
|
||||||
|
- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory)
|
||||||
|
- Step-up token 簽發(auth 模組)
|
||||||
|
|
||||||
|
設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。
|
||||||
|
|
@ -3,8 +3,10 @@ package config
|
||||||
// Config is member module settings (embedded in gateway root config).
|
// Config is member module settings (embedded in gateway root config).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
OTP OTPConfig `json:",optional"`
|
OTP OTPConfig `json:",optional"`
|
||||||
|
TOTP TOTPConfig `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OTPConfig governs the business OTP primitive (email/phone verification).
|
||||||
type OTPConfig struct {
|
type OTPConfig struct {
|
||||||
Length int `json:",optional"`
|
Length int `json:",optional"`
|
||||||
TTLSeconds int `json:",optional"`
|
TTLSeconds int `json:",optional"`
|
||||||
|
|
@ -13,6 +15,24 @@ type OTPConfig struct {
|
||||||
DailyVerifyLimit int `json:",optional"`
|
DailyVerifyLimit int `json:",optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPConfig governs business-tier RFC 6238 TOTP (step-up MFA).
|
||||||
|
//
|
||||||
|
// SecretKEK is the 32-byte master key used to AES-256-GCM encrypt member
|
||||||
|
// secrets at rest. Accepts hex (64 chars) or base64. Empty disables the
|
||||||
|
// TOTPUseCase wiring (factory returns an error).
|
||||||
|
type TOTPConfig struct {
|
||||||
|
Issuer string `json:",optional"`
|
||||||
|
Algorithm string `json:",optional"`
|
||||||
|
Digits int `json:",optional"`
|
||||||
|
PeriodSeconds int `json:",optional"`
|
||||||
|
Window int `json:",optional"`
|
||||||
|
BackupCodeCount int `json:",optional"`
|
||||||
|
BackupCodeLength int `json:",optional"`
|
||||||
|
EnrollTTLSeconds int `json:",optional"`
|
||||||
|
ReplayTTLSeconds int `json:",optional"`
|
||||||
|
SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"`
|
||||||
|
}
|
||||||
|
|
||||||
// Defaults returns zero-value-safe defaults.
|
// Defaults returns zero-value-safe defaults.
|
||||||
func (c Config) Defaults() Config {
|
func (c Config) Defaults() Config {
|
||||||
if c.OTP.Length <= 0 {
|
if c.OTP.Length <= 0 {
|
||||||
|
|
@ -30,5 +50,35 @@ func (c Config) Defaults() Config {
|
||||||
if c.OTP.DailyVerifyLimit <= 0 {
|
if c.OTP.DailyVerifyLimit <= 0 {
|
||||||
c.OTP.DailyVerifyLimit = 10
|
c.OTP.DailyVerifyLimit = 10
|
||||||
}
|
}
|
||||||
|
if c.TOTP.Issuer == "" {
|
||||||
|
c.TOTP.Issuer = "CloudEP"
|
||||||
|
}
|
||||||
|
if c.TOTP.Algorithm == "" {
|
||||||
|
c.TOTP.Algorithm = "SHA1"
|
||||||
|
}
|
||||||
|
if c.TOTP.Digits <= 0 {
|
||||||
|
c.TOTP.Digits = 6
|
||||||
|
}
|
||||||
|
if c.TOTP.PeriodSeconds <= 0 {
|
||||||
|
c.TOTP.PeriodSeconds = 30
|
||||||
|
}
|
||||||
|
if c.TOTP.Window < 0 {
|
||||||
|
c.TOTP.Window = 0
|
||||||
|
}
|
||||||
|
if c.TOTP.Window == 0 {
|
||||||
|
c.TOTP.Window = 1
|
||||||
|
}
|
||||||
|
if c.TOTP.BackupCodeCount <= 0 {
|
||||||
|
c.TOTP.BackupCodeCount = 10
|
||||||
|
}
|
||||||
|
if c.TOTP.BackupCodeLength <= 0 {
|
||||||
|
c.TOTP.BackupCodeLength = 12
|
||||||
|
}
|
||||||
|
if c.TOTP.EnrollTTLSeconds <= 0 {
|
||||||
|
c.TOTP.EnrollTTLSeconds = 600
|
||||||
|
}
|
||||||
|
if c.TOTP.ReplayTTLSeconds <= 0 {
|
||||||
|
c.TOTP.ReplayTTLSeconds = 90
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
)
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
"gateway/internal/model/member"
|
member "gateway/internal/model/member/domain"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -3,36 +3,52 @@ package usecase
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
libcrypto "gateway/internal/library/crypto"
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
memberconfig "gateway/internal/model/member/config"
|
memberconfig "gateway/internal/model/member/config"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
domusecase "gateway/internal/model/member/domain/usecase"
|
domusecase "gateway/internal/model/member/domain/usecase"
|
||||||
"gateway/internal/model/member/repository"
|
"gateway/internal/model/member/repository"
|
||||||
domnotif "gateway/internal/model/notification/domain/usecase"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Module bundles member use cases.
|
// Module bundles member atomic primitives. Each entry is a single-purpose
|
||||||
|
// usecase; composite flows (e.g. "send verification email then mark
|
||||||
|
// business_email verified") are assembled at the logic / driver layer and
|
||||||
|
// MUST NOT live inside another usecase.
|
||||||
type Module struct {
|
type Module struct {
|
||||||
|
// OTP issues and verifies one-time codes (purpose-agnostic).
|
||||||
OTP domusecase.OTPUseCase
|
OTP domusecase.OTPUseCase
|
||||||
Verification domusecase.VerificationUseCase
|
// TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream
|
||||||
|
// code must gracefully degrade (e.g. fall back to SMS/email OTP at the
|
||||||
|
// logic layer).
|
||||||
|
TOTP domusecase.TOTPUseCase
|
||||||
|
|
||||||
|
// Stores exposed for logic-layer orchestration (rate limit, profile
|
||||||
|
// flips). They are intentionally surfaced so the logic layer can compose
|
||||||
|
// atomic usecases with rate-limit + profile mutation without re-wiring.
|
||||||
|
VerifyRate domrepo.VerifyRateStore
|
||||||
|
Profile domrepo.ProfileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModuleParam wires member module dependencies.
|
// ModuleParam wires member module dependencies.
|
||||||
type ModuleParam struct {
|
type ModuleParam struct {
|
||||||
Redis *redislib.Client
|
Redis *redislib.Client
|
||||||
Notifier domnotif.NotifierUseCase
|
|
||||||
Config memberconfig.Config
|
Config memberconfig.Config
|
||||||
Profile domrepo.ProfileRepository // optional; defaults to memory
|
// Profile is optional; defaults to memory repository.
|
||||||
|
Profile domrepo.ProfileRepository
|
||||||
|
// TOTPProfile is optional; defaults to memory repository.
|
||||||
|
TOTPProfile domrepo.TOTPProfileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModuleFromParam builds member use cases.
|
// NewModuleFromParam builds member atomic usecases.
|
||||||
|
//
|
||||||
|
// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local
|
||||||
|
// dev / unit tests boot without a KMS-backed key while production deployments
|
||||||
|
// fail loud when the operator forgets to configure it.
|
||||||
func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
if param.Redis == nil || param.Redis.Zero() == nil {
|
if param.Redis == nil || param.Redis.Zero() == nil {
|
||||||
return nil, fmt.Errorf("member: redis is required")
|
return nil, fmt.Errorf("member: redis is required")
|
||||||
}
|
}
|
||||||
if param.Notifier == nil {
|
|
||||||
return nil, fmt.Errorf("member: notifier is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
||||||
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
||||||
|
|
@ -42,14 +58,29 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := param.Config.Defaults()
|
cfg := param.Config.Defaults()
|
||||||
otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg})
|
mod := &Module{
|
||||||
verificationUC := MustVerificationUseCase(VerificationUseCaseParam{
|
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
||||||
OTP: otpUC,
|
VerifyRate: rateStore,
|
||||||
Notifier: param.Notifier,
|
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Rates: rateStore,
|
}
|
||||||
|
|
||||||
|
if cfg.TOTP.SecretKEK != "" {
|
||||||
|
cipher, err := libcrypto.NewAESGCMFromString(cfg.TOTP.SecretKEK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("member: totp kek: %w", err)
|
||||||
|
}
|
||||||
|
totpProfile := param.TOTPProfile
|
||||||
|
if totpProfile == nil {
|
||||||
|
totpProfile = repository.NewMemoryTOTPProfileRepository()
|
||||||
|
}
|
||||||
|
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
|
||||||
|
Profile: totpProfile,
|
||||||
|
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
|
||||||
|
Replay: repository.NewRedisTOTPReplayStore(param.Redis),
|
||||||
|
Cipher: cipher,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return &Module{OTP: otpUC, Verification: verificationUC}, nil
|
|
||||||
|
return mod, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import (
|
||||||
|
|
||||||
errs "gateway/internal/library/errors"
|
errs "gateway/internal/library/errors"
|
||||||
"gateway/internal/library/errors/code"
|
"gateway/internal/library/errors/code"
|
||||||
"gateway/internal/model/member"
|
|
||||||
memberconfig "gateway/internal/model/member/config"
|
memberconfig "gateway/internal/model/member/config"
|
||||||
|
member "gateway/internal/model/member/domain"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
domusecase "gateway/internal/model/member/domain/usecase"
|
domusecase "gateway/internal/model/member/domain/usecase"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import (
|
||||||
|
|
||||||
func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
||||||
mr := miniredis.RunT(t)
|
mr := miniredis.RunT(t)
|
||||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
||||||
|
|
@ -31,7 +31,7 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
||||||
TenantID: "t1",
|
TenantID: "t1",
|
||||||
UID: "u1",
|
UID: "u1",
|
||||||
Purpose: enum.OTPPurposeBusinessEmail,
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
Target: "user@example.com",
|
Target: testUserEmail,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, code)
|
require.NotEmpty(t, code)
|
||||||
|
|
@ -44,12 +44,12 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
||||||
Purpose: enum.OTPPurposeBusinessEmail,
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "user@example.com", target)
|
require.Equal(t, testUserEmail, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
||||||
mr := miniredis.RunT(t)
|
mr := miniredis.RunT(t)
|
||||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
||||||
|
|
@ -61,7 +61,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
||||||
TenantID: "t1",
|
TenantID: "t1",
|
||||||
UID: "victim",
|
UID: "victim",
|
||||||
Purpose: enum.OTPPurposeBusinessEmail,
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
Target: "user@example.com",
|
Target: testUserEmail,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
||||||
|
|
||||||
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
|
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
|
||||||
mr := miniredis.RunT(t)
|
mr := miniredis.RunT(t)
|
||||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := memberconfig.Config{}.Defaults()
|
cfg := memberconfig.Config{}.Defaults()
|
||||||
|
|
@ -92,7 +92,7 @@ func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
|
||||||
TenantID: "t1",
|
TenantID: "t1",
|
||||||
UID: "u1",
|
UID: "u1",
|
||||||
Purpose: enum.OTPPurposeBusinessEmail,
|
Purpose: enum.OTPPurposeBusinessEmail,
|
||||||
Target: "user@example.com",
|
Target: testUserEmail,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package usecase_test
|
||||||
|
|
||||||
|
const (
|
||||||
|
testRedisTypeNode = "node"
|
||||||
|
testUserEmail = "user@example.com"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -72,11 +72,11 @@ func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) {
|
||||||
return m.MessageID, nil
|
return m.MessageID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateForLog(s string, max int) string {
|
func truncateForLog(s string, maxLen int) string {
|
||||||
if max <= 0 || len(s) <= max {
|
if maxLen <= 0 || len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:max] + "…(truncated)"
|
return s[:maxLen] + "…(truncated)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockSender) Calls() []*Message {
|
func (m *MockSender) Calls() []*Message {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
"gateway/internal/library/validate"
|
"gateway/internal/library/validate"
|
||||||
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
memberusecase "gateway/internal/model/member/usecase"
|
memberusecase "gateway/internal/model/member/usecase"
|
||||||
domnotif "gateway/internal/model/notification/domain/usecase"
|
domnotif "gateway/internal/model/notification/domain/usecase"
|
||||||
|
|
@ -27,8 +28,20 @@ type ServiceContext struct {
|
||||||
NotificationAdmin domnotif.AdminNotifierUseCase
|
NotificationAdmin domnotif.AdminNotifierUseCase
|
||||||
// NotificationRetry runs async delivery when Mongo + Redis are configured.
|
// NotificationRetry runs async delivery when Mongo + Redis are configured.
|
||||||
NotificationRetry *notification_retry.Runner
|
NotificationRetry *notification_retry.Runner
|
||||||
// MemberVerification is nil when Mongo/Redis/Notifier are not fully configured.
|
|
||||||
MemberVerification dommember.VerificationUseCase
|
// MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate).
|
||||||
|
// nil when Redis is not configured. Logic layer composes it with the
|
||||||
|
// Notifier + Profile flips; usecases MUST NOT call other usecases.
|
||||||
|
MemberOTP dommember.OTPUseCase
|
||||||
|
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
|
||||||
|
// is unset or Redis is missing.
|
||||||
|
MemberTOTP dommember.TOTPUseCase
|
||||||
|
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the
|
||||||
|
// logic layer.
|
||||||
|
MemberVerifyRate domrepo.VerifyRateStore
|
||||||
|
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by
|
||||||
|
// the logic layer after a successful OTP confirmation.
|
||||||
|
MemberProfile domrepo.ProfileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceContext(c config.Config) *ServiceContext {
|
func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
|
@ -60,16 +73,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
sc.NotificationAdmin = mod.Admin
|
sc.NotificationAdmin = mod.Admin
|
||||||
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
||||||
}
|
}
|
||||||
if c.Mongo.Host != "" && rds != nil && sc.Notifier != nil {
|
if rds != nil && rds.Zero() != nil {
|
||||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||||
Redis: rds,
|
Redis: rds,
|
||||||
Notifier: sc.Notifier,
|
|
||||||
Config: c.Member,
|
Config: c.Member,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
sc.MemberVerification = memberMod.Verification
|
sc.MemberOTP = memberMod.OTP
|
||||||
|
sc.MemberTOTP = memberMod.TOTP
|
||||||
|
sc.MemberVerifyRate = memberMod.VerifyRate
|
||||||
|
sc.MemberProfile = memberMod.Profile
|
||||||
}
|
}
|
||||||
return sc
|
return sc
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue