Skip to content

fix(auth): per-purpose throttles, per-address sign-in backoff, constant-work login (#688) - #874

Merged
alex-dembele merged 7 commits into
masterfrom
688-auth-throttle
Oct 2, 2026
Merged

alex-dembele merged 7 commits into
masterfrom
688-auth-throttle

Conversation

@alex-dembele

Copy link
Copy Markdown
Member

Closes #688

Stacked on #689 (PR #871): it reuses #689's authmfa.AttemptStore for the per-address lock. Base is #689's branch; once #871 merges, GitHub retargets this PR to master. Spec: docs/688_AUTH_THROTTLE.md.

What changes

  • One throttle bucket per purpose (per IP): login, sign-up, reset, the password-strength meter and re-auth each get their own key. Typing on the sign-up screen no longer locks anyone out of signing in.
  • Readable 429s: Retry-After header plus code/retry_after in the body. Sign-in and sign-up say how long to wait, in FR and EN.
  • Per-address backoff: 10 failed sign-ins on one address, from any source → 15-min lock. The lock is bounded, and a password reset ends it. Each failure is audited with the address hashed.
  • Equal work: unknown and disabled addresses cost one Argon2id comparison, like a wrong password.
  • Case-insensitive lookup: GetByEmail matches LOWER(email), and legacy duplicates resolve deterministically.

Verification

Backend gate (cd backend && go build ./... && go vet ./... && go test ./... -race): BUILD_OK · VET_OK · TEST_OK, 80 packages ok, 0 FAIL.

Frontend gate: npx tsc -b --noEmit exit 0 · npx vitest run → 102 files, 950 tests passed · npm run build → built in 4.97s · eslint src/features/auth → 2 errors, both already on master, in MFAEnrollmentBanner.tsx:61 and MFAPostAhaPrompt.tsx:64, neither touched here. The 6 new tests in authThrottle.test.tsx fail with the old AuthScreen.tsx and pass with the new one.

Named tests: TestLogin_UnknownAddressCostsAHashComparison, TestLogin_EmailCaseInsensitive, TestAuthRateLimit_PasswordCheckDoesNotConsumeLoginBudget, TestLogin_PerAccountBackoff, plus TestLogin_Success/_NotFound/_Unauthorized, TestConfirmPasswordReset_ClearsLoginLock, TestGetByEmail_CaseInsensitive, TestGetByEmail_LegacyMixedCaseRows, TestLoginThrottle_LockedAddressAnswers429AndEveryFailureIsAudited, TestRateLimit_RefusalCarriesRetryAfterAndCode.

Live (throwaway Postgres 18 + Redis, branch server, 2026-10-02):

Reproduction 1, run again:

121 × POST /auth/password/check → 120 × 200, then 429
122nd: HTTP 429 · Retry-After: 282 · {"code":"RATE_LIMITED","error":true,"msg":"Rate limit exceeded","retry_after":282}
POST /auth/login (valid)    → 200
POST /auth/register (new)   → 201

Reproduction 2, run again (20 attempts each, wrong password, one source IP per request):

unknown  n=20 codes=['401'] median=44.6 ms  min=37.5  max=54.5
existing n=20 codes=['401'] median=46.6 ms  min=37.7  max=53.3
difference of medians = 2.0 ms (criterion: < 10 ms)

Reproduction 4, run again (account registered as awa.diallo@e2e.test):

'Awa.Diallo@E2E.test'   → HTTP 200 user.email= awa.diallo@e2e.test
'AWA.DIALLO@E2E.TEST'   → HTTP 200 user.email= awa.diallo@e2e.test
' awa.diallo@e2e.test ' → HTTP 200 user.email= awa.diallo@e2e.test

Per-address backoff (criterion 3):

10 wrong passwords, alternating 127.0.0.70 / .71 → 10 × 401
11th, RIGHT password, from 127.0.0.80 → 429 · Retry-After: 900 · {"code":"LOGIN_LOCKED",...,"retry_after":900}
redis: mfa-attempts:login:lock:ed0afddf… ttl=900s
audit: 5 × .50 / 5 × .51 "authentication failed email_sha256=ed0afddf…", 1 × "login_locked email_sha256=ed0afddf…"  (= sha256("awa.diallo@e2e.test"); the address never appears in clear)
forgot → 200 · mailed link → reset → 200 · lock keys after reset: 0 · login with new password → 200

Browser (Vite against the branch server): sign-in on the locked address shows "Trop de tentatives. Réessayez dans 15 minutes."; sign-up in EN with the IP's sign-up budget used up shows "Too many attempts. Try again in 7 minutes."

Honest remainders

)

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
… throttles (#688)

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…efusal (#688)

GetByEmail now matches LOWER(email) and resolves a legacy case-variant
duplicate to the exact spelling, then the oldest row. Login normalises
the address before the lookup.

An unknown address is compared against a hash made at start-up with the
live hasher, and a disabled account against its own hash, so neither
answers faster than a wrong password.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…urce (#688)

Failed sign-ins are counted per normalised address, hashed, in the store
#689 added. The tenth failure locks the address for fifteen minutes; the
lock is not extended by refused attempts and a password reset ends it.
A locked address answers 429 LOGIN_LOCKED with Retry-After, and every
failed sign-in is audited with the address hashed.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…gn-up (#688)

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
#688)

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Base automatically changed from 689-securityauth-mfa-challenge-codes-can-be-guessed-without-limit-no-attempt-counter-no-rate-limit to master October 2, 2026 11:36
@alex-dembele alex-dembele reopened this Oct 2, 2026
@alex-dembele
alex-dembele merged commit 268f505 into master Oct 2, 2026
13 of 26 checks passed
@alex-dembele
alex-dembele deleted the 688-auth-throttle branch October 2, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(auth): one rate-limit bucket for all auth routes locks users out, login timing reveals registered addresses, no per-account throttle

1 participant