add member totp
This commit is contained in:
parent
3afe3f9502
commit
240fa92f6f
|
|
@ -107,6 +107,9 @@ linters:
|
|||
- -ST1000
|
||||
- -ST1003
|
||||
- -ST1016
|
||||
# go-zero conf uses `json:",optional"` for optional fields; staticcheck
|
||||
# mis-reports it as an unknown json option, so suppress globally.
|
||||
- -SA5008
|
||||
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
|
|
|||
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
|
||||
|
||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test setup-dev run-local
|
||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local
|
||||
|
||||
help: ## 顯示可用指令
|
||||
@echo "Gateway Makefile"
|
||||
|
|
@ -112,5 +112,11 @@ notify-test: setup-dev ## 通知測試(METHOD 必填;例: make notify-test M
|
|||
$(GO) run ./cmd/notify-test -f etc/gateway.dev.yaml -method "$(METHOD)" \
|
||||
$(if $(TO),-to "$(TO)",) $(if $(PHONE),-phone "$(PHONE)",) $(if $(MOCK),-mock,)
|
||||
|
||||
totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;需 Redis)
|
||||
$(GO) run ./cmd/totp-test -f etc/gateway.dev.yaml \
|
||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(UID),-uid "$(UID)",) \
|
||||
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
||||
$(if $(CODE),-code "$(CODE)",)
|
||||
|
||||
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
||||
|
|
|
|||
|
|
@ -18,13 +18,19 @@ import (
|
|||
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)")
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
if c.Mongo.Host == "" {
|
||||
fmt.Fprintln(os.Stderr, "mongo-index: Mongo.Host is empty in config")
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("mongo-index: Mongo.Host is empty in config")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
|
@ -34,13 +40,12 @@ func main() {
|
|||
dlqRepo := notifrepo.NewNotificationDLQRepository(notifrepo.NotificationDLQRepositoryParam{Conf: &c.Mongo})
|
||||
|
||||
if err := notifRepo.Index20260520001UP(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mongo-index: notifications: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("mongo-index: notifications: %w", err)
|
||||
}
|
||||
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mongo-index: notification_dlq: %v\n", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
|
||||
"gateway/internal/config"
|
||||
redislib "gateway/internal/library/redis"
|
||||
memberenum "gateway/internal/model/member/domain/enum"
|
||||
dommember "gateway/internal/model/member/domain/usecase"
|
||||
memberusecase "gateway/internal/model/member/usecase"
|
||||
notifconfig "gateway/internal/model/notification/config"
|
||||
|
|
@ -28,14 +29,14 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
methodEmailSend = "email-send"
|
||||
methodEmailEnqueue = "email-enqueue"
|
||||
methodEmailIdempotency = "email-idempotency"
|
||||
methodSMSSend = "sms-send"
|
||||
methodSMSEnqueue = "sms-enqueue"
|
||||
methodMemberEmail = "member-email"
|
||||
methodMemberPhone = "member-phone"
|
||||
methodAdminDLQ = "admin-dlq"
|
||||
methodEmailSend = "email-send"
|
||||
methodEmailEnqueue = "email-enqueue"
|
||||
methodEmailIdempotency = "email-idempotency"
|
||||
methodSMSSend = "sms-send"
|
||||
methodSMSEnqueue = "sms-enqueue"
|
||||
methodMemberEmail = "member-email"
|
||||
methodMemberPhone = "member-phone"
|
||||
methodAdminDLQ = "admin-dlq"
|
||||
)
|
||||
|
||||
var validMethods = []string{
|
||||
|
|
@ -61,15 +62,17 @@ var (
|
|||
)
|
||||
|
||||
type env struct {
|
||||
ctx context.Context
|
||||
tenant string
|
||||
uid string
|
||||
to string
|
||||
phone string
|
||||
locale string
|
||||
notifier domusecase.NotifierUseCase
|
||||
verification dommember.VerificationUseCase
|
||||
admin domusecase.AdminNotifierUseCase
|
||||
ctx context.Context
|
||||
tenant string
|
||||
uid string
|
||||
to string
|
||||
phone string
|
||||
locale string
|
||||
notifier domusecase.NotifierUseCase
|
||||
// otp is the atomic primitive; this CLI plays the role of the future
|
||||
// logic layer and orchestrates OTP.Generate + Notifier.Send inline.
|
||||
otp dommember.OTPUseCase
|
||||
admin domusecase.AdminNotifierUseCase
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -90,32 +93,41 @@ func main() {
|
|||
}
|
||||
flag.Parse()
|
||||
|
||||
code, err := run()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
if code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// run wires the requested method and returns (exitCode, error). Deferred
|
||||
// cleanups inside run always execute before main calls os.Exit.
|
||||
func run() (int, error) {
|
||||
m := strings.TrimSpace(*method)
|
||||
if m == "" {
|
||||
fmt.Fprintln(os.Stderr, "notify-test: -method is required")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
return 2, fmt.Errorf("notify-test: -method is required")
|
||||
}
|
||||
if !isValidMethod(m) {
|
||||
fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m)
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
return 2, fmt.Errorf("notify-test: unknown method %q", m)
|
||||
}
|
||||
if err := validateArgs(m); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "notify-test: %v\n", err)
|
||||
os.Exit(2)
|
||||
return 2, fmt.Errorf("notify-test: %w", err)
|
||||
}
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
if c.Mongo.Host == "" {
|
||||
fail("Mongo.Host is empty")
|
||||
return 1, fmt.Errorf("notify-test: Mongo.Host is empty")
|
||||
}
|
||||
if c.Redis.Host == "" {
|
||||
fail("Redis.Host is empty")
|
||||
return 1, fmt.Errorf("notify-test: Redis.Host is empty")
|
||||
}
|
||||
if c.Notification.Email.From == "" && needsEmailFrom(m) {
|
||||
fail("Notification.Email.From is empty")
|
||||
return 1, fmt.Errorf("notify-test: Notification.Email.From is empty")
|
||||
}
|
||||
if *mockOnly {
|
||||
forceMock(&c.Notification)
|
||||
|
|
@ -126,7 +138,7 @@ func main() {
|
|||
|
||||
rds, err := redislib.NewClient(c.Redis)
|
||||
if err != nil {
|
||||
fail("redis: %v", err)
|
||||
return 1, fmt.Errorf("notify-test: redis: %w", err)
|
||||
}
|
||||
|
||||
mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{
|
||||
|
|
@ -135,37 +147,36 @@ func main() {
|
|||
Config: c.Notification,
|
||||
})
|
||||
if err != nil {
|
||||
fail("notification: %v", err)
|
||||
return 1, fmt.Errorf("notify-test: notification: %w", err)
|
||||
}
|
||||
|
||||
var verification dommember.VerificationUseCase
|
||||
var otpUC dommember.OTPUseCase
|
||||
if m == methodMemberEmail || m == methodMemberPhone {
|
||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||
Redis: rds,
|
||||
Notifier: mod.Notifier,
|
||||
Config: c.Member,
|
||||
memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||
Redis: rds,
|
||||
Config: c.Member,
|
||||
})
|
||||
if err != nil {
|
||||
fail("member: %v", err)
|
||||
if memErr != nil {
|
||||
return 1, fmt.Errorf("notify-test: member: %w", memErr)
|
||||
}
|
||||
verification = memberMod.Verification
|
||||
otpUC = memberMod.OTP
|
||||
}
|
||||
|
||||
e := &env{
|
||||
ctx: ctx,
|
||||
tenant: *tenantID,
|
||||
uid: *uid,
|
||||
to: *toEmail,
|
||||
phone: *phone,
|
||||
locale: c.Notification.DefaultLocale,
|
||||
notifier: mod.Notifier,
|
||||
verification: verification,
|
||||
admin: mod.Admin,
|
||||
ctx: ctx,
|
||||
tenant: *tenantID,
|
||||
uid: *uid,
|
||||
to: *toEmail,
|
||||
phone: *phone,
|
||||
locale: c.Notification.DefaultLocale,
|
||||
notifier: mod.Notifier,
|
||||
otp: otpUC,
|
||||
admin: mod.Admin,
|
||||
}
|
||||
|
||||
if m == methodEmailEnqueue || m == methodSMSEnqueue {
|
||||
if mod.RetryWorker == nil {
|
||||
fail("retry worker not configured (need Redis)")
|
||||
return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)")
|
||||
}
|
||||
workerCtx, stop := context.WithCancel(context.Background())
|
||||
go mod.RetryWorker.Run(workerCtx)
|
||||
|
|
@ -174,11 +185,11 @@ func main() {
|
|||
|
||||
fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ","))
|
||||
|
||||
if err := runMethod(e, m); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
os.Exit(1)
|
||||
if runErr := runMethod(e, m); runErr != nil {
|
||||
return 1, fmt.Errorf("FAIL: %w", runErr)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func runMethod(e *env, m string) error {
|
||||
|
|
@ -320,21 +331,52 @@ func (e *env) smsEnqueue() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// memberEmail demonstrates the logic-layer orchestration: generate an OTP
|
||||
// challenge (atomic) and dispatch the verification email through Notifier
|
||||
// (atomic). usecases never call each other — this driver is what the real
|
||||
// logic handler will look like.
|
||||
func (e *env) memberEmail() error {
|
||||
ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
||||
return nil
|
||||
return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to)
|
||||
}
|
||||
|
||||
func (e *env) memberPhone() error {
|
||||
ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw")
|
||||
return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone)
|
||||
}
|
||||
|
||||
func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error {
|
||||
if e.otp == nil {
|
||||
return fmt.Errorf("member OTP usecase not configured")
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("target is empty")
|
||||
}
|
||||
dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{
|
||||
TenantID: e.tenant,
|
||||
UID: e.uid,
|
||||
Purpose: purpose,
|
||||
Target: target,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn)
|
||||
if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{
|
||||
TenantID: e.tenant,
|
||||
UID: e.uid,
|
||||
Channel: channel,
|
||||
Kind: kind,
|
||||
Target: target,
|
||||
Locale: e.locale,
|
||||
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
|
||||
IdempotencyKey: dto.ChallengeID,
|
||||
DoNotPersistBody: true,
|
||||
Severity: enum.SeverityInfo,
|
||||
}); err != nil {
|
||||
if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -443,8 +485,3 @@ func smsProviders(cfg *notifconfig.Config) []string {
|
|||
}
|
||||
return []string{"mock"}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 可任意組合,跨流程共用。
|
||||
> 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、流程編排)目前**不實作**;先固化介面契約。
|
||||
|
||||
|
|
|
|||
|
|
@ -15,25 +15,30 @@
|
|||
```
|
||||
internal/model/
|
||||
└── {module}/ # 例:notification、member、permission
|
||||
├── domain/ # 純領域:介面、實體、列舉、DTO(不依賴 mongo/redis/provider)
|
||||
├── domain/ # 純領域:介面、實體、列舉、DTO、模組級定義
|
||||
│ ├── entity/ # Mongo document 結構 + CollectionName()
|
||||
│ ├── enum/ # Channel、Status、Platform…
|
||||
│ ├── repository/ # Repository / Cache 介面 only
|
||||
│ ├── usecase/ # UseCase 介面 + Request/Response DTO
|
||||
│ └── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification)
|
||||
│ ├── template/ # 可選:模板 Spec、Registry、Renderer 介面(notification)
|
||||
│ ├── errors.go # 模組 sentinel(package domain)
|
||||
│ ├── const.go # BSON 欄位名、模組常數(package domain)
|
||||
│ └── redis.go # Redis key 命名(package domain)
|
||||
├── repository/ # domain/repository 的 Mongo / Redis / memory 實作
|
||||
├── usecase/ # domain/usecase 的實作 + factory 組裝
|
||||
├── template/ # 可選:go:embed、DefaultRegistry、Renderer 實作
|
||||
├── provider/ # 可選:僅本模組用的 Sender(email/sms),不放 library/
|
||||
├── totp/、xxx/ # 可選:模組專屬純函式 library(不放 internal/library/)
|
||||
├── config/ # 模組設定 struct(嵌入 gateway Config)
|
||||
├── errors.go # 模組 sentinel
|
||||
├── const.go # BSON 欄位名、模組常數
|
||||
├── redis.go # Redis key 命名
|
||||
└── mock/ # mockgen(路徑對應 domain/)
|
||||
├── repository/
|
||||
└── usecase/
|
||||
```
|
||||
|
||||
> **定義類(errors / const / redis key)統一放 `domain/`**:caller 端以 `member "gateway/internal/model/{module}/domain"` 取用,引用形式仍為 `member.ErrXxx` / `member.Get…RedisKey`,但這些 sentinel 與 key helper 都在 `package domain`,與 `domain/entity` 等子套件平行。
|
||||
>
|
||||
> **`internal/library/` 只放跨模組真正共用的東西**(如 `library/errors`、`library/mongo`、`library/redis`、`library/crypto`)。僅某個模組會用的純函式 / 演算法(例如 member 的 RFC 6238 TOTP helper)應落在該模組底下,例如 `internal/model/member/totp/`,避免污染 library 命名空間。
|
||||
|
||||
**參考實作:** [`internal/model/notification/`](../internal/model/notification/)(N0–N5 核心已完成;流程圖與設定見 [**notification README**](../internal/model/notification/README.md))。
|
||||
|
||||
| 層 | 路徑 | 內容 |
|
||||
|
|
@ -285,6 +290,17 @@ type SendRequest struct {
|
|||
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
||||
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
||||
|
||||
### 6.1 UseCase 互不呼叫(atomic-only)
|
||||
|
||||
> **強制規則**:UseCase 是 **atomic primitive**,**禁止**在 usecase 內部呼叫其他 usecase(不論是同模組或跨模組)。
|
||||
>
|
||||
> - usecase struct 的依賴**只能**是 `domain/repository` 介面、`provider/`、`template/`、library helper、`config`。
|
||||
> - **不可**在 `XxxUseCaseParam` 出現另一個 `domain/usecase.XxxUseCase` 欄位。
|
||||
> - 需要把多個 atomic 串成一個業務流程(例如「OTP.Generate → Notifier.Send → Profile.SetVerified」)時,**編排在 `internal/logic/`**;logic handler 持有多個 usecase interface 並負責順序、補償、rate-limit、step-up 守門。
|
||||
> - CLI / driver(如 `cmd/notify-test/`)扮演 logic 同等角色:直接組 atomic,不應該被包成 composite usecase。
|
||||
>
|
||||
> 這條規則優先於 [identity-member-design.md §5.2](./identity-member-design.md) 提到的 Composite UseCase;該節保留為「**邏輯流的描述**」,不代表 `domain/usecase` 會出現 composite interface。
|
||||
|
||||
**範例:**
|
||||
|
||||
```go
|
||||
|
|
@ -310,12 +326,12 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
|
|||
|
||||
## 7. 錯誤處理
|
||||
|
||||
全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。
|
||||
全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。
|
||||
|
||||
### 7.1 模組 sentinel(`errors.go`)
|
||||
### 7.1 模組 sentinel(`domain/errors.go`)
|
||||
|
||||
```go
|
||||
package member
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
|
|
@ -325,7 +341,7 @@ var (
|
|||
)
|
||||
```
|
||||
|
||||
(專案慣例:`fmt.Errorf` 定義 sentinel,便於 `%w` 包裝;見 `notification/errors.go`。)
|
||||
(專案慣例:sentinel 一律以 `fmt.Errorf` 定義,便於 `%w` 包裝。caller 端 `member "gateway/internal/model/member/domain"` 後即可 `member.ErrNotFound`。)
|
||||
|
||||
### 7.2 Repository
|
||||
|
||||
|
|
@ -354,24 +370,36 @@ var (
|
|||
|
||||
| 檔案 | 用途 |
|
||||
|------|------|
|
||||
| `errors.go` | 模組 sentinel(`ErrNotFound` 等) |
|
||||
| `const.go` | 模組字面常數 |
|
||||
| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper |
|
||||
| `domain/errors.go` | 模組 sentinel(`ErrNotFound` 等,`package domain`) |
|
||||
| `domain/const.go` | 模組字面常數(`package domain`) |
|
||||
| `domain/redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper(`package domain`) |
|
||||
| `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) |
|
||||
|
||||
Redis key 統一帶業務 prefix,避免跨服務衝突:
|
||||
|
||||
```go
|
||||
package domain
|
||||
|
||||
type RedisKey string
|
||||
|
||||
const AccountRedisKey RedisKey = "member:account"
|
||||
|
||||
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
|
||||
func GetAccountRedisKey(id string) string {
|
||||
return AccountRedisKey.With(id).ToString()
|
||||
return AccountRedisKey.With(id).String()
|
||||
}
|
||||
```
|
||||
|
||||
Caller 端:
|
||||
|
||||
```go
|
||||
import (
|
||||
member "gateway/internal/model/member/domain"
|
||||
)
|
||||
|
||||
// 使用:member.GetAccountRedisKey(id)、member.ErrNotFound
|
||||
```
|
||||
|
||||
## 9. Mock(`mock/` + gomock)
|
||||
|
||||
**方案 A(本專案採用):**
|
||||
|
|
@ -433,7 +461,7 @@ make gen-mock
|
|||
|
||||
**Notification 模組進度(參考):** N0–N5 核心 ✅(含 `RetryWorker`、`AdminNotifierUseCase`);文件見 [notification README](../internal/model/notification/README.md)。待做:HTTP admin API(goctl)。
|
||||
|
||||
**Member 模組進度(P3.5):** `OTPUseCase` + `VerificationUseCase`(email/phone)✅,經 `Notifier.Send` 投遞;`ProfileRepository` 暫用 memory(P4 換 Mongo)。`ServiceContext.MemberVerification` 在 Mongo+Redis+Notifier 就緒時注入。後續:Step-up / TOTP、HTTP API(goctl)。
|
||||
**Member 模組進度(P3.5):** atomic primitives `OTPUseCase`(Generate/Verify/Invalidate)+ `TOTPUseCase`(enroll/verify/backup/disable)+ `VerifyRateStore` + `ProfileRepository` ✅。**usecase 之間不互相呼叫**:「業務 email/phone 驗證 = OTP.Generate → Notifier.Send → Profile.SetXxxVerified」的編排由 **logic 層**負責(尚未實作);參考實作見 `cmd/notify-test/main.go::startMemberVerify`(driver 等同 logic 角色)。後續:HTTP API(goctl)+ logic 層編排(含 rate-limit + step-up 守門)。
|
||||
|
||||
## 12. 與 Gateway HTTP 層的關係
|
||||
|
||||
|
|
|
|||
|
|
@ -65,3 +65,16 @@ Member:
|
|||
MaxAttempts: 5
|
||||
ResendCooldownSeconds: 60
|
||||
DailyVerifyLimit: 10
|
||||
TOTP:
|
||||
Issuer: CloudEP
|
||||
Algorithm: SHA1
|
||||
Digits: 6
|
||||
PeriodSeconds: 30
|
||||
Window: 1
|
||||
BackupCodeCount: 10
|
||||
BackupCodeLength: 12
|
||||
EnrollTTLSeconds: 600
|
||||
ReplayTTLSeconds: 90
|
||||
# 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP.
|
||||
# Dev-only placeholder for local totp-test; replace in production.
|
||||
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
|
|
|
|||
|
|
@ -59,3 +59,14 @@ Member:
|
|||
MaxAttempts: 5
|
||||
ResendCooldownSeconds: 60
|
||||
DailyVerifyLimit: 10
|
||||
TOTP:
|
||||
Issuer: CloudEP
|
||||
Algorithm: SHA1
|
||||
Digits: 6
|
||||
PeriodSeconds: 30
|
||||
Window: 1
|
||||
BackupCodeCount: 10
|
||||
BackupCodeLength: 12
|
||||
EnrollTTLSeconds: 600
|
||||
ReplayTTLSeconds: 90
|
||||
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -51,6 +51,7 @@ require (
|
|||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/titanous/json5 v1.0.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
|
|
|
|||
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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import (
|
|||
|
||||
type Config struct {
|
||||
rest.RestConf
|
||||
Mongo mongo.Conf `json:",optional"`
|
||||
Redis redis.RedisConf `json:",optional"`
|
||||
Notification notifconfig.Config `json:",optional"`
|
||||
Member memberconfig.Config `json:",optional"`
|
||||
Mongo mongo.Conf `json:",optional"`
|
||||
Redis redis.RedisConf `json:",optional"`
|
||||
Notification notifconfig.Config `json:",optional"`
|
||||
Member memberconfig.Config `json:",optional"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
@ -2,9 +2,11 @@ package config
|
|||
|
||||
// Config is member module settings (embedded in gateway root config).
|
||||
type Config struct {
|
||||
OTP OTPConfig `json:",optional"`
|
||||
OTP OTPConfig `json:",optional"`
|
||||
TOTP TOTPConfig `json:",optional"`
|
||||
}
|
||||
|
||||
// OTPConfig governs the business OTP primitive (email/phone verification).
|
||||
type OTPConfig struct {
|
||||
Length int `json:",optional"`
|
||||
TTLSeconds int `json:",optional"`
|
||||
|
|
@ -13,6 +15,24 @@ type OTPConfig struct {
|
|||
DailyVerifyLimit int `json:",optional"`
|
||||
}
|
||||
|
||||
// TOTPConfig governs business-tier RFC 6238 TOTP (step-up MFA).
|
||||
//
|
||||
// SecretKEK is the 32-byte master key used to AES-256-GCM encrypt member
|
||||
// secrets at rest. Accepts hex (64 chars) or base64. Empty disables the
|
||||
// TOTPUseCase wiring (factory returns an error).
|
||||
type TOTPConfig struct {
|
||||
Issuer string `json:",optional"`
|
||||
Algorithm string `json:",optional"`
|
||||
Digits int `json:",optional"`
|
||||
PeriodSeconds int `json:",optional"`
|
||||
Window int `json:",optional"`
|
||||
BackupCodeCount int `json:",optional"`
|
||||
BackupCodeLength int `json:",optional"`
|
||||
EnrollTTLSeconds int `json:",optional"`
|
||||
ReplayTTLSeconds int `json:",optional"`
|
||||
SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"`
|
||||
}
|
||||
|
||||
// Defaults returns zero-value-safe defaults.
|
||||
func (c Config) Defaults() Config {
|
||||
if c.OTP.Length <= 0 {
|
||||
|
|
@ -30,5 +50,35 @@ func (c Config) Defaults() Config {
|
|||
if c.OTP.DailyVerifyLimit <= 0 {
|
||||
c.OTP.DailyVerifyLimit = 10
|
||||
}
|
||||
if c.TOTP.Issuer == "" {
|
||||
c.TOTP.Issuer = "CloudEP"
|
||||
}
|
||||
if c.TOTP.Algorithm == "" {
|
||||
c.TOTP.Algorithm = "SHA1"
|
||||
}
|
||||
if c.TOTP.Digits <= 0 {
|
||||
c.TOTP.Digits = 6
|
||||
}
|
||||
if c.TOTP.PeriodSeconds <= 0 {
|
||||
c.TOTP.PeriodSeconds = 30
|
||||
}
|
||||
if c.TOTP.Window < 0 {
|
||||
c.TOTP.Window = 0
|
||||
}
|
||||
if c.TOTP.Window == 0 {
|
||||
c.TOTP.Window = 1
|
||||
}
|
||||
if c.TOTP.BackupCodeCount <= 0 {
|
||||
c.TOTP.BackupCodeCount = 10
|
||||
}
|
||||
if c.TOTP.BackupCodeLength <= 0 {
|
||||
c.TOTP.BackupCodeLength = 12
|
||||
}
|
||||
if c.TOTP.EnrollTTLSeconds <= 0 {
|
||||
c.TOTP.EnrollTTLSeconds = 600
|
||||
}
|
||||
if c.TOTP.ReplayTTLSeconds <= 0 {
|
||||
c.TOTP.ReplayTTLSeconds = 90
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
redislib "gateway/internal/library/redis"
|
||||
"gateway/internal/model/member"
|
||||
member "gateway/internal/model/member/domain"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"fmt"
|
||||
|
||||
libcrypto "gateway/internal/library/crypto"
|
||||
redislib "gateway/internal/library/redis"
|
||||
memberconfig "gateway/internal/model/member/config"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
domusecase "gateway/internal/model/member/domain/usecase"
|
||||
"gateway/internal/model/member/repository"
|
||||
domnotif "gateway/internal/model/notification/domain/usecase"
|
||||
)
|
||||
|
||||
// Module bundles member use cases.
|
||||
// Module bundles member atomic primitives. Each entry is a single-purpose
|
||||
// usecase; composite flows (e.g. "send verification email then mark
|
||||
// business_email verified") are assembled at the logic / driver layer and
|
||||
// MUST NOT live inside another usecase.
|
||||
type Module struct {
|
||||
OTP domusecase.OTPUseCase
|
||||
Verification domusecase.VerificationUseCase
|
||||
// OTP issues and verifies one-time codes (purpose-agnostic).
|
||||
OTP domusecase.OTPUseCase
|
||||
// TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream
|
||||
// code must gracefully degrade (e.g. fall back to SMS/email OTP at the
|
||||
// logic layer).
|
||||
TOTP domusecase.TOTPUseCase
|
||||
|
||||
// Stores exposed for logic-layer orchestration (rate limit, profile
|
||||
// flips). They are intentionally surfaced so the logic layer can compose
|
||||
// atomic usecases with rate-limit + profile mutation without re-wiring.
|
||||
VerifyRate domrepo.VerifyRateStore
|
||||
Profile domrepo.ProfileRepository
|
||||
}
|
||||
|
||||
// ModuleParam wires member module dependencies.
|
||||
type ModuleParam struct {
|
||||
Redis *redislib.Client
|
||||
Notifier domnotif.NotifierUseCase
|
||||
Config memberconfig.Config
|
||||
Profile domrepo.ProfileRepository // optional; defaults to memory
|
||||
Redis *redislib.Client
|
||||
Config memberconfig.Config
|
||||
// Profile is optional; defaults to memory repository.
|
||||
Profile domrepo.ProfileRepository
|
||||
// TOTPProfile is optional; defaults to memory repository.
|
||||
TOTPProfile domrepo.TOTPProfileRepository
|
||||
}
|
||||
|
||||
// NewModuleFromParam builds member use cases.
|
||||
// NewModuleFromParam builds member atomic usecases.
|
||||
//
|
||||
// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local
|
||||
// dev / unit tests boot without a KMS-backed key while production deployments
|
||||
// fail loud when the operator forgets to configure it.
|
||||
func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||
if param.Redis == nil || param.Redis.Zero() == nil {
|
||||
return nil, fmt.Errorf("member: redis is required")
|
||||
}
|
||||
if param.Notifier == nil {
|
||||
return nil, fmt.Errorf("member: notifier is required")
|
||||
}
|
||||
|
||||
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
||||
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
||||
|
|
@ -42,14 +58,29 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
|||
}
|
||||
|
||||
cfg := param.Config.Defaults()
|
||||
otpUC := MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg})
|
||||
verificationUC := MustVerificationUseCase(VerificationUseCaseParam{
|
||||
OTP: otpUC,
|
||||
Notifier: param.Notifier,
|
||||
Profile: profile,
|
||||
Rates: rateStore,
|
||||
Config: cfg,
|
||||
})
|
||||
mod := &Module{
|
||||
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
||||
VerifyRate: rateStore,
|
||||
Profile: profile,
|
||||
}
|
||||
|
||||
return &Module{OTP: otpUC, Verification: verificationUC}, nil
|
||||
if cfg.TOTP.SecretKEK != "" {
|
||||
cipher, err := libcrypto.NewAESGCMFromString(cfg.TOTP.SecretKEK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("member: totp kek: %w", err)
|
||||
}
|
||||
totpProfile := param.TOTPProfile
|
||||
if totpProfile == nil {
|
||||
totpProfile = repository.NewMemoryTOTPProfileRepository()
|
||||
}
|
||||
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
|
||||
Profile: totpProfile,
|
||||
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
|
||||
Replay: repository.NewRedisTOTPReplayStore(param.Redis),
|
||||
Cipher: cipher,
|
||||
Config: cfg,
|
||||
})
|
||||
}
|
||||
|
||||
return mod, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
errs "gateway/internal/library/errors"
|
||||
"gateway/internal/library/errors/code"
|
||||
"gateway/internal/model/member"
|
||||
memberconfig "gateway/internal/model/member/config"
|
||||
member "gateway/internal/model/member/domain"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
domusecase "gateway/internal/model/member/domain/usecase"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import (
|
|||
|
||||
func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||
require.NoError(t, err)
|
||||
|
||||
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
||||
|
|
@ -31,7 +31,7 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
|||
TenantID: "t1",
|
||||
UID: "u1",
|
||||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
Target: "user@example.com",
|
||||
Target: testUserEmail,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, code)
|
||||
|
|
@ -44,12 +44,12 @@ func TestOTPUseCase_GenerateAndVerify(t *testing.T) {
|
|||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user@example.com", target)
|
||||
require.Equal(t, testUserEmail, target)
|
||||
}
|
||||
|
||||
func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||
require.NoError(t, err)
|
||||
|
||||
uc := usecase.MustOTPUseCase(usecase.OTPUseCaseParam{
|
||||
|
|
@ -61,7 +61,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
|||
TenantID: "t1",
|
||||
UID: "victim",
|
||||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
Target: "user@example.com",
|
||||
Target: testUserEmail,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ func TestOTPUseCase_VerifyUIDMismatch(t *testing.T) {
|
|||
|
||||
func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: "node"})
|
||||
rds, err := redislib.NewClient(redis.RedisConf{Host: mr.Addr(), Type: testRedisTypeNode})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := memberconfig.Config{}.Defaults()
|
||||
|
|
@ -92,7 +92,7 @@ func TestOTPUseCase_MaxAttemptsLocks(t *testing.T) {
|
|||
TenantID: "t1",
|
||||
UID: "u1",
|
||||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
Target: "user@example.com",
|
||||
Target: testUserEmail,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
func truncateForLog(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
func truncateForLog(s string, maxLen int) string {
|
||||
if maxLen <= 0 || len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…(truncated)"
|
||||
return s[:maxLen] + "…(truncated)"
|
||||
}
|
||||
|
||||
func (m *MockSender) Calls() []*Message {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"gateway/internal/config"
|
||||
redislib "gateway/internal/library/redis"
|
||||
"gateway/internal/library/validate"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
dommember "gateway/internal/model/member/domain/usecase"
|
||||
memberusecase "gateway/internal/model/member/usecase"
|
||||
domnotif "gateway/internal/model/notification/domain/usecase"
|
||||
|
|
@ -27,8 +28,20 @@ type ServiceContext struct {
|
|||
NotificationAdmin domnotif.AdminNotifierUseCase
|
||||
// NotificationRetry runs async delivery when Mongo + Redis are configured.
|
||||
NotificationRetry *notification_retry.Runner
|
||||
// MemberVerification is nil when Mongo/Redis/Notifier are not fully configured.
|
||||
MemberVerification dommember.VerificationUseCase
|
||||
|
||||
// MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate).
|
||||
// nil when Redis is not configured. Logic layer composes it with the
|
||||
// Notifier + Profile flips; usecases MUST NOT call other usecases.
|
||||
MemberOTP dommember.OTPUseCase
|
||||
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
|
||||
// is unset or Redis is missing.
|
||||
MemberTOTP dommember.TOTPUseCase
|
||||
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the
|
||||
// logic layer.
|
||||
MemberVerifyRate domrepo.VerifyRateStore
|
||||
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by
|
||||
// the logic layer after a successful OTP confirmation.
|
||||
MemberProfile domrepo.ProfileRepository
|
||||
}
|
||||
|
||||
func NewServiceContext(c config.Config) *ServiceContext {
|
||||
|
|
@ -60,16 +73,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||
sc.NotificationAdmin = mod.Admin
|
||||
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
||||
}
|
||||
if c.Mongo.Host != "" && rds != nil && sc.Notifier != nil {
|
||||
if rds != nil && rds.Zero() != nil {
|
||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||
Redis: rds,
|
||||
Notifier: sc.Notifier,
|
||||
Config: c.Member,
|
||||
Redis: rds,
|
||||
Config: c.Member,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sc.MemberVerification = memberMod.Verification
|
||||
sc.MemberOTP = memberMod.OTP
|
||||
sc.MemberTOTP = memberMod.TOTP
|
||||
sc.MemberVerifyRate = memberMod.VerifyRate
|
||||
sc.MemberProfile = memberMod.Profile
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue