feat(server): web 用户中心后端(3/3) — 用户 TOTP 2FA + 登录二段式

- 用户 TOTP(auth/totp_user.go,复用 internal/totp + AES-256-GCM 加密存密钥):
  POST /v1/me/totp/setup(生成密钥+otpauth_uri)、/verify(校验码→启用)、
  /disable(校验码→清空)。仅在 USER_TOTP_ENC_KEY(32B/64hex) 配置时挂载。
- 登录二段式:Login 在 totp_enabled 时不发 token,改发短期 pending token(Redis
  5min)+ 返回 {totp_required, pending_token};POST /v1/auth/login/totp 消费
  pending + 校验码 → 发 token。非 TOTP 用户仍走扁平 TokenPair,app 不受影响。
- User 结构 + GetUserByEmail 补 totp_enabled。
- 单测覆盖 AES seal/open 往返 + 篡改/错误密钥检测 + pending token 唯一性。
- 全量 server 23 包测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 08:45:50 +08:00
parent 8047d17b48
commit 0782cf651b
6 changed files with 416 additions and 13 deletions
+31 -3
View File
@@ -215,7 +215,21 @@ func (s *Service) verifyCode(ctx context.Context, email, code string) *apierr.Er
// Login authenticates email+password with constant-time behaviour and a
// failure-count lock. retryAfter is non-zero only when the account is locked.
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*TokenPair, time.Duration, *apierr.Error) {
// LoginOutcome is the result of a password login: either issued tokens, or a
// short-lived PendingToken when the account has TOTP enabled and must complete
// the second step at /auth/login/totp.
type LoginOutcome struct {
Tokens *TokenPair
PendingToken string
}
// TOTP pending-login token (Redis): maps a one-time pending token → user id.
const (
totpPendingPrefix = "totp:pending:"
totpPendingTTL = 5 * time.Minute
)
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*LoginOutcome, time.Duration, *apierr.Error) {
_ = ip // IP reserved for future per-IP login throttling; not logged.
email := NormalizeEmail(rawEmail)
if email == "" || password == "" {
@@ -252,13 +266,27 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*To
return nil, 0, ErrAccountBanned
}
// Success: clear the failure counter and issue tokens.
// Success: clear the failure counter.
_ = s.rl.ClearFailures(ctx, scopeLogin, email)
// Two-factor gate: when enabled, don't issue tokens yet — return a short-lived
// pending token the client exchanges with a TOTP code at /auth/login/totp.
if user.TOTPEnabled {
pending, perr := newToken16()
if perr != nil {
return nil, 0, ErrInternal
}
if err := s.rdb.Set(ctx, totpPendingPrefix+pending, user.ID, totpPendingTTL).Err(); err != nil {
return nil, 0, ErrInternal
}
return &LoginOutcome{PendingToken: pending}, 0, nil
}
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
if err != nil {
return nil, 0, ErrInternal
}
return pair, 0, nil
return &LoginOutcome{Tokens: pair}, 0, nil
}
// Logout revokes the given refresh token. Idempotent: an empty, unknown, or