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:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
@@ -235,6 +236,18 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
authHandler = auth.NewHandler(authSvc)
|
||||
}
|
||||
|
||||
// ── User TOTP (web 用户中心 2FA) ─────────────────────────────────────────────
|
||||
// Only enabled when a valid 32-byte key is configured (USER_TOTP_ENC_KEY,
|
||||
// raw 32 bytes or 64 hex chars); secrets are encrypted at rest under it.
|
||||
var totpHandler *auth.TOTPHandler
|
||||
if tm != nil {
|
||||
if key := parseTOTPKey(os.Getenv("USER_TOTP_ENC_KEY")); key != nil {
|
||||
totpHandler = auth.NewTOTPHandler(sqlDB, key, tm, rdb)
|
||||
} else if os.Getenv("USER_TOTP_ENC_KEY") != "" {
|
||||
log.Printf("USER_TOTP_ENC_KEY invalid (need 32 bytes or 64 hex chars); user TOTP disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codes ────────────────────────────────────────────────────────────────
|
||||
codesStore := codes.NewStore(sqlDB)
|
||||
codesSvc := codes.NewService(codesStore, rdb, 5, time.Hour)
|
||||
@@ -282,6 +295,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
v1.Post("/auth/logout", authHandler.Logout)
|
||||
if totpHandler != nil {
|
||||
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook: HMAC-authenticated, no JWT.
|
||||
@@ -299,6 +315,11 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
me.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
me.Get("/subscription", subAPI.GetSubscription)
|
||||
me.Post("/subscription/reset", subAPI.ResetSubscription)
|
||||
if totpHandler != nil {
|
||||
me.Post("/totp/setup", totpHandler.Setup)
|
||||
me.Post("/totp/verify", totpHandler.Verify)
|
||||
me.Post("/totp/disable", totpHandler.Disable)
|
||||
}
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
@@ -409,6 +430,21 @@ func getenvDefault(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// parseTOTPKey returns a 32-byte AES key from USER_TOTP_ENC_KEY, accepting either
|
||||
// 64 hex chars or a raw 32-byte string. Returns nil when unset/invalid.
|
||||
func parseTOTPKey(raw string) []byte {
|
||||
if len(raw) == 64 {
|
||||
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(raw) == 32 {
|
||||
return []byte(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intEnvDefault(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
Reference in New Issue
Block a user