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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user