feat(auth): resend code for password reset - #440
Merged
Conversation
Adds a "Resend code" button to the reset modal with the policy: 60 s between codes, one send plus five resends an hour, then an hour's wait. Backend: a new existence-blind in-process cooldown limiter (AUTH_FORGOT_COOLDOWN, 1/60 s, 429 + Retry-After) checked before the hourly per-address budget so a refused click never spends one of the hour's six; the hourly limiter's default now reads PASSWORD_RESET_MAX_REQUESTS_PER_HOUR (6). The 200 body carries resend_after_seconds (config-shaped, still enumeration-blind). The durable backstops are shortened by a 15 s skew allowance (cooldown 45 s, hourly window 1h-15s) because the limiter stamps arrival while the row is stamped after the send returns -- equal windows would swallow the first click after every countdown. Daily cap 5 -> 12 so "wait an hour" is true. Frontend: countdown label from the server's number (and from a 429's Retry-After, now lifted by AuthAPI.request); the address stage 1 submitted is locked in the field and used for both resend and the final submit. Spec amended; the four exact-match cache-bust guards bumped alongside. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tkaai4sur2oxnXd82EtrFm
- Cooldown is check()ed before the hourly budget and record()ed only on an accepted request: an hourly 429's Retry-After had re-armed the minute on the way out, so the click it lined up met a contradictory cooldown 429. - The send runs under a hard 10 s asyncio.wait_for deadline (httpx's timeout is per phase); skew 15 -> 20 s, durable cooldown 45 -> 40 s, sized against that bound plus a cold Neon first read. - _check_forgot_policy_coherence() WARNs at startup when an AUTH_FORGOT_* override sets a visible gate looser than its durable backstop. - Frontend: a stage-1 429 (resubmit inside the minute) still opens the code step -- the mailed code is valid; a generation counter drops responses that land after "Back to sign in" so they cannot lock the login email field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tkaai4sur2oxnXd82EtrFm
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
CodeQL's py/clear-text-logging-sensitive-data classifies a source by identifier name: the new startup lines print these counts and durations, so every line they flowed into was reported as logging a password (alerts #1287-#1292 on the merge ref). They are a TTL, an attempt cap, a cooldown and two request caps; the name now says so. Both store twins, tests and the spec follow. No behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tkaai4sur2oxnXd82EtrFm
Addresses the 15 findings from the PR #440 review. - Stamp password_reset_requests.created_at with the request's arrival (both store twins take requested_at); drop the send deadline, which could cancel a send Brevo had already accepted and leave a mailed code with no row. - forgot-password gate order: 503 first; daily/hourly/cooldown checked longest window first; client budget charged only after the address gates; caps allow()ed and cooldown record()ed only on accept. A refusal or a 503 spends nothing, and a double refusal carries the longer wait. - Visible daily cap (AUTH_FORGOT_DAILY, 12/day) with a skewed durable twin. - Global send budget 10/h -> 30/h plus 240/day (AUTH_FORGOT_GLOBAL_DAILY). - Every forgot-password 429 carries X-RateLimit-Scope (address|client), exposed via CORS; the modal opens the code step only on address scope. - One store read serves the durable cooldown, hourly and daily checks. - Countdown label rounds to the nearest unit and knows hours. - Email-change form gets the generation guard; Cancel disabled in flight. - resetStage folded into resetEmail; _hourly_window_start folded into _window_start; cache-buster app.js?v=127 (five pins bumped). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLE4dnLivYESRftd22fS4b
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLE4dnLivYESRftd22fS4b
Collaborator
Author
|
Round 2 ( Correctness
Reuse / efficiency / cleanup
Not touched (pre-existing, outside this PR): |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLE4dnLivYESRftd22fS4b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Resend code button to the password-reset modal.
Policy: 60 s between codes; one send + five resends per hour per address; then wait an hour; twelve a day. All three limits are visible (429 +
Retry-After, counted down on the button), not silent skips.Round 2 (
f045576c) addresses the 15 findings of the 2026-09-03 review — see the comment below.Backend (
api/auth.py,users.py)AUTH_FORGOT_COOLDOWN(1 / 60 s) andAUTH_FORGOT_DAILY(12 / day) limiters. Gate order: the 503 first; daily/hourly/cooldowncheck()ed longest window first (a double refusal carries the longer wait); the client budget charged only after the address gates; capsallow()ed and cooldownrecord()ed only on accept — a refused click or a 503 spends nothing. Every 429 carriesX-RateLimit-Scope: address|client(CORS-exposed). Hourly default readsRESET_CODE_MAX_REQUESTS_PER_HOUR(6); global send budget 30/h + 240/day.resend_after_seconds(config-shaped; the enumeration-blind test still compares bodies byte-for-byte).requested_at, both store twins), so store or send latency can never push its window behind the gate's — no send deadline. One store read serves all three durable checks. Daily cap 5 → 12 so "wait an hour" is true. A startupWARNINGnames anyAUTH_FORGOT_*override that sets a gate looser than its backstop.Frontend (
app.js,app.html,styles.css)Retry-Afteron a 429 (now surfaced byAuthAPI.request). Seconds under a minute, minutes under an hour, hours above — nearest unit.Tests: 18 new backend cases (existence-blind cooldown 429, gate ordering in every direction on an injected clock, arrival stamping, 503 spends nothing, visible daily cap, scope header, global daily budget, single store read, policy-coherence warning, defaults pin), a store-twin case, 9 source-shape guards, node-executed countdown tests incl. a monotonicity sweep. Cache-bust guards bumped to
app.js?v=127. Full suite green (3997 passed); modal flow verified live in Playwright.Rename:
PASSWORD_RESET_*→RESET_CODE_*(five constants, both store twins, no behaviour change) — CodeQL classifies a clear-text-logging source by identifier name, and the new startup lines print these counts.Docs:
docs/source/lab/accounts.rstresend bullet updated in its own commit (2b14bd18,sphinx -n -Eclean); spec amended in-tree;CLAUDE.mddocuments theAUTH_FORGOT_*knobs.Related, not fixed here: the first prod reset attempt (2026-09-02 14:49 UTC) failed at the provider — Brevo answered
403 "SMTP account is not yet activated". That is an activation step on the Brevo account, not code.🤖 Generated with Claude Code
https://claude.ai/code/session_01Tkaai4sur2oxnXd82EtrFm