Skip to content

feat(auth): resend code for password reset - #440

Merged
FlyM1ss merged 6 commits into
mainfrom
feat/password-reset-resend
Sep 3, 2026
Merged

feat(auth): resend code for password reset#440
FlyM1ss merged 6 commits into
mainfrom
feat/password-reset-resend

Conversation

@FlyM1ss

@FlyM1ss FlyM1ss commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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)

  • New existence-blind AUTH_FORGOT_COOLDOWN (1 / 60 s) and AUTH_FORGOT_DAILY (12 / day) limiters. Gate order: the 503 first; daily/hourly/cooldown check()ed longest window first (a double refusal carries the longer wait); the client budget charged only after the address gates; caps allow()ed and cooldown record()ed only on accept — a refused click or a 503 spends nothing. Every 429 carries X-RateLimit-Scope: address|client (CORS-exposed). Hourly default reads RESET_CODE_MAX_REQUESTS_PER_HOUR (6); global send budget 30/h + 240/day.
  • 200 body carries resend_after_seconds (config-shaped; the enumeration-blind test still compares bodies byte-for-byte).
  • Durable backstops sit inside the visible gates by a 20 s skew (cooldown 40 s, hourly 1h−20s, daily 1d−20s). The row is stamped with the request's arrival time (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 startup WARNING names any AUTH_FORGOT_* override that sets a gate looser than its backstop.

Frontend (app.js, app.html, styles.css)

  • Countdown from the server's number, and from Retry-After on a 429 (now surfaced by AuthAPI.request). Seconds under a minute, minutes under an hour, hours above — nearest unit.
  • The address stage 1 submitted is locked in the field and used for resend and the final submit (closes the "editable stage-2 email" post-merge finding).
  • A stage-1 resubmit refused on the address (reload, second tab, deep link) still opens the code step — the code already mailed is valid. A client-scoped 429 stays a plain error.
  • A generation counter drops responses that land after "Back to sign in", so a slow reset request cannot lock the login email field. The email-change form gets the same guard, with Cancel disabled while a submit is in flight.

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.rst resend bullet updated in its own commit (2b14bd18, sphinx -n -E clean); spec amended in-tree; CLAUDE.md documents the AUTH_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

FlyM1ss and others added 2 commits September 2, 2026 23:32
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
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agentic-trading-lab Ready Ready Preview Sep 3, 2026 8:23am UTC

Comment thread dashboard/backend/api/auth.py Fixed
Comment thread dashboard/backend/api/auth.py Fixed
Comment thread dashboard/backend/api/auth.py Fixed
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
FlyM1ss and others added 2 commits September 3, 2026 16:16
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
@FlyM1ss

FlyM1ss commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 (f045576c + 2b14bd18): the 15 findings from today's review, all addressed.

Correctness

  • Global send budget 10/h could be exhausted by two honest users → 30/h + 240/day (AUTH_FORGOT_GLOBAL_DAILY).
  • asyncio.wait_for could cancel a send Brevo had already accepted → deadline removed; the row is stamped with the request's arrival (requested_at, both twins), so send/store latency no longer matters to the windows.
  • 20 s skew did not cover the un-deadlined store hops → moot under arrival stamping; the residual skew covers clock disagreement only.
  • Any stage-1 429 opened the code step → 429s carry X-RateLimit-Scope; the step opens only on address.
  • Cooldown checked before hourly (Retry-After 30 then 3239) → gates checked longest window first.
  • Hourly allow() recorded before the 503 → 503 decided first; nothing charged on a refusal or an outage.
  • Daily 12 had no visible gate → AUTH_FORGOT_DAILY limiter, durable twin skewed like the hourly.

Reuse / efficiency / cleanup

  • Deadline at one of three send sites + duplicated 10.0 → gone with the deadline.
  • initEmailChangeForm lacked the generation guard → added; Cancel disabled while a submit is in flight.
  • Cooldown checked after the client budget was charged → client budget charged after the address gates.
  • Cooldown/hourly/daily were three store reads → one read over the skewed day.
  • Countdown label "2 min" → "60s" jump → nearest-unit rounding, hours above an hour.
  • accounts.rst stale → updated in its own commit.
  • _hourly_window_start duplicate → _window_start(window, at=…).
  • resetStage derivable from resetEmail → removed.

Not touched (pre-existing, outside this PR): advance_email_change updates by id without checking cancelled_at, the server half of the email-change race.

Comment thread dashboard/backend/users_postgres.py Fixed
@FlyM1ss
FlyM1ss merged commit 7be9dae into main Sep 3, 2026
7 checks passed
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.

2 participants