diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 2d947be..2346b19 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -142,6 +142,20 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`, | ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) | | created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | +### password_resets — self-service reset links +| col | type | notes | +|---|---|---| +| id | INT PK AUTO_INCREMENT | | +| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** | +| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets | +| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) | +| requested_ip | VARCHAR(64) NULL | who asked (audit only) | +| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this | +| created_at / used_at | DATETIME | | + +Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`. +A DB read never yields a usable reset link. See §4 `/auth/password/*`. + --- ## 4. API contract @@ -154,10 +168,17 @@ accepts `Authorization: Bearer` for API testing). |---|---|---|---|---| | POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` | | POST | `/logout` | cookie | — | clear cookie | -| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state | +| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state | +| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. | +| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) | +| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. | -No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further -admins are created under `/admin/users`. +**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token +whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It +also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The +reset link points at the web front end (`/account/reset/:token`); the Android app hands off here +rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped +by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites. ### /public (public.routes.js → public.controller.js) — all GET, no auth | Method | Path | Notes |