diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a03c78..b13e426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,247 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.13.0 — 2026-05-28 + +**Minor — schema migration required; new optional env vars.** This +release ships the cookie / privacy consent surface (roadmap item #11, +SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike — +now sees a non-modal bottom-of-page banner on first visit asking which +categories of cookies they allow (essential / essential + analytics / +essential + analytics + other). The choice persists in `localStorage` +for anonymous viewers and in a new `cookie_consent` table for +authenticated viewers, with server-side overriding local on sign-in. +The framework also ships default `/privacy` and `/cookies` policy pages +that deployments can layer their own policy URL on top of via two new +optional env vars. No analytics SDK ships in this release — the +consent infrastructure is wired so item #13 (v0.15.0) can read from +`frontend/src/lib/consent.js` when the SDK lands. + +### Added + +- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`). + Non-modal, bottom of viewport. Three single-select choices with + inline descriptions. Visible until the user makes a choice; hides + thereafter. Reachable for revision via the settings surface. +- **Consent helper** (`frontend/src/lib/consent.js`). Exports + `getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`, + `hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the + `storage` event. Item #13's analytics SDK reads consent here before + importing. +- **Privacy and cookies policy pages** + (`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`). + Default minimal policies that describe the framework's stance and + list the cookies the framework sets. Deployments override via the + two new env vars below; the framework's stub always renders above + the link so the framework-level contract stays visible. +- **"Privacy & cookies" tab** in `/settings/notifications` showing + the current consent choice, the recorded-at stamp, and a "Change" + button that re-opens the banner via a custom DOM event. +- **`§17` endpoints** — + - `GET /api/users/me/cookie-consent` — read the current consent + record. + - `PUT /api/users/me/cookie-consent` — write a new consent record. + Upserts a single row per user, stamps `recorded_at` to now, + accepts `essential` for symmetry but always persists it as true. +- **Schema migration** `013_cookie_consent.sql` — new + `cookie_consent` table keyed by `user_id`, three flags + (`essential`, `analytics`, `other_cookies`), and `recorded_at`. + (Renumbered from `012_*` during driver integration because v0.7.0 + also added a `012_otc.sql` migration that landed in the integration + order before this one.) +- **SPEC `§14.5` Cookie / privacy consent** — settles the banner + shape, the three-category single-select, the storage shape (local + for anon, server row for authenticated), the precedence rule on + sign-in, and the `consent.js` helper surface for downstream + callers including item #13. +- **SPEC `§14.6` Privacy and cookies policy pages** — settles the + `/privacy` and `/cookies` routes, the framework's stub content, and + the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override + shape. +- **SPEC `§5`** — names the `cookie_consent` table in the canonical + app-tables list. +- **SPEC `§17`** — lists the two new cookie-consent endpoints. +- **SPEC `§19.2`** — surfaces four candidates: policy content via + content-repo file vs env var, GPC / DNT headers, multi-language + consent text, and the item #13 analytics-SDK gating dependency. + +### Changed + +- **`frontend/.env.example`** — documents the two new optional env + vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset + is supported; defaults render the framework's stub. +- **`backend/app/api_notifications.py`** — module docstring grew two + endpoint lines; the new endpoints sit alongside the existing + `/api/users/me/*` neighbors. +- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies` + routes (anonymous-reachable), wires `` into + the global chrome, and listens for a `rfc-app:cookie-consent-reopen` + custom event to re-open the banner from the settings surface. + +### Upgrade steps (from 0.7.0) + +- You **MUST** rebuild the frontend and restart the backend after + upgrading. `frontend/package.json#version` and `VERSION` both move + to `0.13.0` and the build embeds the new env-var contract. +- You **MUST** apply schema migration `013_cookie_consent.sql`. The + migration creates a single new table keyed by `user_id` with three + flag columns and a `recorded_at` stamp. The framework runs + migrations automatically at process start; no manual step is + required beyond restarting the backend so the migration runner + picks the file up. +- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that + points at your deployment's full privacy policy. The framework's + `/privacy` page renders its built-in stub above a link to the + configured URL. Unset is supported — the stub is sufficient for a + default-config deployment. +- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that + points at your deployment's full cookies policy. Same shape as the + privacy URL. +- You **MAY** announce the new consent banner to your users. Existing + authenticated users will see the banner on their next visit + (because their `cookie_consent` row does not yet exist); their + current sessions remain valid. + +## 0.10.0 — 2026-05-28 + +**Minor — schema migration required; new auth path is additive.** +This release lands user-set passcodes after OTC (roadmap item #8, +SPEC §6.2). After a successful one-time-code sign-in, the user can +set a passcode (4–20 characters) and use email + passcode for +subsequent sign-ins. OTC remains the structural fallback: a +forgotten passcode is recovered by requesting a fresh code, and +five consecutive failed passcode verifies lock the passcode path +for 15 minutes (HTTP 423) while leaving the OTC path open. The +`/login` surface now consults a new `GET /auth/passcode/check` +endpoint after the email step to decide whether to render a +passcode input or an OTC code input; an "Use a code instead" link +on the passcode step lets the user fall back to OTC manually. The +`/settings/notifications` page grew a new "Sign-in" tab where the +user can set, change, or remove their passcode. + +### Upgrade steps (from 0.8.0) + +1. **MUST** restart the backend so migration `015_passcode.sql` + runs. The migration adds four nullable columns to the `users` + table: `passcode_hash`, `passcode_set_at`, + `passcode_failed_attempts` (default 0), `passcode_locked_until`. + Existing rows pass through with `passcode_hash = NULL`, which + the runtime treats as "no passcode set" — every existing user + continues to sign in via OTC unchanged, and can opt into a + passcode from the new settings tab at any time. +2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and + the new settings tab ship. `frontend/package.json#version` and + `VERSION` both move to `0.10.0`. +3. **SHOULD** announce the new sign-in option to users. Wording + suggestion: "You can now set a passcode for faster sign-in. + We'll keep emailing one-time codes as a fallback — if you + forget your passcode, just request a code as usual." +4. **MAY** leave the §6.2 default lockout shape (5 attempts, + 15-minute window) unchanged. v0.10.0 does not expose env + tunables for these; raising or lowering them lives in §19.2 + as a candidate. + +### Added + +- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`. + Validates length (4–20) and refuses obvious patterns from a small + denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes + the passcode and writes `users.passcode_hash` plus + `users.passcode_set_at`. Clears any active lockout and the + failure counter (a user setting a fresh passcode is implicitly + re-authenticating). Replaces any prior passcode. +- **`DELETE /auth/passcode`** — authenticated. Clears the passcode + hash and the set-at stamp; the user is back to OTC-only. +- **`POST /auth/passcode/verify`** — unauthenticated. Body + `{email, passcode}`. Returns HTTP 200 + minimal user payload on + success; HTTP 423 with `locked_until` when the account is in the + lockout window; HTTP 400 for every other failure (the + wrong-passcode and unknown-email modes both collapse to 400 so + the response does not enumerate account state). +- **`GET /auth/passcode/check`** — unauthenticated. Query param + `email`. Returns `{has_passcode: boolean}`. The Login.jsx flow + consults this after the email step to decide whether to render a + passcode input or fall back to OTC. The response carries only + the boolean; lockout state, the hash, and the `passcode_set_at` + stamp are not leaked. An unknown email and a known-without- + passcode email both return `false`, so the endpoint is + account-enumeration-safe. +- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD + COLUMN statements on the `users` table: + - `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means + "no passcode set". + - `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp. + - `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0` — + consecutive failure counter since last success. + - `passcode_locked_until TEXT` (nullable) — lockout window + expiry; verify refuses with HTTP 423 while populated and + in the future. +- **`backend/app/passcode.py`** — the passcode state machine: + validation (length + denylist), bcrypt hashing, set/clear, + status check, and the verify path with lockout management. +- **`frontend/src/components/Login.jsx`** — extended to a five-step + surface: email → passcode-or-code → optional post-OTC + passcode-offer → optional set-passcode. The "Use a code instead" + link on the passcode step re-dispatches an OTC and switches to + the code step. A 423 from passcode verify auto-falls back to OTC + with a visible status message. +- **"Sign-in" tab** in `/settings/notifications` — shows + passcode-set status, the recorded `passcode_set_at` stamp when + set, and Set / Change / Remove buttons. Mirrors the §14.5 + "Privacy & cookies" tab pattern. +- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3 + rule 2: + - §6 names the three current auth paths (OTC, passcode-with-OTC- + fallback, OAuth-fallback-during-migration). + - §14.1 documents the stepped `/login` surface and the passcode + check endpoint. + - §17 lists the four new `/auth/passcode/*` endpoints. + - §19.2 surfaces four new candidates (passcode policy tunables + via env, per-IP rate-limit on `/auth/passcode/verify`, + passcode-change "old passcode" challenge, passkey/WebAuthn); + the "device-trust 30d" entry's passcode cross-ref is updated; + the "first-OTC profile capture" entry's roadmap cross-ref is + updated. + +### Changed + +- **`backend/app/main.py`** — registers the four new + `/auth/passcode/*` routes on the existing oauth router, alongside + the v0.7.0 `/auth/otc/*` routes. +- **`backend/app/api.py`** — `/api/auth/me` payload now includes + `has_passcode` (boolean) and `passcode_set_at` (string or null) + so the settings surface can render the Set / Change / Remove + affordances without a second round trip. +- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`, + `setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0 + `requestOtc` / `verifyOtc` block. +- **`frontend/src/components/NotificationSettings.jsx`** — adds the + `SignInSection` component between `MutesSection` and + `PrivacyCookiesSection`. + +### Tests + +- **`backend/tests/test_passcode_vertical.py`** — 17 new tests + cover: set requires session; check returns false for unknown and + for set-less users; check returns true after set without leaking + other fields; happy-path OTC → set → verify roundtrip; wrong + passcode increments the counter without locking; five consecutive + failures lock with 423 and persist `passcode_locked_until`; + lockout expires and the next attempt clears the counter; the OTC + path is unaffected by passcode lockout; clear wipes the hash and + set-at; setting a new passcode replaces the prior one and resets + the lockout; `passcode_set_at` updates on every set; validation + refuses too-short passcodes and denylist patterns; `/api/auth/me` + carries `has_passcode` and `passcode_set_at` correctly. + +### Environment variables + +None new. The existing `SECRET_KEY` continues to sign session +cookies; passcode hashing reuses the bcrypt dependency added in +v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length +range (4–20) are hard-coded in `backend/app/passcode.py`. See +§19.2 for the env-tunable candidate. + ## 0.8.0 — 2026-05-28 **Minor — schema migration required; admission semantics shift.** @@ -209,107 +450,6 @@ direct DB `UPDATE`. state is wired in the schema and the auth gate; v0.9.0 ships the admin UI that flips the column. -## 0.13.0 — 2026-05-28 - -**Minor — schema migration required; new optional env vars.** This -release ships the cookie / privacy consent surface (roadmap item #11, -SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike — -now sees a non-modal bottom-of-page banner on first visit asking which -categories of cookies they allow (essential / essential + analytics / -essential + analytics + other). The choice persists in `localStorage` -for anonymous viewers and in a new `cookie_consent` table for -authenticated viewers, with server-side overriding local on sign-in. -The framework also ships default `/privacy` and `/cookies` policy pages -that deployments can layer their own policy URL on top of via two new -optional env vars. No analytics SDK ships in this release — the -consent infrastructure is wired so item #13 (v0.15.0) can read from -`frontend/src/lib/consent.js` when the SDK lands. - -### Added - -- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`). - Non-modal, bottom of viewport. Three single-select choices with - inline descriptions. Visible until the user makes a choice; hides - thereafter. Reachable for revision via the settings surface. -- **Consent helper** (`frontend/src/lib/consent.js`). Exports - `getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`, - `hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the - `storage` event. Item #13's analytics SDK reads consent here before - importing. -- **Privacy and cookies policy pages** - (`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`). - Default minimal policies that describe the framework's stance and - list the cookies the framework sets. Deployments override via the - two new env vars below; the framework's stub always renders above - the link so the framework-level contract stays visible. -- **"Privacy & cookies" tab** in `/settings/notifications` showing - the current consent choice, the recorded-at stamp, and a "Change" - button that re-opens the banner via a custom DOM event. -- **`§17` endpoints** — - - `GET /api/users/me/cookie-consent` — read the current consent - record. - - `PUT /api/users/me/cookie-consent` — write a new consent record. - Upserts a single row per user, stamps `recorded_at` to now, - accepts `essential` for symmetry but always persists it as true. -- **Schema migration** `013_cookie_consent.sql` — new - `cookie_consent` table keyed by `user_id`, three flags - (`essential`, `analytics`, `other_cookies`), and `recorded_at`. - (Renumbered from `012_*` during driver integration because v0.7.0 - also added a `012_otc.sql` migration that landed in the integration - order before this one.) -- **SPEC `§14.5` Cookie / privacy consent** — settles the banner - shape, the three-category single-select, the storage shape (local - for anon, server row for authenticated), the precedence rule on - sign-in, and the `consent.js` helper surface for downstream - callers including item #13. -- **SPEC `§14.6` Privacy and cookies policy pages** — settles the - `/privacy` and `/cookies` routes, the framework's stub content, and - the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override - shape. -- **SPEC `§5`** — names the `cookie_consent` table in the canonical - app-tables list. -- **SPEC `§17`** — lists the two new cookie-consent endpoints. -- **SPEC `§19.2`** — surfaces four candidates: policy content via - content-repo file vs env var, GPC / DNT headers, multi-language - consent text, and the item #13 analytics-SDK gating dependency. - -### Changed - -- **`frontend/.env.example`** — documents the two new optional env - vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset - is supported; defaults render the framework's stub. -- **`backend/app/api_notifications.py`** — module docstring grew two - endpoint lines; the new endpoints sit alongside the existing - `/api/users/me/*` neighbors. -- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies` - routes (anonymous-reachable), wires `` into - the global chrome, and listens for a `rfc-app:cookie-consent-reopen` - custom event to re-open the banner from the settings surface. - -### Upgrade steps (from 0.7.0) - -- You **MUST** rebuild the frontend and restart the backend after - upgrading. `frontend/package.json#version` and `VERSION` both move - to `0.13.0` and the build embeds the new env-var contract. -- You **MUST** apply schema migration `013_cookie_consent.sql`. The - migration creates a single new table keyed by `user_id` with three - flag columns and a `recorded_at` stamp. The framework runs - migrations automatically at process start; no manual step is - required beyond restarting the backend so the migration runner - picks the file up. -- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that - points at your deployment's full privacy policy. The framework's - `/privacy` page renders its built-in stub above a link to the - configured URL. Unset is supported — the stub is sufficient for a - default-config deployment. -- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that - points at your deployment's full cookies policy. Same shape as the - privacy URL. -- You **MAY** announce the new consent banner to your users. Existing - authenticated users will see the banner on their next visit - (because their `cookie_consent` row does not yet exist); their - current sessions remain valid. - ## 0.7.0 — 2026-05-28 **Minor — schema migration required; new auth path is additive.** @@ -897,7 +1037,3 @@ names itself. - `CLAUDE.md` at the repo root capturing the separation-of-concerns rule for working sessions. -## 0.1.0 — v1 build - -Initial release. See `docs/DEV.md` for the slicing plan and build -history. diff --git a/SPEC.md b/SPEC.md index e6cfd75..5ecea9e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -356,18 +356,37 @@ merge with no data movement. Authorization is owned by the app. Gitea sees only the bot account. -Authentication, as of v0.7.0, is by email + one-time-code: a visitor -enters their email address, receives a six-digit code via SMTP, and -exchanges the code for a session. The Gitea OAuth callback that -v0.1 used as the human sign-in path remains as a migration fallback -during the v0.7.0 window — `users.gitea_id` is preserved on existing -rows so a grandfathered user signing in via either path resolves to -the same row — but the primary surface points at OTC. `users.email` -is the identity key for everything provisioned after v0.7.0; -`users.gitea_id` is the grandfathering linker (nullable, partial- -unique). The Gitea bot user + token are still required for server- -side git operations (repo reads, PR creation); only the operator- -facing sign-in surface moved. +Authentication has three paths, in the order a visitor encounters +them: + +1. **Email + one-time code (OTC).** The v0.7.0 primary path: a + visitor enters their email address, receives a six-digit code via + SMTP, and exchanges the code for a session. Used by every visitor + on first sign-in, and as the fallback for the other two paths. +2. **Email + passcode (with OTC fallback).** Added in v0.10.0 + (roadmap item #8). After a successful OTC sign-in, the visitor + may set a user-chosen passcode (4–20 characters, bcrypt-hashed at + rest) and use email + passcode on subsequent sign-ins. Five + consecutive failed verifies lock the passcode path for 15 minutes + (HTTP 423); during the lockout the user falls back to OTC. The + OTC path is unaffected by the passcode lockout, so a forgotten + passcode is recovered by requesting a fresh OTC — there is no + separate "forgot passcode" flow. The user can remove the passcode + at any time from the §6.2 sign-in settings tab, returning to + OTC-only. +3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth + callback remains functional during the v0.7.0 window, with a + small "Sign in with Gitea (fallback)" link on `/login` so users + with active OAuth sessions or older invite paths still have a + way in. Scheduled for removal in a future release per §19.2. + +`users.gitea_id` is preserved on existing rows so a grandfathered +user signing in via any of the three paths resolves to the same row. +`users.email` is the identity key for everything provisioned after +v0.7.0; `users.gitea_id` is the grandfathering linker (nullable, +partial-unique). The Gitea bot user + token are still required for +server-side git operations (repo reads, PR creation); only the +operator-facing sign-in surface moved. Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the v0.3.0 `allowed_emails` table forward as the admission gate at the @@ -2035,6 +2054,30 @@ with Gitea"; v0.7.0's email/OTC surface replaced that as the primary gesture, with a small "Sign in with Gitea (fallback)" link surviving on `/login` itself for the migration window. +`/login` itself is a stepped surface, driven by which auth path the +viewer is currently on (§6): + +1. **Email step.** The viewer enters their email. The frontend + consults `GET /auth/passcode/check?email=…` to learn whether + this email has a passcode set. The check endpoint is + account-enumeration-safe — it returns `has_passcode: false` for + both "unknown email" and "known email without passcode", so a + probing client cannot distinguish the two from the response. +2. **Either the passcode step or the OTC code step.** If the email + has a passcode set, the viewer is asked for it (v0.10.0). + Otherwise an OTC is dispatched and the viewer is asked for the + six-digit code from their email (v0.7.0). +3. **Optional post-OTC passcode-offer step.** After a successful + OTC verify on an account with no passcode set, the surface + asks "Set a passcode for faster sign-in next time?" — the user + can dismiss the offer or set one inline. The skip-for-now path + redirects straight to `/`. + +The passcode step carries a "Use a code instead" link that +re-dispatches an OTC and switches to the code step — the same path +the lockout response (HTTP 423) takes automatically after five +consecutive failed passcode verifies. + This is the front door. It sets expectation before the user encounters the mechanics, so the mechanics (super-drafts, graduation, public arguments, AI participation in chat) read as load-bearing rather than @@ -2728,6 +2771,38 @@ The follow-up session will refine this. A minimal starting set: v0.8.0 — the first-OTC profile-capture endpoint (roadmap item #6). v0.9.0's admin user-management page consumes this column set to render the request queue. +- `GET /auth/passcode/check` — unauthenticated. Query param `email`. + Returns `{has_passcode: boolean}`. The frontend's `/login` surface + calls this after the email step to decide whether to render a + passcode input or fall back to OTC. The response carries only the + boolean; lockout state, the bcrypt hash, and the `passcode_set_at` + stamp are not leaked. An unknown email and a known-without-passcode + email both return `false`, so the endpoint is enumeration-safe. +- `POST /auth/passcode/set` — authenticated (any role). Body carries + `passcode` (4–20 characters). bcrypt-hashes the passcode, writes + `users.passcode_hash` + `users.passcode_set_at`, clears the failure + counter and any active lockout. Refuses obvious patterns (a small + denylist: `0000`, `1234`, `aaaa`, `password`, etc.) and length + violations with HTTP 422. Replaces any prior passcode. v0.10.0. +- `DELETE /auth/passcode` — authenticated. Clears + `users.passcode_hash` and `users.passcode_set_at`, returning the + user to OTC-only on next sign-in. v0.10.0. +- `POST /auth/passcode/verify` — unauthenticated. Body carries + `email` and `passcode`. Locates the user, checks the lockout + window, and compares via bcrypt. On success: clears the failure + counter, refreshes `last_seen_at`, stores the session cookie, + returns HTTP 200 with the minimal user payload. On failure: + increments `passcode_failed_attempts`. After five consecutive + failures, stamps `passcode_locked_until = now + 15 minutes` and + returns HTTP 423 with a `locked_until` field; subsequent attempts + inside the window are refused with the same shape. After the + window expires, the next attempt clears the counter and proceeds + normally. The OTC path (§17 above) is unaffected by the passcode + lockout — a locked-out user can still request and verify a fresh + OTC. The wrong-passcode and unknown-email failure modes both + return HTTP 400 with a generic message; the no-passcode-set + failure also collapses to 400 so the response does not enumerate + account state. v0.10.0. - `GET /api/rfcs` — list entries with state, id, title, slug, repo, owners, last_active_at, has_open_prs, starred-by-me. Supports search, sort, filter chips, and the `unclaimed` predicate. @@ -3785,16 +3860,17 @@ Candidates surfaced during v0.8.0 (open beta-access request flow, its session once the OTC adoption curve flattens. - **Device trust (30-day skip).** *Surfaced by v0.7.0 — the signed-in cookie already lasts 30 days via SessionMiddleware, - but every sign-in still requires a fresh OTC.* The roadmap - item-#9 candidate adds a "trust this device" affordance on the - verify step that issues a longer-lived rotating token, so - returning visitors on the same device skip the OTC step. The - shape question is whether the trust is a signed cookie distinct - from the session, a row in a `device_trust` table keyed by a - random device-id, or a property of the session itself; and - whether the trust survives password-equivalent events (none - exist yet — passcodes are item #8 / v0.10.0) or only survives - explicit logout. Earns its session as the v0.11.0 design pass. + but every sign-in still requires a fresh OTC or passcode.* The + roadmap item-#9 candidate adds a "trust this device" affordance + on the verify step that issues a longer-lived rotating token, + so returning visitors on the same device skip both the OTC and + the passcode step. The shape question is whether the trust is a + signed cookie distinct from the session, a row in a `device_trust` + table keyed by a random device-id, or a property of the session + itself; and whether the trust survives password-equivalent events + — v0.10.0's passcode-change and passcode-clear gestures are the + v1 instances — or only survives explicit logout. Earns its + session as the v0.11.0 design pass. - **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.** *Surfaced by v0.7.0 — the endpoint is now the new abuse hot path.* Per-email cooldown stops the trivial loop; what it @@ -3814,6 +3890,47 @@ Candidates surfaced during v0.8.0 (open beta-access request flow, harness mocks the challenge. Earns its session as the v0.12.0 design pass. +Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 / +roadmap item #8): + +- **Passcode policy tunables via env.** v0.10.0 hard-codes the + lockout shape (5 consecutive failures → 15-minute lockout) and + the min/max passcode length (4 / 20) in + `backend/app/passcode.py`. The denylist of obvious patterns is + also hard-coded. A deployment that wants tighter or looser rules + has to fork the constants. Two env vars + (`PASSCODE_LOCKOUT_AFTER_ATTEMPTS`, + `PASSCODE_LOCKOUT_DURATION_MINUTES`) would let operators + reshape the lockout without forking; a third + (`PASSCODE_MIN_LENGTH`) would cover the length floor. Earns its + session if a deployment surfaces evidence that the v1 defaults + bite. +- **Per-IP rate-limiting on `/auth/passcode/verify`.** v0.10.0's + lockout is per-account: 5 failures against the same email lock + that account for 15 minutes. A distributed attacker that knows + many emails can fan out across them without ever tripping any + one account's lockout. Adding a per-IP throttle (e.g., 30 + passcode-verify attempts / minute / IP, returning HTTP 429) is + the natural pairing. Defer-able — the per-account lockout is + the v1 shape that closes the loud-loop case; the per-IP + distributed case waits on evidence. Touches §6.2 and §17. +- **Passcode-change "still know your old passcode" challenge.** + v0.10.0 lets a signed-in user replace their passcode from + `/settings/notifications` without re-entering the old one — the + session is sufficient. A future hardening pass may require the + old passcode (or a fresh OTC verify) before accepting the + change, to mitigate session-hijack scenarios where the attacker + rotates the passcode to lock the legitimate owner out. The same + question applies to the clear gesture. Earns its session if + session-hijack becomes a real threat surface. +- **Passkey / WebAuthn.** A much heavier next step than passcodes: + hardware-backed device credentials that resist phishing. The + v0.10.0 passcode shape is a stopgap for the "I'd rather not + type a code every time" ergonomic problem; passkeys are the + long-term answer. Out of scope for the current roadmap — + earns a dedicated session if/when the deployment grows enough + that the phishing surface justifies the integration cost. + Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5 and §14.6): diff --git a/VERSION b/VERSION index a3df0a6..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.10.0 diff --git a/backend/app/api.py b/backend/app/api.py index 83f97d6..732c9a1 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -131,13 +131,12 @@ def make_router( user = auth.current_user(request) if user is None: return {"authenticated": False, "user": None} - # v0.8.0: surface `permission_state` plus the capture-flow - # readiness signal (`needs_profile`). The frontend gates - # the /beta-pending page and the inline banner off these - # fields, and decides whether to prompt for the first/last/why - # capture on first OTC sign-in. + # v0.8.0 + v0.10.0: single round-trip for everything the + # frontend gates UI off of — beta-access state + passcode state. row = db.conn().execute( - "SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?", + "SELECT first_name, last_name, beta_request_reason, " + "passcode_hash, passcode_set_at " + "FROM users WHERE id = ?", (user.user_id,), ).fetchone() first_name = (row["first_name"] if row else None) or "" @@ -153,6 +152,8 @@ def make_router( and not last_name and not beta_request_reason ) + has_passcode = bool(row and row["passcode_hash"]) + passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None return { "authenticated": True, "user": { @@ -167,6 +168,8 @@ def make_router( "last_name": last_name, "beta_request_reason": beta_request_reason, "needs_profile": needs_profile, + "has_passcode": has_passcode, + "passcode_set_at": passcode_set_at, }, } diff --git a/backend/app/main.py b/backend/app/main.py index ae44fb8..fcb1683 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from . import ( email_otc, hygiene, otc, + passcode as passcode_mod, providers as providers_mod, webhooks, ) @@ -44,6 +45,15 @@ class OtcVerifyBody(BaseModel): code: str = Field(min_length=1, max_length=16) +class PasscodeSetBody(BaseModel): + passcode: str = Field(min_length=1, max_length=64) + + +class PasscodeVerifyBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + passcode: str = Field(min_length=1, max_length=64) + + @asynccontextmanager async def lifespan(app: FastAPI): config = load_config() @@ -204,4 +214,72 @@ def _oauth_router(config) -> APIRouter: "needs_profile": needs_profile, } + # --------------------------------------------------------------- + # v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8). + # + # After a successful OTC sign-in, a contributor may set a passcode + # and use email + passcode for subsequent sign-ins. OTC remains the + # forgot-passcode fallback — a verify failure beyond 5 consecutive + # attempts locks the passcode path for 15 minutes; the OTC path is + # unaffected by the lockout. + # --------------------------------------------------------------- + + @router.get("/auth/passcode/check") + async def passcode_check(email: str = ""): + """Does this email have a passcode set? Anonymous endpoint — + the Login.jsx flow calls this after the user types their email + to decide whether to render a passcode input or fall back to + OTC. We surface only the boolean; lockout state, the hash, and + the set-at stamp are not leaked here.""" + status = passcode_mod.passcode_status(email) + return {"has_passcode": status.has_passcode} + + @router.post("/auth/passcode/set") + async def passcode_set(body: PasscodeSetBody, request: Request): + """Set or replace the signed-in user's passcode. Requires an + active session (OTC- or passcode-authenticated).""" + user = auth.require_user(request) + try: + passcode_mod.set_passcode(user.user_id, body.passcode) + except passcode_mod.PasscodeValidationError as e: + raise HTTPException(422, str(e)) + return {"ok": True} + + @router.delete("/auth/passcode") + async def passcode_delete(request: Request): + """Remove the signed-in user's passcode. The user is back to + OTC-only on next sign-in.""" + user = auth.require_user(request) + passcode_mod.clear_passcode(user.user_id) + return {"ok": True} + + @router.post("/auth/passcode/verify") + async def passcode_verify(body: PasscodeVerifyBody, request: Request): + """Sign in with email + passcode. Returns the standard session + payload on success; HTTP 423 with `locked_until` when the + account is in the lockout window; HTTP 400 for every other + failure (the wrong-vs-unknown distinction is intentionally + collapsed so a probing client cannot enumerate emails).""" + result = passcode_mod.verify_passcode(body.email, body.passcode) + if result.reason == "locked": + raise HTTPException( + 423, + { + "detail": "Too many failed attempts; sign in with a one-time code instead", + "locked_until": result.locked_until, + }, + ) + if not result.ok or result.user is None: + raise HTTPException(400, "Invalid passcode") + auth.store_session(request, result.user) + return { + "ok": True, + "user": { + "id": result.user.user_id, + "display_name": result.user.display_name, + "email": result.user.email, + "role": result.user.role, + }, + } + return router diff --git a/backend/app/passcode.py b/backend/app/passcode.py new file mode 100644 index 0000000..6d54c4b --- /dev/null +++ b/backend/app/passcode.py @@ -0,0 +1,367 @@ +"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8). + +After a successful OTC sign-in, a contributor may set a passcode and +use email + passcode for subsequent sign-ins. OTC remains the fallback +— a forgotten passcode is recovered by requesting a fresh OTC. + +This module is the state machine behind the four `/auth/passcode/*` +endpoints (`set`, `clear`, `verify`, `check`). The endpoints in +`main.py` thin-wrap these helpers in the same shape the OTC module +uses (see `otc.py`). + +Shape: + + * `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and + write it to `users.passcode_hash` + `users.passcode_set_at`. + Validation (length, denylist) happens here, not at the endpoint, + so the rule lives in one place. Replaces any prior passcode. + * `clear_passcode(user_id)` — null out `passcode_hash` and + `passcode_set_at`. The user is back to OTC-only. + * `verify_passcode(email, passcode)` — locate the user by email, + check lockout, compare via bcrypt, manage the failure counter, + and return a populated `SessionUser` on success. + * `passcode_status(email)` — does this email have a passcode set? + Used by the `/auth/passcode/check` endpoint that the Login.jsx + flow consults after the user types their email. + +Lockout is a v1 shape: 5 consecutive failures sets +`passcode_locked_until` to `now + 15 minutes`, after which a verify +attempt that lands inside the window returns HTTP 423. The OTC path +is unaffected by the lockout — a user can request and verify a fresh +OTC to sign in while their passcode is locked out, and `verify_code` +in `otc.py` does not consult these columns. + +The lockout window and the failure threshold are hard-coded here. +Tuning them via env vars (or moving to per-IP rate-limiting) is a +§19.2 candidate; see SPEC §19.2. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import bcrypt + +from . import db +from .auth import SessionUser + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Tunables — intentionally hard-coded in v0.10.0 (see module docstring). +# --------------------------------------------------------------------------- + +LOCKOUT_AFTER_FAILED_ATTEMPTS = 5 +LOCKOUT_DURATION_MINUTES = 15 + +PASSCODE_MIN_LENGTH = 4 +PASSCODE_MAX_LENGTH = 20 + +# A small denylist of patterns we never want a passcode to be. The +# rule is "no obvious patterns"; the list is deliberately small — +# every entry here is a verbatim string match. A heavier check +# (sequential digits, single-character runs of length >= N, etc.) +# is a §19.2 candidate. +PASSCODE_DENYLIST: frozenset[str] = frozenset( + { + "0000", + "1111", + "2222", + "3333", + "4444", + "5555", + "6666", + "7777", + "8888", + "9999", + "1234", + "12345", + "123456", + "1234567", + "12345678", + "123456789", + "1234567890", + "0123", + "01234", + "012345", + "0123456", + "01234567", + "012345678", + "0123456789", + "abcd", + "abcde", + "abcdef", + "qwer", + "qwerty", + "asdf", + "asdfg", + "asdfgh", + "aaaa", + "bbbb", + "cccc", + "password", + "letmein", + } +) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class PasscodeValidationError(Exception): + """The proposed passcode failed validation. The endpoint surface + maps this to HTTP 422 with the message intact.""" + + +def _validate(passcode: str) -> str: + """Return the normalized passcode (stripped) or raise. + + Rules: + * 4-20 characters after stripping leading/trailing whitespace. + * Not on the small denylist of obvious patterns. + + No character-class restriction beyond that — the spec says + "numeric PIN or short alphanumeric"; we don't refuse other + characters because the entropy isn't load-bearing (the per-account + lockout is what carries the security weight, mirroring the OTC + shape from v0.7.0). + """ + pc = (passcode or "").strip() + if not pc: + raise PasscodeValidationError("Passcode is required") + if len(pc) < PASSCODE_MIN_LENGTH: + raise PasscodeValidationError( + f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters" + ) + if len(pc) > PASSCODE_MAX_LENGTH: + raise PasscodeValidationError( + f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters" + ) + if pc.lower() in PASSCODE_DENYLIST: + raise PasscodeValidationError("Passcode is too common; pick something less obvious") + return pc + + +# --------------------------------------------------------------------------- +# Hashing +# --------------------------------------------------------------------------- + + +def _hash(passcode: str) -> str: + return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + +def _check(passcode: str, passcode_hash: str) -> bool: + try: + return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii")) + except (ValueError, TypeError): + return False + + +# --------------------------------------------------------------------------- +# Set / clear +# --------------------------------------------------------------------------- + + +def set_passcode(user_id: int, passcode: str) -> None: + """Hash and store the passcode. Replaces any prior passcode on the + same row; clears the failure counter and lockout (a user setting a + fresh passcode is implicitly re-authenticating their account).""" + pc = _validate(passcode) + h = _hash(pc) + db.conn().execute( + """ + UPDATE users + SET passcode_hash = ?, + passcode_set_at = datetime('now'), + passcode_failed_attempts = 0, + passcode_locked_until = NULL + WHERE id = ? + """, + (h, user_id), + ) + + +def clear_passcode(user_id: int) -> None: + """Remove the passcode. The user is back to OTC-only on next sign-in.""" + db.conn().execute( + """ + UPDATE users + SET passcode_hash = NULL, + passcode_set_at = NULL, + passcode_failed_attempts = 0, + passcode_locked_until = NULL + WHERE id = ? + """, + (user_id,), + ) + + +# --------------------------------------------------------------------------- +# Check (status surface for the Login.jsx flow) +# --------------------------------------------------------------------------- + + +@dataclass +class PasscodeStatus: + """The shape `/auth/passcode/check` returns. + + `has_passcode` is the only signal the frontend needs to decide + whether to show a passcode input or an OTC request step. We do + not leak the hash, the set-at timestamp, or the lockout state — + a probing client that wants to know "is this account locked + out" can attempt a verify and read the 423. + """ + has_passcode: bool + + +def passcode_status(email: str) -> PasscodeStatus: + email = (email or "").strip() + if not email or "@" not in email: + return PasscodeStatus(has_passcode=False) + row = db.conn().execute( + "SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE", + (email,), + ).fetchone() + if row is None: + return PasscodeStatus(has_passcode=False) + return PasscodeStatus(has_passcode=bool(row["passcode_hash"])) + + +# --------------------------------------------------------------------------- +# Verify +# --------------------------------------------------------------------------- + + +@dataclass +class VerifyOutcome: + """Result of a `verify_passcode` call. + + `reason` distinguishes the failure modes the endpoint surfaces as + distinct HTTP shapes: + * 'ok' — populated `user`, HTTP 200. + * 'unknown' — no user with this email, HTTP 400 (generic). + * 'no_passcode' — user exists but never set a passcode, HTTP 400 + (the frontend should fall back to OTC). + * 'locked' — user is currently in the lockout window, HTTP + 423. `locked_until` carries the ISO-8601 stamp for the client. + * 'wrong' — passcode didn't match. HTTP 400. If the failure + crossed the lockout threshold the row is now locked; the + endpoint surfaces this as a fresh `locked` response on the + next attempt rather than collapsing the two states here. + """ + ok: bool + user: SessionUser | None + reason: str + locked_until: str | None = None + + +def verify_passcode(email: str, passcode: str) -> VerifyOutcome: + email = (email or "").strip() + passcode = (passcode or "").strip() + if not email or not passcode: + return VerifyOutcome(ok=False, user=None, reason="unknown") + + row = db.conn().execute( + """ + SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, + passcode_hash, passcode_failed_attempts, passcode_locked_until + FROM users + WHERE email = ? COLLATE NOCASE + """, + (email,), + ).fetchone() + if row is None: + return VerifyOutcome(ok=False, user=None, reason="unknown") + if not row["passcode_hash"]: + return VerifyOutcome(ok=False, user=None, reason="no_passcode") + + # Lockout check: if `passcode_locked_until` is populated and in the + # future, the verify is refused without touching the hash. Once the + # window has elapsed we let the verify proceed; the failed-attempts + # counter is also reset so the user gets a fresh 5-attempt budget. + locked_until = row["passcode_locked_until"] + if locked_until: + still_locked = db.conn().execute( + "SELECT datetime(?) > datetime('now') AS still_locked", + (locked_until,), + ).fetchone()["still_locked"] + if still_locked: + return VerifyOutcome( + ok=False, + user=None, + reason="locked", + locked_until=locked_until, + ) + # Lockout expired — clear the counter so the next failure starts + # from zero, and continue with the verify. + db.conn().execute( + """ + UPDATE users + SET passcode_failed_attempts = 0, + passcode_locked_until = NULL + WHERE id = ? + """, + (row["id"],), + ) + + if _check(passcode, row["passcode_hash"]): + # Success: clear the counter (a single success wipes the + # accumulated failures — the threshold tracks *consecutive* + # failures). + db.conn().execute( + """ + UPDATE users + SET passcode_failed_attempts = 0, + passcode_locked_until = NULL, + last_seen_at = datetime('now') + WHERE id = ? + """, + (row["id"],), + ) + return VerifyOutcome( + ok=True, + user=SessionUser( + user_id=row["id"], + gitea_id=row["gitea_id"] or 0, + gitea_login=row["gitea_login"] or "", + display_name=row["display_name"], + email=row["email"] or email, + avatar_url=row["avatar_url"] or "", + role=row["role"], + ), + reason="ok", + ) + + # Failure: increment the counter. If this push crosses the + # threshold, stamp the lockout. The next verify attempt against + # the same row returns 423 with the `locked_until` stamp. + next_count = (row["passcode_failed_attempts"] or 0) + 1 + if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS: + db.conn().execute( + f""" + UPDATE users + SET passcode_failed_attempts = ?, + passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes') + WHERE id = ? + """, + (next_count, row["id"]), + ) + new_locked_until = db.conn().execute( + "SELECT passcode_locked_until FROM users WHERE id = ?", + (row["id"],), + ).fetchone()["passcode_locked_until"] + return VerifyOutcome( + ok=False, + user=None, + reason="locked", + locked_until=new_locked_until, + ) + db.conn().execute( + "UPDATE users SET passcode_failed_attempts = ? WHERE id = ?", + (next_count, row["id"]), + ) + return VerifyOutcome(ok=False, user=None, reason="wrong") diff --git a/backend/migrations/015_passcode.sql b/backend/migrations/015_passcode.sql new file mode 100644 index 0000000..d2a2e9f --- /dev/null +++ b/backend/migrations/015_passcode.sql @@ -0,0 +1,52 @@ +-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8). +-- +-- After a successful OTC sign-in, a contributor may set a passcode +-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same +-- account can use email + passcode instead of email + OTC. OTC remains +-- the structural fallback — a forgotten passcode is recovered by +-- requesting a fresh OTC and signing in via that path. Per-account +-- lockout after 5 consecutive verify failures redirects the user to +-- the OTC path for 15 minutes; the OTC path itself is unaffected by +-- the passcode lockout (a locked-out user can still receive a fresh +-- code and sign in). +-- +-- The columns are additive to the `users` table from `012_otc.sql`. +-- v0.8.0's `permission_state` column (roadmap item #6) lands in the +-- driver's integration order ahead of this migration; we do not touch +-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape +-- is preserved verbatim. +-- +-- Storage shape: +-- +-- * `passcode_hash` (nullable) — bcrypt hash of the passcode. +-- NULL means "no passcode set"; the user is OTC-only. +-- * `passcode_set_at` (nullable) — timestamp of the most recent +-- `passcode/set` call. Updated when a passcode is set or +-- replaced; cleared when the passcode is removed. +-- * `passcode_failed_attempts` — count of consecutive failed +-- verify attempts since the last successful verify (or since +-- the lockout cleared). Resets to 0 on success and on lockout +-- expiry. Defaults to 0 so existing rows post-migration are +-- not implicitly half-locked. +-- * `passcode_locked_until` (nullable) — if populated and the +-- timestamp is in the future, passcode verify is refused with +-- HTTP 423. Cleared on successful verify after the window +-- expires, or by the operator via direct DB intervention if +-- ever needed (no admin endpoint surfaces this in v1). +-- +-- v0.10.0 introduces no new env vars. The lockout window (5 attempts, +-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or +-- lowering it is a future-§19.2 candidate. Passcode hashing reuses +-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is +-- required (the existing `SECRET_KEY` continues to sign sessions). +-- +-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this +-- migration does not need the rebuild dance that `012_otc.sql` +-- required. The runner wraps each file in a single BEGIN/COMMIT +-- block — see `backend/app/db.py` — so either every ADD COLUMN +-- here lands or none do. + +ALTER TABLE users ADD COLUMN passcode_hash TEXT; +ALTER TABLE users ADD COLUMN passcode_set_at TEXT; +ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN passcode_locked_until TEXT; diff --git a/backend/tests/test_passcode_vertical.py b/backend/tests/test_passcode_vertical.py new file mode 100644 index 0000000..93e64e1 --- /dev/null +++ b/backend/tests/test_passcode_vertical.py @@ -0,0 +1,532 @@ +"""End-to-end integration tests for the v0.10.0 user-set passcode +vertical (§6.2, roadmap item #8). + +After a successful OTC sign-in the user can set a passcode and use +email + passcode for subsequent sign-ins. OTC remains the structural +fallback — these tests prove: + + * `/auth/passcode/set` requires an active session. + * `/auth/passcode/check` returns `has_passcode` without leaking the + hash, the set-at stamp, or the lockout state. + * Happy path: OTC sign-in → set passcode → sign out → email + + passcode signs in (no OTC roundtrip). + * Wrong passcode increments the failure counter without locking. + * Five consecutive failures lock the passcode path (HTTP 423) and + persist `passcode_locked_until` on the user row. + * The lockout expires after `passcode_locked_until`; a verify + attempt past the window succeeds again and clears the counter. + * The OTC path is unaffected by the passcode lockout — a user + whose passcode is locked can still request and verify a fresh + OTC to sign in. + * Clearing the passcode wipes the hash; subsequent verify refuses + with the no-passcode failure shape. + * Setting a new passcode replaces the prior one (and resets the + failure counter / lockout state). + * `passcode_set_at` updates on every set call. + * The validation denylist refuses obvious patterns (e.g. `0000`, + `1234`). + * Passcode length is enforced (4-20). + +The fakes from `test_propose_vertical` give us a working app harness. +The OTC envelope buffer from `test_otc_vertical` is reused for the +OTC roundtrips this suite needs. +""" +from __future__ import annotations + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers — mirror the OTC suite's outbound-buffer helpers. +# --------------------------------------------------------------------------- + + +def _reset_outbound(): + from app import email as email_mod + email_mod.reset_sent_envelopes() + + +def _outbound_otc_codes(to_address: str | None = None) -> list[str]: + from app import email as email_mod + out = [] + for env in email_mod.sent_envelopes(): + if env.get("kind") != "otc": + continue + if to_address is not None and env["to"] != to_address: + continue + for line in env["body"].splitlines(): + tok = line.strip() + if tok.isdigit() and len(tok) == 6: + out.append(tok) + break + return out + + +def _sign_in_via_otc(client, email: str) -> None: + """Run an OTC request+verify so the client carries an authenticated + session. The cooldown is irrelevant on a fresh email; we don't + need to drop it.""" + r = client.post("/auth/otc/request", json={"email": email}) + assert r.status_code == 200, r.text + code = _outbound_otc_codes(email)[-1] + r = client.post("/auth/otc/verify", json={"email": email, "code": code}) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Set passcode — auth-required, happy path +# --------------------------------------------------------------------------- + + +def test_set_passcode_requires_session(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/auth/passcode/set", json={"passcode": "secret123"}) + assert r.status_code == 401 + + +def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "alice@example.com") + + r = client.post("/auth/passcode/set", json={"passcode": "secret123"}) + assert r.status_code == 200, r.text + + row = db.conn().execute( + "SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE", + ("alice@example.com",), + ).fetchone() + assert row is not None + assert row["passcode_hash"] is not None + # Not the plaintext. + assert row["passcode_hash"] != "secret123" + assert row["passcode_set_at"] is not None + + +# --------------------------------------------------------------------------- +# Check endpoint — leak-free shape +# --------------------------------------------------------------------------- + + +def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"}) + assert r.status_code == 200 + assert r.json() == {"has_passcode": False} + + +def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "bob@example.com") + + r = client.get("/auth/passcode/check", params={"email": "bob@example.com"}) + assert r.status_code == 200 + assert r.json() == {"has_passcode": False} + + +def test_check_endpoint_returns_true_after_set(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "carol@example.com") + client.post("/auth/passcode/set", json={"passcode": "letmein9"}) + + # Drop the session so the check is read in the anonymous shape. + client.cookies.clear() + r = client.get("/auth/passcode/check", params={"email": "carol@example.com"}) + assert r.status_code == 200 + assert r.json() == {"has_passcode": True} + # The response carries ONLY the boolean — no hash, no stamp. + assert set(r.json().keys()) == {"has_passcode"} + + +# --------------------------------------------------------------------------- +# Verify path — happy path +# --------------------------------------------------------------------------- + + +def test_verify_passcode_signs_in_user(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "dave@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + client.cookies.clear() + + r = client.post( + "/auth/passcode/verify", + json={"email": "dave@example.com", "passcode": "secret123"}, + ) + assert r.status_code == 200, r.text + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == "dave@example.com" + assert me["user"]["has_passcode"] is True + + +# --------------------------------------------------------------------------- +# Verify path — failure modes +# --------------------------------------------------------------------------- + + +def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "erin@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + client.cookies.clear() + + # Three bad attempts — under the lockout threshold. + for _ in range(3): + r = client.post( + "/auth/passcode/verify", + json={"email": "erin@example.com", "passcode": "wrongwrong"}, + ) + assert r.status_code == 400 + + row = db.conn().execute( + "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?", + ("erin@example.com",), + ).fetchone() + assert row["passcode_failed_attempts"] == 3 + assert row["passcode_locked_until"] is None + + +def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "frank@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + client.cookies.clear() + + # Five bad attempts — the last crosses the threshold and the + # response shape flips to 423. + statuses = [] + for _ in range(5): + r = client.post( + "/auth/passcode/verify", + json={"email": "frank@example.com", "passcode": "wrongwrong"}, + ) + statuses.append(r.status_code) + # First four are 400, the fifth (threshold-crossing) is 423. + assert statuses == [400, 400, 400, 400, 423] + + row = db.conn().execute( + "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?", + ("frank@example.com",), + ).fetchone() + assert row["passcode_failed_attempts"] >= 5 + assert row["passcode_locked_until"] is not None + + # Sixth attempt — still locked, still 423, even with the correct + # passcode (lockout overrides the verify). + r = client.post( + "/auth/passcode/verify", + json={"email": "frank@example.com", "passcode": "secret123"}, + ) + assert r.status_code == 423 + + +def test_verify_passcode_lockout_expires(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "gina@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + client.cookies.clear() + + for _ in range(5): + client.post( + "/auth/passcode/verify", + json={"email": "gina@example.com", "passcode": "wrongwrong"}, + ) + + # Backdate the lockout to the past so the next attempt clears it. + db.conn().execute( + """ + UPDATE users + SET passcode_locked_until = datetime('now', '-1 minute') + WHERE email = ? + """, + ("gina@example.com",), + ) + + r = client.post( + "/auth/passcode/verify", + json={"email": "gina@example.com", "passcode": "secret123"}, + ) + assert r.status_code == 200, r.text + + # Lockout cleared, counter reset. + row = db.conn().execute( + "SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?", + ("gina@example.com",), + ).fetchone() + assert row["passcode_failed_attempts"] == 0 + assert row["passcode_locked_until"] is None + + +def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + # Drop the OTC cooldown so the second request lands without a 429. + # The cooldown is re-read from env on every `request_code` call so + # this takes effect mid-process. + monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0") + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "harvey@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + client.cookies.clear() + + # Lock the passcode path. + for _ in range(5): + client.post( + "/auth/passcode/verify", + json={"email": "harvey@example.com", "passcode": "wrongwrong"}, + ) + + # The OTC path is unaffected by the passcode lockout: the user + # can still request and verify a fresh code to sign in. + r = client.post("/auth/otc/request", json={"email": "harvey@example.com"}) + assert r.status_code == 200 + code = _outbound_otc_codes("harvey@example.com")[-1] + r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code}) + assert r.status_code == 200 + + # The user is now signed in via OTC even though the passcode + # path is locked. The /api/auth/me payload reflects this. + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == "harvey@example.com" + + +# --------------------------------------------------------------------------- +# Clear + replace +# --------------------------------------------------------------------------- + + +def test_clear_passcode_wipes_the_hash(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "ivy@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + + r = client.delete("/auth/passcode") + assert r.status_code == 200 + + row = db.conn().execute( + "SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?", + ("ivy@example.com",), + ).fetchone() + assert row["passcode_hash"] is None + assert row["passcode_set_at"] is None + + # Verify against the cleared passcode refuses (no-passcode shape + # collapses to a generic 400). + client.cookies.clear() + r = client.post( + "/auth/passcode/verify", + json={"email": "ivy@example.com", "passcode": "secret123"}, + ) + assert r.status_code == 400 + + +def test_setting_new_passcode_replaces_old(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "jane@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + # Replace. + r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"}) + assert r.status_code == 200 + + client.cookies.clear() + + # Old passcode refuses. + r = client.post( + "/auth/passcode/verify", + json={"email": "jane@example.com", "passcode": "secret123"}, + ) + assert r.status_code == 400 + + # New passcode signs in. + r = client.post( + "/auth/passcode/verify", + json={"email": "jane@example.com", "passcode": "newsecret9"}, + ) + assert r.status_code == 200 + + +def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + from app import db + + monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0") + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "kate@example.com") + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + + # Lock the passcode path with bad attempts (drop session first). + client.cookies.clear() + for _ in range(5): + client.post( + "/auth/passcode/verify", + json={"email": "kate@example.com", "passcode": "wrongwrong"}, + ) + + row = db.conn().execute( + "SELECT passcode_locked_until FROM users WHERE email = ?", + ("kate@example.com",), + ).fetchone() + assert row["passcode_locked_until"] is not None + + # Sign back in via OTC and reset the passcode. + r = client.post("/auth/otc/request", json={"email": "kate@example.com"}) + assert r.status_code == 200 + code = _outbound_otc_codes("kate@example.com")[-1] + r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code}) + assert r.status_code == 200 + + r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"}) + assert r.status_code == 200 + + # Lockout cleared on set. + row = db.conn().execute( + "SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?", + ("kate@example.com",), + ).fetchone() + assert row["passcode_locked_until"] is None + assert row["passcode_failed_attempts"] == 0 + + +def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + import time + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "luke@example.com") + + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + first_stamp = db.conn().execute( + "SELECT passcode_set_at FROM users WHERE email = ?", + ("luke@example.com",), + ).fetchone()["passcode_set_at"] + assert first_stamp is not None + + # SQLite's datetime('now') has second precision; sleep so the + # stamp visibly advances on the next set. + time.sleep(1.1) + + client.post("/auth/passcode/set", json={"passcode": "newcode99"}) + second_stamp = db.conn().execute( + "SELECT passcode_set_at FROM users WHERE email = ?", + ("luke@example.com",), + ).fetchone()["passcode_set_at"] + assert second_stamp is not None + assert second_stamp >= first_stamp + # Lexicographic compare on ISO-8601 datetime strings works for + # the SQLite shape. + assert second_stamp > first_stamp + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def test_set_passcode_refuses_too_short(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "mia@example.com") + r = client.post("/auth/passcode/set", json={"passcode": "abc"}) + assert r.status_code == 422 + + +def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "nick@example.com") + for bad in ["0000", "1234", "aaaa", "qwerty", "password"]: + r = client.post("/auth/passcode/set", json={"passcode": bad}) + assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}" + + +# --------------------------------------------------------------------------- +# Auth me payload +# --------------------------------------------------------------------------- + + +def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + _sign_in_via_otc(client, "olga@example.com") + + me = client.get("/api/auth/me").json() + assert me["user"]["has_passcode"] is False + assert me["user"]["passcode_set_at"] is None + + client.post("/auth/passcode/set", json={"passcode": "secret123"}) + me = client.get("/api/auth/me").json() + assert me["user"]["has_passcode"] is True + assert me["user"]["passcode_set_at"] is not None diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a84b8c3..71d2cd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.8.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.8.0", + "version": "0.10.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 1fd3c57..ac2ab48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.8.0", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.js b/frontend/src/api.js index 18a710a..e63ef5a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -65,6 +65,49 @@ export async function submitBetaRequest({ first_name, last_name, beta_request_re return jsonOrThrow(res) } +// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ───────── +// +// After a successful OTC sign-in, a contributor may set a passcode and +// use email + passcode for subsequent sign-ins. OTC remains the +// forgot-passcode fallback — 5 consecutive verify failures locks the +// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected. + +export async function checkPasscode(email) { + // Anonymous endpoint. Returns `{has_passcode: boolean}` so the + // Login.jsx flow can decide whether to render a passcode input or + // fall back to OTC. We URL-encode the email so addresses with '+' + // round-trip cleanly. + const params = new URLSearchParams({ email }) + const res = await fetch(`/auth/passcode/check?${params}`) + return jsonOrThrow(res) +} + +export async function verifyPasscode(email, passcode) { + const res = await fetch('/auth/passcode/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, passcode }), + }) + return jsonOrThrow(res) +} + +export async function setPasscode(passcode) { + // Requires an active session — the server returns 401 if not signed + // in. The signed-in user is the implicit subject; the body carries + // only the new passcode. + const res = await fetch('/auth/passcode/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ passcode }), + }) + return jsonOrThrow(res) +} + +export async function clearPasscode() { + const res = await fetch('/auth/passcode', { method: 'DELETE' }) + return jsonOrThrow(res) +} + export async function listRFCs() { return jsonOrThrow(await fetch('/api/rfcs')) } diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index 7eb1ef3..f711397 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -1,43 +1,90 @@ -// Login.jsx — v0.7.0's primary sign-in surface (§6.2), extended by -// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile -// capture step. +// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0 +// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request +// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5) +// established the email + OTC scaffolding both releases extended. // -// Three-step (the third is conditional): -// 1. Enter email → POST /auth/otc/request → on 200, advance. -// On 429 (rate-limit), surface a "wait a moment" hint and keep -// the user on step 1. -// 2. Enter the six-digit code from the email → POST /auth/otc/verify -// → on 200, the response body carries `needs_profile`: -// * needs_profile=false (returning user, OAuth-era grandfather, -// or already-captured pending user): redirect to "/". -// * needs_profile=true (fresh OTC sign-in, no profile fields -// yet): advance to step 3. -// Cmd/Ctrl+Enter on the code field is the keyboard shortcut. -// 3. First name, last name, and "why I should be included in the -// beta" → POST /api/auth/me/beta-request → redirect to -// /beta-pending. The user's row stays `permission_state='pending'` -// until an admin grants access. +// Four-to-six-step flow (most users see three; the longest path is +// pending-user with no passcode, who never sees the passcode steps): // -// Server-side, /auth/otc/request returns 202 uniformly so abuse paths -// (e.g. distributed allowlist-probing) don't leak the recognized-email -// set. This surface never distinguishes "we couldn't reach you" from -// "we don't know you" — it just advances to step 2. If a request was -// rate-limited, the user sees a 429 hint and stays on step 1. +// 1. 'email' Enter email → GET /auth/passcode/check. +// * has_passcode=true → step 'passcode'. +// * has_passcode=false → POST /auth/otc/request, +// step 'code'. +// 429 on either dispatch surfaces a "wait a +// moment" hint and keeps the user on step 1. // -// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback -// during the migration; we surface a "Sign in with Gitea" link as a -// fallback in the footer so users with active OAuth sessions or older -// invite emails still have a path. +// 2a. 'passcode' Enter passcode → POST /auth/passcode/verify. +// * 200 → redirect to "/". +// * 423 (lockout, 5 consecutive failures) → +// auto-fall back to OTC by requesting a fresh +// code and advancing to step 'code'. +// * 400 → wrong passcode; user can retry or +// click "Use a code instead" to fall back +// manually. +// +// 2b. 'code' Enter the six-digit code → POST /auth/otc/verify. +// On 200, fetch /api/auth/me and branch: +// * needs_profile === true → 'capture-profile' +// * has_passcode === false → 'offer-passcode' +// * otherwise → redirect to "/". +// needs_profile WINS over has_passcode — a +// pending user goes through the §6.1 capture +// flow first; setting a passcode while waiting +// for admin grant gains them nothing. +// Cmd/Ctrl+Enter on the code field is the +// keyboard shortcut. +// +// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why +// I should be included in the beta" → POST +// /api/auth/me/beta-request → redirect to +// /beta-pending. The user's row stays +// permission_state='pending' until an admin +// grants access; they can set a passcode later +// from settings, or on a future sign-in once +// granted. +// +// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in +// next time?" Yes → 'set-passcode'. Skip → "/". +// +// 4b. 'set-passcode' Pick a passcode (4–20 chars) → POST +// /auth/passcode/set → redirect to "/". A +// "Skip for now" link also redirects to "/". +// +// Server-side, /auth/otc/request returns 202 uniformly and +// /auth/passcode/check returns has_passcode=false for an unknown +// email, so this surface never distinguishes "we couldn't reach you" +// from "we don't know you" — an unknown email always lands in the +// OTC path with no account-enumeration signal. +// +// The legacy Gitea OAuth callback remains at /auth/login → +// /auth/callback during the v0.7.0 migration; we surface a "Sign in +// with Gitea" link as a fallback in the footer so users with active +// OAuth sessions or older invite emails still have a path. We hide +// the fallback on 'capture-profile' so a half-captured pending user +// doesn't bail out into the OAuth path mid-form. import { useEffect, useRef, useState } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { requestOtc, verifyOtc, submitBetaRequest } from '../api' +import { + requestOtc, + verifyOtc, + submitBetaRequest, + checkPasscode, + verifyPasscode, + setPasscode as apiSetPasscode, +} from '../api' export default function Login() { + // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after + // verify) one of: 'capture-profile' (pending user), 'offer-passcode' + // (no passcode yet), or straight to "/". 'set-passcode' is reached + // from 'offer-passcode'. const [step, setStep] = useState('email') const [email, setEmail] = useState('') const [code, setCode] = useState('') - // v0.8.0 — step 3 capture fields. + const [passcode, setPasscode] = useState('') + const [newPasscode, setNewPasscode] = useState('') + // v0.8.0 — capture-profile fields. const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [reason, setReason] = useState('') @@ -45,13 +92,17 @@ export default function Login() { const [busy, setBusy] = useState(false) const emailRef = useRef(null) const codeRef = useRef(null) + const passcodeRef = useRef(null) + const newPasscodeRef = useRef(null) const firstNameRef = useRef(null) const navigate = useNavigate() useEffect(() => { if (step === 'email') emailRef.current?.focus() else if (step === 'code') codeRef.current?.focus() - else if (step === 'profile') firstNameRef.current?.focus() + else if (step === 'passcode') passcodeRef.current?.focus() + else if (step === 'capture-profile') firstNameRef.current?.focus() + else if (step === 'set-passcode') newPasscodeRef.current?.focus() }, [step]) async function submitEmail(e) { @@ -63,20 +114,72 @@ export default function Login() { setBusy(true) setStatus('') try { - await requestOtc(email.trim()) - setStep('code') - setStatus('Check your inbox — a six-digit code is on the way.') + const { has_passcode } = await checkPasscode(email.trim()) + if (has_passcode) { + setStep('passcode') + setStatus('') + } else { + await requestOtc(email.trim()) + setStep('code') + setStatus('Check your inbox — a six-digit code is on the way.') + } } catch (err) { if (err.status === 429) { setStatus('Slow down — wait a minute before requesting another code.') } else { - setStatus(err.message || 'Could not request a code. Try again.') + setStatus(err.message || 'Could not start sign-in. Try again.') } } finally { setBusy(false) } } + async function submitPasscode(e) { + if (e) e.preventDefault() + if (!passcode.trim()) { + setStatus('Enter your passcode.') + return + } + setBusy(true) + setStatus('') + try { + await verifyPasscode(email.trim(), passcode.trim()) + // Reload so App.jsx's getMe() picks up the fresh session. A + // returning passcode user is by definition already past the + // §6.1 capture step (they couldn't have set a passcode while + // pending), so we go straight to "/". + window.location.assign('/') + } catch (err) { + if (err.status === 423) { + // Lockout — auto-fall back to OTC. The OTC request endpoint + // is independent of the passcode lockout, so this lands a + // fresh code in the user's inbox immediately. + setPasscode('') + try { + await requestOtc(email.trim()) + setStep('code') + setStatus( + 'Too many failed attempts. We sent a one-time code to your email — use it to sign in.', + ) + } catch (e2) { + if (e2.status === 429) { + setStep('code') + setStatus( + 'Too many failed attempts. Wait a minute, then request a one-time code to sign in.', + ) + } else { + setStatus( + 'Too many failed attempts. Use the "Use a code instead" link to sign in via email.', + ) + } + } + } else { + setStatus('Wrong passcode. Try again, or use a one-time code instead.') + } + setBusy(false) + } + } + async function submitCode(e) { if (e) e.preventDefault() if (!code.trim() || code.trim().length !== 6) { @@ -86,22 +189,39 @@ export default function Login() { setBusy(true) setStatus('') try { - const result = await verifyOtc(email.trim(), code.trim()) - // v0.8.0 — a fresh OTC user lands in `permission_state='pending'` - // with no profile fields. The verify response now carries a - // `needs_profile` flag the server stamped from the row state; - // surface the capture form here instead of jumping straight to - // "/". The fallback path (no flag, e.g. an older backend - // before the migration ran) jumps to "/" as before. - if (result?.needs_profile) { - setStep('profile') + await verifyOtc(email.trim(), code.trim()) + // OTC verified — the server has signed in the user. Fetch the + // canonical /api/auth/me to decide where to land: + // * needs_profile → §6.1 capture (then /beta-pending). + // * no passcode → §6.2 offer-passcode (then /). + // * otherwise → /. + // needs_profile wins over has_passcode: a pending user can't yet + // do anything that benefits from faster sign-in, so we don't + // distract them with the passcode offer mid-admission. + const meResp = await fetch('/api/auth/me', { credentials: 'include' }) + let me = null + if (meResp.ok) { + try { + me = await meResp.json() + } catch (_) { + me = null + } + } + if (me?.needs_profile === true) { + setStep('capture-profile') setStatus('') setBusy(false) return } - // Reload so App.jsx's getMe() picks up the fresh session. We - // navigate to "/" via a hard load so any cached "anonymous" - // view state in memory is dropped cleanly. + if (me?.has_passcode === false) { + setStep('offer-passcode') + setStatus('') + setBusy(false) + return + } + // Either /me returned the granted-with-passcode shape, or the + // call failed but the session cookie is set — fall through to + // a hard reload so App.jsx re-fetches and renders accordingly. window.location.assign('/') } catch (err) { setStatus('That code is invalid or expired. Try again, or request a new code.') @@ -126,10 +246,10 @@ export default function Login() { last_name: ln, beta_request_reason: why, }) - // The user is still `permission_state='pending'`; bounce them - // to /beta-pending so the next thing they see is the - // "your request is in review" page. Hard-load so App.jsx - // re-fetches /api/auth/me and picks up the captured fields. + // Hard-load so App.jsx re-fetches /api/auth/me and picks up + // the captured fields. The user stays permission_state='pending' + // until an admin grants access — the next thing they should + // see is the "your request is in review" page. window.location.assign('/beta-pending') } catch (err) { setStatus(err.message || 'Could not submit your request. Try again.') @@ -137,6 +257,26 @@ export default function Login() { } } + async function submitNewPasscode(e) { + if (e) e.preventDefault() + const pc = newPasscode.trim() + if (pc.length < 4) { + setStatus('Passcode must be at least 4 characters.') + return + } + setBusy(true) + setStatus('') + try { + await apiSetPasscode(pc) + window.location.assign('/') + } catch (err) { + // 422 carries the validation message verbatim (denylist / + // length); surface it as-is so the user knows what to change. + setStatus(err.message || 'Could not set passcode. Try a different one.') + setBusy(false) + } + } + function onCodeKey(e) { // §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { @@ -144,12 +284,59 @@ export default function Login() { } } + function onPasscodeKey(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitPasscode(e) + } + } + + function onReasonKey(e) { + // §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from + // the reason textarea (the multi-line input that would otherwise + // swallow Enter as a newline). + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitProfile(e) + } + } + + function onNewPasscodeKey(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitNewPasscode(e) + } + } + function backToEmail() { setStep('email') setCode('') + setPasscode('') setStatus('') } + async function fallbackToOtc() { + // Manual "Use a code instead" from the passcode step. Same shape + // as the email-step OTC dispatch. + setBusy(true) + setStatus('') + try { + await requestOtc(email.trim()) + setPasscode('') + setStep('code') + setStatus('Check your inbox — a six-digit code is on the way.') + } catch (err) { + if (err.status === 429) { + setStatus('Slow down — wait a minute before requesting another code.') + } else { + setStatus(err.message || 'Could not request a code. Try again.') + } + } finally { + setBusy(false) + } + } + + function skipPasscodeOffer() { + window.location.assign('/') + } + return (
@@ -157,7 +344,8 @@ export default function Login() { {step === 'email' && (

- Enter your email. We'll send you a one-time code. + Enter your email. If you've set a passcode, you'll enter that + next; otherwise we'll send a one-time code.

)} + {step === 'passcode' && ( +
+

+ Enter the passcode for {email}. +

+ setPasscode(e.target.value)} + onKeyDown={onPasscodeKey} + placeholder="Your passcode" + required + disabled={busy} + /> +
+ + + +
+
+ )} {step === 'code' && (

@@ -211,7 +438,7 @@ export default function Login() {

)} - {step === 'profile' && ( + {step === 'capture-profile' && (

You're signed in. {import.meta.env.VITE_APP_NAME} is in private @@ -245,6 +472,7 @@ export default function Login() {