diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 9b38cef..4d3314e 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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 == "" { diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index 3d1a636..5d2bad7 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -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. diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index b5ed649..ec06ccb 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -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 diff --git a/server/internal/auth/store.go b/server/internal/auth/store.go index b155dd8..daa558a 100644 --- a/server/internal/auth/store.go +++ b/server/internal/auth/store.go @@ -13,12 +13,13 @@ import ( // User is the subset of the users row the auth module needs. type User struct { - ID int64 - UUID string - Email string - PwHash string - DpUUID string - Status string // "active" | "banned" + ID int64 + UUID string + Email string + PwHash string + DpUUID string + Status string // "active" | "banned" + TOTPEnabled bool // two-factor enabled → login requires a second TOTP step } // Sentinel store errors. Service maps these to API errors. @@ -112,8 +113,8 @@ func (s *SQLStore) CreateUserWithTrial(ctx context.Context, email, pwHash string func (s *SQLStore) GetUserByEmail(ctx context.Context, email string) (*User, error) { var u User err := s.db.QueryRowContext(ctx, - `SELECT id, uuid, email, pw_hash, dp_uuid, status FROM users WHERE email = ?`, - email).Scan(&u.ID, &u.UUID, &u.Email, &u.PwHash, &u.DpUUID, &u.Status) + `SELECT id, uuid, email, pw_hash, dp_uuid, status, totp_enabled FROM users WHERE email = ?`, + email).Scan(&u.ID, &u.UUID, &u.Email, &u.PwHash, &u.DpUUID, &u.Status, &u.TOTPEnabled) if err == sql.ErrNoRows { return nil, ErrNotFound } diff --git a/server/internal/auth/totp_user.go b/server/internal/auth/totp_user.go new file mode 100644 index 0000000..74cb8c1 --- /dev/null +++ b/server/internal/auth/totp_user.go @@ -0,0 +1,251 @@ +package auth + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/totp" +) + +const totpIssuer = "Pangolin" + +// errTOTPInvalid mirrors the web user-center's expected error code. +var errTOTPInvalid = apierr.New("totp_invalid", "动态码不正确,请重试", "Invalid code, try again") + +// TOTPHandler serves the user two-factor endpoints (/v1/me/totp/*) and the +// second login step (/v1/auth/login/totp). Secrets are stored AES-256-GCM +// encrypted under encKey; pending login tokens live in Redis. +type TOTPHandler struct { + db *sql.DB + encKey []byte // 32 bytes (AES-256) + tokens *TokenManager + rdb *redis.Client + now func() time.Time +} + +// NewTOTPHandler wires the handler. encKey must be 32 bytes. +func NewTOTPHandler(db *sql.DB, encKey []byte, tokens *TokenManager, rdb *redis.Client) *TOTPHandler { + return &TOTPHandler{db: db, encKey: encKey, tokens: tokens, rdb: rdb, now: time.Now} +} + +type totpCodeRequest struct { + Code string `json:"code"` +} + +// Setup handles POST /v1/me/totp/setup — generates and stores a secret (not yet +// enabled) and returns it plus an otpauth:// URI for QR rendering. +func (h *TOTPHandler) Setup(w http.ResponseWriter, r *http.Request) { + uid, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + var email string + if err := h.db.QueryRowContext(r.Context(), + `SELECT email FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&email); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + secret, err := totp.GenerateSecret() + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + enc, err := sealAES(h.encKey, secret) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if _, err := h.db.ExecContext(r.Context(), + `UPDATE users SET totp_secret_enc = ?, totp_enabled = FALSE WHERE id = ?`, enc, uid); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]string{ + "secret": secret, + "otpauth_uri": totp.ProvisioningURI(secret, email, totpIssuer), + }) +} + +// Verify handles POST /v1/me/totp/verify {code} — enables TOTP after confirming +// the user can produce a valid code for the secret created by Setup. +func (h *TOTPHandler) Verify(w http.ResponseWriter, r *http.Request) { + uid, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + var req totpCodeRequest + if !decodeJSON(w, r, &req) { + return + } + secret, _, err := h.loadSecret(r.Context(), uid) + if err != nil || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) { + apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid) + return + } + if _, err := h.db.ExecContext(r.Context(), + `UPDATE users SET totp_enabled = TRUE WHERE id = ?`, uid); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Disable handles POST /v1/me/totp/disable {code} — turns off TOTP after a valid +// code and clears the stored secret. +func (h *TOTPHandler) Disable(w http.ResponseWriter, r *http.Request) { + uid, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + var req totpCodeRequest + if !decodeJSON(w, r, &req) { + return + } + secret, _, err := h.loadSecret(r.Context(), uid) + if err != nil || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) { + apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid) + return + } + if _, err := h.db.ExecContext(r.Context(), + `UPDATE users SET totp_secret_enc = NULL, totp_enabled = FALSE WHERE id = ?`, uid); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type loginTOTPRequest struct { + PendingToken string `json:"pending_token"` + Code string `json:"code"` +} + +// LoginTOTP handles POST /v1/auth/login/totp — second login step. Consumes the +// one-time pending token from Login, validates the TOTP code, then issues tokens. +func (h *TOTPHandler) LoginTOTP(w http.ResponseWriter, r *http.Request) { + var req loginTOTPRequest + if !decodeJSON(w, r, &req) { + return + } + if req.PendingToken == "" { + apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid) + return + } + // One-time consume of the pending token. + uidStr, err := h.rdb.GetDel(r.Context(), totpPendingPrefix+req.PendingToken).Result() + if errors.Is(err, redis.Nil) { + apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + uid, err := strconv.ParseInt(uidStr, 10, 64) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + secret, enabled, err := h.loadSecret(r.Context(), uid) + if err != nil || !enabled || secret == "" || !totp.Validate(secret, req.Code, h.now().UTC(), 1) { + apierr.WriteJSON(w, http.StatusUnauthorized, errTOTPInvalid) + return + } + + var uuid string + if err := h.db.QueryRowContext(r.Context(), + `SELECT uuid FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&uuid); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + pair, err := h.tokens.Issue(r.Context(), uid, uuid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + writeTokenPair(w, pair) +} + +// loadSecret returns the user's decrypted TOTP secret and enabled flag. A NULL +// secret yields ("", enabled, nil). +func (h *TOTPHandler) loadSecret(ctx context.Context, uid int64) (string, bool, error) { + var enc []byte + var enabled bool + if err := h.db.QueryRowContext(ctx, + `SELECT totp_secret_enc, totp_enabled FROM users WHERE id = ?`, uid).Scan(&enc, &enabled); err != nil { + return "", false, err + } + if len(enc) == 0 { + return "", enabled, nil + } + secret, err := openAES(h.encKey, enc) + if err != nil { + return "", enabled, err + } + return secret, enabled, nil +} + +// ── crypto / token helpers ─────────────────────────────────────────────────── + +// sealAES encrypts plaintext with AES-256-GCM under key (32 bytes); the nonce is +// prepended to the ciphertext. +func sealAES(key []byte, plaintext string) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil +} + +// openAES reverses sealAES. +func openAES(key, blob []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(blob) < gcm.NonceSize() { + return "", errors.New("auth: totp ciphertext too short") + } + nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +// newToken16 returns a 32-char hex random token (used for TOTP pending logins). +func newToken16() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/server/internal/auth/totp_user_test.go b/server/internal/auth/totp_user_test.go new file mode 100644 index 0000000..a7dedc9 --- /dev/null +++ b/server/internal/auth/totp_user_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "crypto/rand" + "testing" +) + +func TestSealOpenAES_Roundtrip(t *testing.T) { + var key [32]byte + if _, err := rand.Read(key[:]); err != nil { + t.Fatal(err) + } + const secret = "JBSWY3DPEHPK3PXP" // sample base32 TOTP secret + + blob, err := sealAES(key[:], secret) + if err != nil { + t.Fatalf("sealAES: %v", err) + } + if string(blob) == secret { + t.Fatal("ciphertext equals plaintext — not encrypted") + } + + got, err := openAES(key[:], blob) + if err != nil { + t.Fatalf("openAES: %v", err) + } + if got != secret { + t.Fatalf("roundtrip mismatch: got %q want %q", got, secret) + } +} + +func TestOpenAES_TamperAndWrongKey(t *testing.T) { + var key, other [32]byte + _, _ = rand.Read(key[:]) + _, _ = rand.Read(other[:]) + + blob, err := sealAES(key[:], "secret-value") + if err != nil { + t.Fatal(err) + } + + // Wrong key must fail (GCM auth). + if _, err := openAES(other[:], blob); err == nil { + t.Error("openAES with wrong key should fail") + } + + // Tampered ciphertext must fail. + bad := append([]byte(nil), blob...) + bad[len(bad)-1] ^= 0xff + if _, err := openAES(key[:], bad); err == nil { + t.Error("openAES with tampered ciphertext should fail") + } + + // Too-short blob must fail, not panic. + if _, err := openAES(key[:], []byte{1, 2, 3}); err == nil { + t.Error("openAES with short blob should fail") + } +} + +func TestNewToken16_UniqueHex(t *testing.T) { + seen := make(map[string]bool, 100) + for i := 0; i < 100; i++ { + tok, err := newToken16() + if err != nil { + t.Fatal(err) + } + if len(tok) != 32 { + t.Fatalf("token length = %d, want 32 hex chars", len(tok)) + } + if seen[tok] { + t.Fatalf("duplicate token: %s", tok) + } + seen[tok] = true + } +}