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

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:37:01 +08:00
1 parent 18e2397272
commit 15f729ffea
17 files changed
+664 -37

No files matched your search

+42 -4
View File
@@ -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