Unverified Commit 15f729ff authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(auth): 邮箱验证码按账号累计错误次数封顶并通知

parent 18e23972
Loading
Loading
Loading
Loading
+36 −11
Changes for docs/openapi.yaml: 36 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -2209,7 +2209,9 @@ paths:
        purpose. An address with no account returns the SAME 202 with no code minted,
        and the per-recipient cooldown is kept on that path too, so probing reveals
        nothing (existence is learnt only at the sanctioned /auth/options oracle).
        Gated on local_auth_enabled.
        An account that spent its daily wrong-code budget (10 per 24h, across every
        code) also gets the same 202 and no mail until the window ends. Gated on
        local_auth_enabled.
      x-felis-face: [external]
      x-felis-tier: public
      security: []
@@ -2268,7 +2270,10 @@ paths:
        under the login purpose, and on success mints a host-only felis_session. An
        unknown address, a wrong or expired code, and an attempt-exhausted code all
        return the IDENTICAL 400 invalid_code, so the door is not an existence or
        lockout oracle. Staff are refused (403) — but only AFTER a valid code is
        lockout oracle. The 10th wrong code in 24h locks the door for that account
        until the window ends (the right code then also reads as invalid_code); the
        owner is told by mail once, and the lock is audited as auth.otp.locked.
        Staff are refused (403) — but only AFTER a valid code is
        redeemed, so only the account owner can ever reach that refusal.
      x-felis-face: [external]
      x-felis-tier: public
@@ -2324,7 +2329,8 @@ paths:
        staff address, opens an op_login request, and mails a one-time code under the
        op_login purpose, returning the request handle the browser polls. A non-staff
        or unknown address gets the SAME 202 with a random, non-persisted handle and no
        mail, so this never becomes a staff-enumeration oracle. Gated on
        mail, so this never becomes a staff-enumeration oracle. A staff account that
        spent its daily wrong-code budget gets the same neutral 202. Gated on
        local_auth_enabled.
      x-felis-face: [external]
      x-felis-tier: public
@@ -2415,7 +2421,7 @@ paths:
        Public, pre-session final leg: mints a host-only staff session only when BOTH
        factors have landed — the request is approved-and-live AND the mailed code
        verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code,
        lost race) collapses into one uniform 400 op_login_invalid, so a code-less
        an account past its daily wrong-code budget, lost race) collapses into one uniform 400 op_login_invalid, so a code-less
        caller learns nothing. Admin is re-asserted before the session is issued.
      x-felis-face: [external]
      x-felis-tier: public
