From 8f3a8ec33aacc65e2cc9f4a861a76078fe41630d Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 02:24:38 -0700 Subject: [PATCH] Release 0.10.0: user-set passcodes (OTC stays as fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful OTC sign-in, a contributor can set a passcode (4-20 characters, bcrypt-hashed) and use email + passcode for subsequent sign-ins. OTC remains the structural fallback: five consecutive failed verifies lock the passcode path for 15 minutes (HTTP 423), and a forgotten passcode is recovered by requesting a fresh code. Migration 015_passcode.sql adds four nullable columns to the users table; existing rows pass through as OTC-only and can opt into a passcode from a new Sign-in tab in /settings/notifications. The /login surface is extended to a five-step flow (email → either passcode or OTC code → optional post-OTC passcode offer → optional set-passcode). SPEC corrections per §19.3 rule 2: §6 names the three auth paths, §14.1 documents the stepped login flow, §17 lists the four new /auth/passcode/* endpoints, §19.2 surfaces four new candidates and refreshes the cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 140 +++++ SPEC.md | 186 ++++-- VERSION | 2 +- backend/app/api.py | 13 + backend/app/main.py | 78 +++ backend/app/passcode.py | 367 ++++++++++++ backend/migrations/015_passcode.sql | 52 ++ backend/tests/test_passcode_vertical.py | 532 ++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api.js | 43 ++ frontend/src/components/Login.jsx | 278 ++++++++- .../src/components/NotificationSettings.jsx | 159 ++++++ 13 files changed, 1794 insertions(+), 62 deletions(-) create mode 100644 backend/app/passcode.py create mode 100644 backend/migrations/015_passcode.sql create mode 100644 backend/tests/test_passcode_vertical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc362b..df546f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,146 @@ 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.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.13.0 — 2026-05-28 **Minor — schema migration required; new optional env vars.** This diff --git a/SPEC.md b/SPEC.md index df625f8..d1c23c1 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. ### 6.1 Four roles, each a strict superset of the one below @@ -1985,6 +2004,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 @@ -2645,6 +2688,38 @@ The follow-up session will refine this. A minimal starting set: failure modes collapse to a single generic message so a probing client cannot distinguish "you got the wrong code" from "we don't know this email" — the operator logs carry the distinction. +- `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. @@ -3624,19 +3699,18 @@ quiet-hours, per-user mute, unsubscribe, bounce webhook). migration.* When a fresh email lands at `/auth/otc/verify` with no matching `users.email` row, v0.7.0 provisions the row with `display_name = ` and no other identity - fields. A subsequent release (the roadmap item-#6 candidate) - is expected to add a one-shot profile-capture step on the - first-OTC sign-in: first name, last name, and a free-text - "why I want access" field that flows into the open beta-access - request queue (also item #6) that replaces the v0.3.0 - `allowed_emails` gate. The schema slot exists implicitly already - (`users.display_name` is updateable, the audit-log + permission- - events tables carry the freeform notes); the structural decision - is what gates the capture (modal on `/login` after verify? a - one-time redirect to `/welcome/profile`? a deferred banner on - the main view?) and how it interacts with the open-access - request flow that replaces the allowlist. Earns its session as - the v0.8.0 design pass. + fields. The roadmap item-#6 candidate (v0.8.0) is expected to + add a one-shot profile-capture step on the first-OTC sign-in: + first name, last name, and a free-text "why I want access" field + that flows into the open beta-access request queue (also item #6) + that replaces the v0.3.0 `allowed_emails` gate. The schema slot + exists implicitly already (`users.display_name` is updateable, + the audit-log + permission-events tables carry the freeform + notes); the structural decision is what gates the capture (modal + on `/login` after verify? a one-time redirect to `/welcome/profile`? + a deferred banner on the main view?) and how it interacts with + the open-access request flow that replaces the allowlist. Earns + its session as the v0.8.0 design pass. - **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.* v0.7.0 keeps `/auth/callback` functional and links to it as a "Sign in with Gitea (fallback)" affordance on the new `/login` @@ -3653,16 +3727,17 @@ quiet-hours, per-user mute, unsubscribe, bounce webhook). 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 @@ -3682,6 +3757,47 @@ quiet-hours, per-user mute, unsubscribe, bounce webhook). 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 54d1a4f..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.0 +0.10.0 diff --git a/backend/app/api.py b/backend/app/api.py index d7bc6c7..f45f06d 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -120,6 +120,17 @@ def make_router( user = auth.current_user(request) if user is None: return {"authenticated": False, "user": None} + # v0.10.0: surface a single `has_passcode` flag so the §6.2 + # settings tab can render "Set passcode" vs. "Change/Remove + # passcode" without a second round trip. The set-at timestamp + # rides along for the same reason. The hash itself is never + # exposed. + row = db.conn().execute( + "SELECT passcode_hash, passcode_set_at FROM users WHERE id = ?", + (user.user_id,), + ).fetchone() + 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": { @@ -129,6 +140,8 @@ def make_router( "email": user.email, "avatar_url": user.avatar_url, "role": user.role, + "has_passcode": has_passcode, + "passcode_set_at": passcode_set_at, }, } diff --git a/backend/app/main.py b/backend/app/main.py index a03673d..88d87ef 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() @@ -182,4 +192,72 @@ def _oauth_router(config) -> APIRouter: }, } + # --------------------------------------------------------------- + # 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 00068c0..71d2cd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.13.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.13.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 cdc9024..ac2ab48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.13.0", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.js b/frontend/src/api.js index d910ef6..258e64d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -49,6 +49,49 @@ export async function verifyOtc(email, code) { 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 ac0903d..58338c8 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -1,18 +1,31 @@ -// Login.jsx — v0.7.0's primary sign-in surface (§6.2). +// Login.jsx — v0.7.0's email + OTC sign-in surface (§6.2), extended +// in v0.10.0 with passcode sign-in (roadmap item #8). // -// Two-step: -// 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, redirect to the post-login landing. Cmd/Ctrl+Enter -// on the code field is the keyboard shortcut. +// Three-to-five-step flow: +// 1. Enter email → GET /auth/passcode/check. +// * If `has_passcode`: advance to step 'passcode'. +// * Otherwise: POST /auth/otc/request, advance to step 'code'. +// 2a. Step 'passcode': enter passcode → POST /auth/passcode/verify. +// * On 200: redirect to /. +// * On 423: passcode is locked (5 consecutive failures); the +// UI auto-falls back to OTC by requesting a fresh code. +// * On 400: wrong passcode; the user can retry or click +// "Use a code instead" to fall back to OTC manually. +// 2b. Step 'code' (the v0.7.0 path): enter the six-digit code → +// POST /auth/otc/verify → on 200, the server has signed in the +// user. If the user has no passcode set, we show step +// 'offer-passcode' inviting them to set one for faster sign-in +// next time. Dismiss skips to /; "Set passcode" advances to +// step 'set-passcode'. +// 3. Step 'set-passcode': enter a passcode → POST /auth/passcode/set +// → redirect to /. The user can also "Skip for now". // // Server-side, /auth/otc/request always returns 202 for an unrecognized // email (so the allowlist gate doesn't leak), so this surface never -// distinguishes "we couldn't reach you" from "we don't know you" — -// it just advances to step 2. If a user is genuinely blocked, the -// code never arrives. +// distinguishes "we couldn't reach you" from "we don't know you". The +// check endpoint also returns `has_passcode: false` for an unknown +// email — so an unknown email always lands in the OTC path, 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 @@ -21,21 +34,36 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { requestOtc, verifyOtc } from '../api' +import { + requestOtc, + verifyOtc, + checkPasscode, + verifyPasscode, + setPasscode as apiSetPasscode, +} from '../api' export default function Login() { + // Steps: 'email' → 'passcode' or 'code' → (after OTC verify) optional + // 'offer-passcode' → optional 'set-passcode'. The latter two only + // appear on the OTC path for accounts that don't yet have a passcode. const [step, setStep] = useState('email') const [email, setEmail] = useState('') const [code, setCode] = useState('') + const [passcode, setPasscode] = useState('') + const [newPasscode, setNewPasscode] = useState('') const [status, setStatus] = useState('') const [busy, setBusy] = useState(false) const emailRef = useRef(null) const codeRef = useRef(null) + const passcodeRef = useRef(null) + const newPasscodeRef = useRef(null) const navigate = useNavigate() useEffect(() => { if (step === 'email') emailRef.current?.focus() - else codeRef.current?.focus() + else if (step === 'code') codeRef.current?.focus() + else if (step === 'passcode') passcodeRef.current?.focus() + else if (step === 'set-passcode') newPasscodeRef.current?.focus() }, [step]) async function submitEmail(e) { @@ -47,20 +75,68 @@ 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()) + 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) { @@ -71,16 +147,43 @@ export default function Login() { setStatus('') try { await verifyOtc(email.trim(), code.trim()) - // 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. - window.location.assign('/') + // OTC verified. If the user has no passcode, offer to set one + // before redirecting. We re-read `has_passcode` from the server + // rather than caching the step-1 result because the user could + // have set a passcode in another tab between then and now. + const { has_passcode } = await checkPasscode(email.trim()) + if (has_passcode) { + window.location.assign('/') + } else { + setStep('offer-passcode') + setBusy(false) + } } catch (err) { setStatus('That code is invalid or expired. Try again, or request a new code.') setBusy(false) } } + 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') { @@ -88,12 +191,44 @@ export default function Login() { } } + function onPasscodeKey(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitPasscode(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 (
@@ -101,7 +236,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' && (

@@ -155,6 +330,63 @@ export default function Login() {

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

+ You're signed in. Want to set a passcode for faster sign-in + next time? You can always use a one-time code instead — and + you can change or remove the passcode from your settings. +

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

+ Pick a passcode (4–20 characters). You'll use it with your + email to sign in next time. +

+ setNewPasscode(e.target.value)} + placeholder="New passcode" + required + disabled={busy} + minLength={4} + maxLength={20} + /> +
+ + +
+
+ )} {status &&

{status}

}

Read the philosophy → diff --git a/frontend/src/components/NotificationSettings.jsx b/frontend/src/components/NotificationSettings.jsx index 4a20022..a272364 100644 --- a/frontend/src/components/NotificationSettings.jsx +++ b/frontend/src/components/NotificationSettings.jsx @@ -29,6 +29,9 @@ import { muteUser, searchUsers, getCookieConsent, + getMe, + setPasscode, + clearPasscode, } from '../api.js' import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js' @@ -50,11 +53,167 @@ export default function NotificationSettings({ viewer }) { +

) } +// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ────────── + +function SignInSection() { + // Source of truth for `has_passcode` and `passcode_set_at` is the + // /api/auth/me payload (v0.10.0 added both fields). We re-read after + // every mutation so the surface reflects what just landed. + const [me, setMe] = useState(null) + const [error, setError] = useState(null) + const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change' + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [savedNote, setSavedNote] = useState('') + + useEffect(() => { + getMe() + .then(payload => setMe(payload.user || null)) + .catch(e => setError(e.message)) + }, []) + + async function refresh() { + const payload = await getMe() + setMe(payload.user || null) + } + + async function save(e) { + if (e) e.preventDefault() + const pc = draft.trim() + if (pc.length < 4) { + setError('Passcode must be at least 4 characters.') + return + } + setBusy(true) + setError(null) + setSavedNote('') + try { + await setPasscode(pc) + setDraft('') + setMode('idle') + setSavedNote('Passcode saved.') + await refresh() + } catch (err) { + // 422 carries the validation message verbatim (denylist / + // length); surface it as-is so the user knows what to change. + setError(err.message || 'Could not save passcode. Try a different one.') + } finally { + setBusy(false) + setTimeout(() => setSavedNote(''), 2000) + } + } + + async function remove() { + if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) { + return + } + setBusy(true) + setError(null) + setSavedNote('') + try { + await clearPasscode() + setSavedNote('Passcode removed.') + await refresh() + } catch (err) { + setError(err.message || 'Could not remove passcode.') + } finally { + setBusy(false) + setTimeout(() => setSavedNote(''), 2000) + } + } + + if (!me) return + + const hasPasscode = !!me.has_passcode + + return ( + +
+ + Passcode:{' '} + {hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'} + +
+ {hasPasscode && me.passcode_set_at && ( +

Set on {me.passcode_set_at}.

+ )} + + {mode === 'idle' && ( +
+ {hasPasscode ? ( + <> + + + + ) : ( + + )} +
+ )} + + {(mode === 'set' || mode === 'change') && ( +
+ + + +
+ )} + + {savedNote &&

{savedNote}

} + {error &&

{error}

} +
+ ) +} + // ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ──────────── function PrivacyCookiesSection() {