Release 0.10.0: user-set passcodes (OTC stays as fallback)
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) <noreply@anthropic.com>
This commit is contained in:
+140
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user