@@ -3768,6 +3774,14 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: >-
            Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
            account spent its daily wrong-code budget (otp_account_locked, with
            Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          $ref: '#/components/responses/MailUndeliverable'

@@ -3779,8 +3793,10 @@ paths:
      description: >
        Consumes a previously delivered code for the authenticated principal. On
        success the user's email is written and email_verified is set true. Too many
        incorrect attempts lock the code (429); an unknown, expired, consumed, or
        mismatched code is a 400.
        incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h,
        counted across every code, lock the account's email-code door until the
        window ends (429 otp_account_locked with Retry-After). An unknown, expired,
        consumed, or mismatched code is a 400.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -3812,7 +3828,9 @@ paths:
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: Too many incorrect attempts; the code is locked.
          description: >-
            Too many incorrect attempts on this code (otp_locked), or the account's
            daily wrong-code budget is spent (otp_account_locked, with Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -4070,7 +4088,10 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Resend requested before the cooldown elapsed.
          description: >-
            Resend requested before the cooldown elapsed (otp_resend_cooldown), or the
            account's daily wrong-code budget is spent (otp_account_locked, with
            Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -4085,8 +4106,9 @@ paths:
      description: >
        Consumes the fresh migrate-purpose email code for the caller's initiated
        migration and advances it to confirmed with confirm_factor email_otp. Too many
        wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or
        mismatched code is a 400 invalid_code.
        wrong attempts lock the code (429 otp_locked), and 10 wrong codes in 24h lock
        the account's email-code door (429 otp_account_locked with Retry-After); an
        unknown, expired, consumed, or mismatched code is a 400 invalid_code.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4127,7 +4149,10 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: The code is locked after too many wrong attempts (otp_locked).
          description: >-
            The code is locked after too many wrong attempts (otp_locked), or the
            account's daily wrong-code budget is spent (otp_account_locked, with
            Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
+42 −4
Changes for internal/api/api_test.go: 42 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -74,6 +74,8 @@ type fakeRepo struct {
	// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
	// newest live (user, purpose) just as the PG query does.
	otps map[string]*fakeEmailOTP
	// otpBudget mirrors otp_failure_windows, keyed user|purpose.
	otpBudget map[string]*fakeOTPBudget
	// op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed
	// by id; the in-game approve/finish paths mutate status/consumed in place, and
	// tests plant rows directly to drive the status/finish/pending-list paths.
@@ -151,6 +153,37 @@ type fakeDataHold struct {
	expiresAt time.Time
}

// fakeOTPBudget mirrors an otp_failure_windows row.
type fakeOTPBudget struct {
	windowStart time.Time
	failures    int
}

// OTPLockedUntil mirrors PGRepo.OTPLockedUntil through the shared otpLockEnd rule.
func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now time.Time) (time.Time, error) {
	b := f.otpBudget[userID+"|"+purpose]
	if b == nil {
		return time.Time{}, nil
	}
	return otpLockEnd(b.windowStart, b.failures, now), nil
}

// chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget.
func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error {
	live.attempts++
	key := live.userID + "|" + live.purpose
	b := f.otpBudget[key]
	if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) {
		b = &fakeOTPBudget{windowStart: now}
		f.otpBudget[key] = b
	}
	b.failures++
	if b.failures == otpFailureBudget {
		return &OTPAccountLockedError{Until: b.windowStart.Add(otpFailureWindow), JustLocked: true}
	}
	return ErrOTPInvalid
}

// fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the
// digits), attempts caps brute force, consumed marks single-use, and createdAt
// orders the newest-live lookup.
@@ -227,6 +260,7 @@ func newFakeRepo() *fakeRepo {
		sessions:          map[string]*fakeSession{},
		settings:          map[string][]byte{},
		otps:              map[string]*fakeEmailOTP{},
		otpBudget:         map[string]*fakeOTPBudget{},
		opLogins:          map[string]*fakeOpLogin{},
		setupTokens:       map[string]fakeSetupToken{},
		blacklist:         map[string]bool{},
@@ -373,12 +407,14 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
	if !live.expiresAt.After(now) {
		return "", ErrOTPInvalid
	}
	if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() {
		return "", &OTPAccountLockedError{Until: until}
	}
	if live.attempts >= otpMaxAttempts {
		return "", ErrOTPLocked
	}
	if live.codeHash != codeHash {
		live.attempts++ // a typo costs an attempt but does not consume the code
		return "", ErrOTPInvalid
		return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
	}
	// A DIFFERENT verified holder of the same address → ErrEmailTaken, code left
	// live — mirrors PGRepo's guard + the users_verified_email_unique index.
@@ -1319,12 +1355,14 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code
	if live == nil || !live.expiresAt.After(now) {
		return ErrOTPInvalid
	}
	if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() {
		return &OTPAccountLockedError{Until: until}
	}
	if live.attempts >= otpMaxAttempts {
		return ErrOTPLocked
	}
	if live.codeHash != codeHash {
		live.attempts++ // a typo costs an attempt but does not consume the code
		return ErrOTPInvalid
		return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
	}
	live.consumed = true
	return nil
+20 −0
Changes for internal/api/errors.go: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ import (
	"fmt"
	"log"
	"net/http"
	"time"
)

// Sentinel errors the repository and cluster layers return so handlers can map
@@ -44,6 +45,11 @@ var (
	// can answer 429 (back off / request a new code) rather than inviting another
	// guess against a code that will never accept one.
	ErrOTPLocked = errors.New("email code locked: too many attempts")
	// ErrOTPAccountLocked means the (user, purpose) has spent its wrong-code budget
	// for the current window (otpFailureBudget): every code for that door is refused,
	// the right one included, until the window ends. The repo returns it as an
	// *OTPAccountLockedError carrying the end of the lock.
	ErrOTPAccountLocked = errors.New("email codes locked for this account: too many wrong codes")
	// ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be
	// finished: there is no live (unconsumed, unexpired) challenge for the caller and
	// purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the
@@ -102,6 +108,20 @@ func (e *MaintenanceBusyError) Error() string {

func (e *MaintenanceBusyError) Is(target error) bool { return target == ErrMaintenanceInProgress }

// OTPAccountLockedError is ErrOTPAccountLocked with its detail. JustLocked is set
// only on the wrong guess that spent the budget, so the handler notifies and
// audits the lock exactly once.
type OTPAccountLockedError struct {
	Until      time.Time
	JustLocked bool
}

func (e *OTPAccountLockedError) Error() string {
	return ErrOTPAccountLocked.Error() + " until " + e.Until.UTC().Format(time.RFC3339)
}

func (e *OTPAccountLockedError) Is(target error) bool { return target == ErrOTPAccountLocked }

// apiError is a handler-level error carrying an HTTP status and a stable,
// machine-readable code. The error envelope matches the platform convention:
//
+12 −0
Changes for internal/api/handlers_account_migrate.go: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -206,6 +206,13 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques
	}
	// Per-recipient cooldown, namespaced apart from the other OTP doors so they never
	// perturb each other's throttle.
	if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil {
		writeError(w, r, err)
		return
	} else if !until.IsZero() {
		writeOTPAccountLocked(w, r, until, a.now())
		return
	}
	emailKey := "migrate:confirm:" + strings.ToLower(p.Email)
	lim := a.otpLimiter()
	emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
@@ -267,7 +274,12 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
	if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
		return
	}
	var lock *OTPAccountLockedError
	switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()); {
	case errors.As(err, &lock):
		a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate)
		writeOTPAccountLocked(w, r, lock.Until, a.now())
		return
	case errors.Is(err, ErrOTPLocked):
		writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
			"too many incorrect attempts; request a new code"))
+16 −1
Changes for internal/api/handlers_auth_email.go: 16 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -126,6 +126,19 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
		return
	}

	// A locked door (wrong-code budget spent) gets the same neutral 202 and no
	// mail: the owner was told by the lock notice, and a distinct answer here
	// would tell a prober the address has an account.
	switch until, err := a.Repo.OTPLockedUntil(r.Context(), u.ID, otpPurposeLogin, a.now()); {
	case err != nil:
		writeError(w, r, err)
		return
	case !until.IsZero():
		committed = true
		writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
		return
	}

	code, err := newEmailOTP()
	if err != nil {
		writeError(w, r, err)
@@ -220,7 +233,9 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
	// verified; touching the row here would let a stale OTP-snapshot address overwrite
	// the live one and could 500 a correct code on a spurious collision.
	switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); {
	case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
	case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked), errors.Is(err, ErrOTPAccountLocked):
		// The account lock answers the same way; its owner hears about it by mail.
		a.noteOTPLock(r, err, u.ID, otpPurposeLogin)
		// Both a wrong/expired code and an attempt-exhausted one return the SAME 400
		// invalid_code, byte-identical to the unknown-account branch above. Surfacing
		// otp_locked as a distinct 429 (as the authenticated onboarding door does) would
Loading