mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(service): verify-totp reports a store fault as 500, uncounted (OC-0377)
challengeSecret folded a GetUserByID error into the same 401 "invalid or expired two-factor challenge" an expired challenge earns, and the attempt had already been charged to the per-user totp_fail cap. Split the outcome: a store error logs and returns the new service.ErrTOTPUnavailable (ErrInternal, "two-factor verification temporarily unavailable"); an unknown user or a missing secret still answers 401. The limiter reservation moves after the store read — the rule authenticate already applies — and still precedes the code compare, so the check-then-act it closes stays closed. Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/ user lookup fails -> 500, challenge kept, attempt not counted` — after the fault ten wrong codes still answer 401 (the tenth would be 429 had the fault counted), then the eleventh is refused. `per-user failure cap spans challenges` unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo
This commit is contained in:
@@ -777,16 +777,31 @@ func TestAuthCharacterization_VerifyTOTPFailurePaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
const challengeGone = "invalid or expired two-factor challenge"
|
const challengeGone = "invalid or expired two-factor challenge"
|
||||||
|
|
||||||
t.Run("user lookup fails -> 401", func(t *testing.T) {
|
t.Run("user lookup fails -> 500, challenge kept, attempt not counted", func(t *testing.T) {
|
||||||
database, router, _, secret := setup(t)
|
database, router, _, secret := setup(t)
|
||||||
pt := loginPartial(t, router, "two", "correctPass1", "", "")
|
pt := loginPartial(t, router, "two", "correctPass1", "", "")
|
||||||
hideTable(t, database, "users")
|
hideTable(t, database, "users")
|
||||||
// known: totpChallengeSecret folds a database error into the same 401
|
// OC-0377 (fixed in B3-9): a store fault while loading the challenged
|
||||||
// an expired challenge gets, so a DB fault during the second factor
|
// user is an outage, not a bad challenge — 5xx, the challenge stays
|
||||||
// reads as a bad code and burns the caller's attempt budget (ledger
|
// live, and the attempt is not charged to the per-user totp_fail cap.
|
||||||
// OC-0377). The plan's rule is 5xx for a non-sentinel error; pinned
|
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusInternalServerError, "INTERNAL_ERROR", "two-factor verification temporarily unavailable")
|
||||||
// as-is for B3-2, fixed in B3-9.
|
if _, err := database.ExecContext(context.Background(), `ALTER TABLE users_gone RENAME TO users`); err != nil {
|
||||||
wantErr(t, verify(t, router, pt, totpCode(t, secret), ""), http.StatusUnauthorized, "UNAUTHORIZED", challengeGone)
|
t.Fatalf("restore users: %v", err)
|
||||||
|
}
|
||||||
|
// The cap is 10 per user: ten wrong codes must all still answer 401
|
||||||
|
// "invalid two-factor code" — had the faulted attempt counted, the
|
||||||
|
// tenth would be the 429. The first five ride the surviving challenge
|
||||||
|
// (which proves it was kept), the next five a fresh one.
|
||||||
|
attempt := 0
|
||||||
|
for _, token := range []string{pt, loginPartial(t, router, "two", "correctPass1", "", "")} {
|
||||||
|
for range 5 {
|
||||||
|
attempt++
|
||||||
|
wantErr(t, verify(t, router, token, wrongTOTPCode(t, secret), fmt.Sprintf("203.0.113.%d", attempt)), http.StatusUnauthorized, "UNAUTHORIZED", "invalid two-factor code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Negative control: the counter is live — the eleventh attempt is refused.
|
||||||
|
pt = loginPartial(t, router, "two", "correctPass1", "", "")
|
||||||
|
wantErr(t, verify(t, router, pt, totpCode(t, secret), "203.0.113.99"), http.StatusTooManyRequests, "RATE_LIMITED", "too many failed attempts, try again later")
|
||||||
})
|
})
|
||||||
t.Run("secret removed after the challenge was issued -> 401", func(t *testing.T) {
|
t.Run("secret removed after the challenge was issued -> 401", func(t *testing.T) {
|
||||||
database, router, uid, secret := setup(t)
|
database, router, uid, secret := setup(t)
|
||||||
|
|||||||
+20
-7
@@ -216,7 +216,10 @@ var (
|
|||||||
// Second factor.
|
// Second factor.
|
||||||
ErrTOTPChallengeInvalid = &authError{ErrUnauthorized, "invalid or expired two-factor challenge"}
|
ErrTOTPChallengeInvalid = &authError{ErrUnauthorized, "invalid or expired two-factor challenge"}
|
||||||
ErrTOTPSecretUnreadable = &authError{ErrInternal, "failed to verify two-factor code"}
|
ErrTOTPSecretUnreadable = &authError{ErrInternal, "failed to verify two-factor code"}
|
||||||
ErrTOTPCodeInvalid = &authError{ErrUnauthorized, "invalid two-factor code"}
|
// ErrTOTPUnavailable is a store fault while loading the challenged user
|
||||||
|
// (OC-0377): an outage, not a bad challenge, so no attempt is charged.
|
||||||
|
ErrTOTPUnavailable = &authError{ErrInternal, "two-factor verification temporarily unavailable"}
|
||||||
|
ErrTOTPCodeInvalid = &authError{ErrUnauthorized, "invalid two-factor code"}
|
||||||
// ErrTOTPAlreadyEnabled is written by the transport as 409
|
// ErrTOTPAlreadyEnabled is written by the transport as 409
|
||||||
// TOTP_ALREADY_ENABLED, not the generic CONFLICT code.
|
// TOTP_ALREADY_ENABLED, not the generic CONFLICT code.
|
||||||
ErrTOTPAlreadyEnabled = &authError{ErrConflict, "disable 2FA before re-enabling"}
|
ErrTOTPAlreadyEnabled = &authError{ErrConflict, "disable 2FA before re-enabling"}
|
||||||
@@ -472,6 +475,11 @@ func (s *AuthService) VerifyTOTP(ctx context.Context, partialToken, code string)
|
|||||||
return nil, ErrTOTPChallengeInvalid
|
return nil, ErrTOTPChallengeInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, secret, err := s.challengeSecret(ctx, challenge.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
totpRateLimitKey := auth.Key("totp_fail", challenge.UserID)
|
totpRateLimitKey := auth.Key("totp_fail", challenge.UserID)
|
||||||
// Atomically record this attempt and reject once the per-user failure cap
|
// Atomically record this attempt and reject once the per-user failure cap
|
||||||
// is reached. Recording up-front — rather than a read-only Check now and
|
// is reached. Recording up-front — rather than a read-only Check now and
|
||||||
@@ -485,15 +493,13 @@ func (s *AuthService) VerifyTOTP(ctx context.Context, partialToken, code string)
|
|||||||
// multiplier exists for shared-NAT per-IP limits; scaling a per-user
|
// multiplier exists for shared-NAT per-IP limits; scaling a per-user
|
||||||
// threshold with it would hand a distributed attacker more guesses.
|
// threshold with it would hand a distributed attacker more guesses.
|
||||||
// Mirrors loginUserFailureThreshold staying unscaled in authenticate.
|
// Mirrors loginUserFailureThreshold staying unscaled in authenticate.
|
||||||
|
// The reservation sits after the store read above, as in authenticate,
|
||||||
|
// so an outage does not consume attempts (OC-0377); it still precedes
|
||||||
|
// the code compare, the check-then-act the up-front record closes.
|
||||||
if !s.limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) {
|
if !s.limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) {
|
||||||
return nil, ErrTooManyAttempts
|
return nil, ErrTooManyAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
user, secret, err := s.challengeSecret(ctx, challenge.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(code), time.Now().UTC(), user.ID, s.usedCodes) {
|
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(code), time.Now().UTC(), user.ID, s.usedCodes) {
|
||||||
// The attempt was already recorded atomically up-front via
|
// The attempt was already recorded atomically up-front via
|
||||||
// limiter.Allow; only the per-partial-token counter is advanced here.
|
// limiter.Allow; only the per-partial-token counter is advanced here.
|
||||||
@@ -522,7 +528,14 @@ func (s *AuthService) VerifyTOTP(ctx context.Context, partialToken, code string)
|
|||||||
// returns their decrypted TOTP secret.
|
// returns their decrypted TOTP secret.
|
||||||
func (s *AuthService) challengeSecret(ctx context.Context, challengeUserID int64) (*db.User, string, error) {
|
func (s *AuthService) challengeSecret(ctx context.Context, challengeUserID int64) (*db.User, string, error) {
|
||||||
user, err := s.st.GetUserByID(ctx, challengeUserID)
|
user, err := s.st.GetUserByID(ctx, challengeUserID)
|
||||||
if err != nil || user == nil || user.TOTPSecret == nil {
|
if err != nil {
|
||||||
|
// GetUserByID answers (nil, nil) for an unknown user, so a non-nil
|
||||||
|
// error is a store fault: report the outage, not a bad challenge
|
||||||
|
// (OC-0377). VerifyTOTP records no attempt for it.
|
||||||
|
slog.Error("verify-totp: GetUserByID failed", "err", err, "user_id", challengeUserID)
|
||||||
|
return nil, "", ErrTOTPUnavailable
|
||||||
|
}
|
||||||
|
if user == nil || user.TOTPSecret == nil {
|
||||||
return nil, "", ErrTOTPChallengeInvalid
|
return nil, "", ErrTOTPChallengeInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user