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
+14 -2
View File
@@ -101,12 +101,24 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &req) {
return
}
pair, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
if apiErr != nil {
writeAPIErr(w, apiErr, retryAfter)
return
}
writeTokenPair(w, pair)
// TOTP-enabled accounts get a pending token instead of tokens; the web
// user-center then completes login at /auth/login/totp. The app (no TOTP)
// always receives the flat token pair below.
if out.PendingToken != "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"totp_required": true,
"pending_token": out.PendingToken,
})
return
}
writeTokenPair(w, out.Tokens)
}
// Refresh handles POST /v1/auth/refresh.