From e58aa1b9960c9adfc1471623feffcb763bc0140b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 06:11:00 +0200 Subject: [PATCH] refactor: sign-in codes and registration invitations leave; the SSO has both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in is email → code at sso.pedshub.com, and new accounts come from an invitation link minted there, so the app's own code emails and invite codes recorded a path nobody can take. Gone: the login-code routes and their rate limiters, the invite admin API and card, the invite field on the register form, the "email me a code / use my password" choice on the sign-in screen (an email now leads straight to the password), both utility modules, and the invite-only setting. A migration drops login_codes and registration_invites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/api-reference.md | 25 +- docs/authentication.md | 83 +------ docs/database.md | 37 --- e2e/seed.js | 6 +- e2e/tests/auth-screen.spec.js | 15 +- .../1781100000000_drop-sign-in-codes.js | 15 ++ public/components/admin.html | 36 +-- public/index.html | 25 -- public/js/admin.js | 197 +--------------- public/js/auth.js | 88 +------- public/js/authFetch.js | 6 +- server.js | 17 -- src/routes/adminConfig.js | 61 +---- src/routes/auth.js | 125 +--------- src/utils/adminLockdown.js | 1 - src/utils/loginCodes.js | 150 ------------ src/utils/openapiRoutes.js | 15 +- src/utils/registrationInvites.js | 213 ------------------ test/backend-hardening.test.js | 82 +------ test/crypto-context-binding.test.js | 9 - test/login-codes.test.js | 96 -------- 21 files changed, 55 insertions(+), 1247 deletions(-) create mode 100644 migrations/1781100000000_drop-sign-in-codes.js delete mode 100644 src/utils/loginCodes.js delete mode 100644 src/utils/registrationInvites.js delete mode 100644 test/login-codes.test.js diff --git a/docs/api-reference.md b/docs/api-reference.md index cc0664ab..35fc939e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1574,7 +1574,7 @@ Get all application configuration settings. ### PUT /api/admin/config/:key -Update one application configuration setting. The key must match an allowed prefix: `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `registration_invite_only`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `clinical_assistant.`, or `my_resources.`. Anything else is rejected with 400. +Update one application configuration setting. The key must match an allowed prefix: `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `clinical_assistant.`, or `my_resources.`. Anything else is rejected with 400. Some keys are refused here even when allowed: `models.*` must go through the validated model endpoints, `feature.*` values must be `true` or `false`, and any key under lockdown returns 403. @@ -2050,18 +2050,6 @@ The signed-in user's own active sessions. `src/routes/sessions.js`. | `GET` | `/api/sessions` | | `DELETE` | `/api/sessions/:id` | -### Registration Invites (Admin) - -Invite codes for invite-only registration. `DELETE /invites/spent` clears codes that are used or expired-and-not-revoked; a live code is revoked first. `src/routes/adminConfig.js`. - -| Method | Path | -|---|---| -| `GET` | `/api/admin/invites` | -| `POST` | `/api/admin/invites` | -| `DELETE` | `/api/admin/invites/:id` | -| `POST` | `/api/admin/invites/:id/revoke` | -| `DELETE` | `/api/admin/invites/spent` | - ### Admin - Documentation Viewer Serves this `docs/` tree inside the Admin panel. `src/routes/adminDocs.js`. @@ -2092,12 +2080,9 @@ Per-workflow image generation settings. ### Authentication (additional) -`/api/auth/login-code/request` and `/api/auth/login-code/verify` are the -passwordless sign-in path: a 6-digit code is emailed, hashed at rest, valid -for 10 minutes, single use, 5 attempts. Password sign-in always remains -available, so a code that never arrives is never a lockout. The rest manage -2FA backup codes and password changes. `src/routes/auth.js`, -`src/utils/loginCodes.js`. +These manage 2FA backup codes and password changes for local accounts. +Sign-in codes are the SSO's (`sso.pedshub.com`), not the app's. +`src/routes/auth.js`. | Method | Path | |---|---| @@ -2105,8 +2090,6 @@ available, so a code that never arrives is never a lockout. The rest manage | `GET` | `/api/auth/2fa/backup-codes/count` | | `POST` | `/api/auth/change-password` | | `POST` | `/api/auth/check-password` | -| `POST` | `/api/auth/login-code/request` | -| `POST` | `/api/auth/login-code/verify` | Remaining endpoints not listed above are additional admin configuration, model/STT/TTS discovery and test calls, and the per-feature AI helpers (`/api/dont-miss`, `/api/suggest-codes`, `/api/generate-pe-narrative`, `/api/hospital-course-update`, `/api/hospital-course-clarify`, `/api/well-visit/note`, `/api/milestones-data`, `/api/user/features`, `/api/logs/client-error`, `/api/logs/client-event`, `/api/generated-images/:id`, `/api/image-jobs/:workflow`). diff --git a/docs/authentication.md b/docs/authentication.md index 1d908216..363d64e9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -103,8 +103,6 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID. |---|---| | `/api/*` general | 200 req / min / IP | | `/api/auth/login` | 10 / 15 min | -| `/api/auth/login-code/request` | 5 / hour | -| `/api/auth/login-code/verify` | 10 / 15 min | | `/api/auth/register` | 5 / hour | | `/api/auth/forgot-password` | 5 / hour | | `/api/auth/resend-verification` | 3 / 15 min | @@ -113,15 +111,6 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID. Limits are per-IP (`express-rate-limit`). A clinic behind a single NAT shares the bucket; increase or switch to per-user keying if that becomes a problem. -Requesting a sign-in code is limited more tightly than attempting one, because -each request sends mail to somebody else's address — the cost of abuse lands on -the mailbox owner, not the caller. `LOGIN_CODE_RATE_LIMIT_MAX` overrides it. - -These are separate limiters rather than covered by the `/api/auth/login` one: -Express matches `app.use` paths on segment boundaries, so `/api/auth/login` does -**not** match `/api/auth/login-code/...`. A new sign-in endpoint needs its own -entry or it has no limit at all. - ## Login enumeration resistance `/api/auth/login` returns `"Invalid credentials"` for: @@ -132,74 +121,12 @@ entry or it has no limit at all. `"Email not verified"` is still returned for unverified accounts — deemed a necessary UX tradeoff over perfect indistinguishability. -## Sign-in codes +## Sign-in codes and invitations -A six-digit code emailed to the address being signed in with, offered beside the -password rather than instead of it. The screen asks for the email first, then -shows both routes: the code depends on mail being delivered and the password -does not, so neither is allowed to be the only way in. - -`POST /api/auth/login-code/request` → `POST /api/auth/login-code/verify`. - -What makes it a front door rather than a weaker side entrance: - -| | | -|---|---| -| Storage | bcrypt hash only, in `login_codes` — a code read out of the database is not a working credential | -| Lifetime | 10 minutes | -| Reuse | single use, marked used **before** the session is issued so a replay cannot race it | -| Supersession | requesting a new code deletes the previous one | -| Guessing | 5 wrong attempts burn the code; six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries at one code | -| Two-factor | still applies — a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it | - -Generation uses rejection sampling on `crypto.randomBytes`, not modulo, which -would make low digits slightly likelier. - -`loginCodes.sweep()` clears codes more than a day past expiry. It is fire and -forget: housekeeping never fails a request. - -Verified end to end against the running server with SMTP configured: a code is -requested from the sign-in screen, the mail is sent, and typing the code returns -200 from `/login-code/verify` and enters the app. - -### Frontend note - -`public/js/authFetch.js` keeps an allowlist of `/api` paths callable with no -verified account owner, and rejects everything else **before it is sent**. A new -pre-auth endpoint must be added there or it fails as a "Connection error" with -no request ever leaving the browser. - -## Registration invitations - -`registration_invite_only` sits between open and closed registration: people may -register, but only with a code. It is subordinate to `registration_enabled` — with -registration disabled entirely, nobody can register, code or not. - -`registration_invites` holds codes that let someone register while -`registration_invite_only` is on. Only the hash is stored; the code is shown -once, at creation. - -Four states: `active`, `used`, `expired`, `revoked`. - -**Revoke** stops a live code and leaves the row, marked. **Delete** removes the -row, and is only permitted once the code can no longer be redeemed: - -```sql -(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) -``` - -Deleting a code that could still be redeemed takes it off the list without -taking it out of anybody's inbox: the holder keeps something that looks valid, -it quietly stops working, and nothing is left to say who had it. Revoked rows -are kept because revoking records a decision somebody took. - -The condition is written to match the status the admin list displays. The -simpler `used OR expires_at <= NOW()` would also catch a revoked code whose date -had since passed — a row the screen still labels revoked and offers no delete -on, so button and query would disagree about the same row. - -`DELETE /api/admin/invites/spent` clears them in bulk under the same rule. It is -declared **before** `/invites/:id` or Express reads `spent` as an id. +Both live at the SSO (`sso.pedshub.com`, Authentik) rather than in this app: +sign-in is email → code, and new accounts come from an invitation link minted +with `authentik-pedshub/invite.py`. The app's own sign-in codes and +registration invites were removed once sign-in became SSO-only. ## Turnstile (Cloudflare bot protection) diff --git a/docs/database.md b/docs/database.md index 1e1b973f..4246dc13 100644 --- a/docs/database.md +++ b/docs/database.md @@ -306,43 +306,6 @@ Saved diagrams: the Mermaid source plus the user's own notes. | title, source, notes | TEXT NOT NULL | | | created_at, updated_at | TIMESTAMPTZ NOT NULL | | -### `login_codes` - -Passwordless sign-in codes. Only the bcrypt hash is stored, so a code read out -of the database is not a working credential. Ten-minute TTL, single use -(`used_at`), five attempts (`attempts`). See -[`authentication.md`](authentication.md). - -| Column | Type | Notes | -|---|---|---| -| id | SERIAL PK | | -| user_id | INTEGER NOT NULL | | -| code_hash | TEXT NOT NULL | bcrypt | -| attempts | INTEGER NOT NULL | Refused at 5 | -| expires_at | TIMESTAMPTZ NOT NULL | 10 minutes | -| used_at | TIMESTAMPTZ | Set once; a used code never verifies again | -| created_at | TIMESTAMPTZ NOT NULL | | - -### `registration_invites` - -Invite codes for invite-only registration. Only the hash is stored; `code_hint` -is the fragment shown in the admin list so a code can be recognised without -being recoverable. - -A code is *spent* when `used_at IS NOT NULL`, or when it has expired and was not -revoked. `DELETE /api/admin/invites/spent` deletes exactly that set — a live -code has to be revoked before it can be removed, so no code disappears while it -could still be redeemed. - -| Column | Type | Notes | -|---|---|---| -| id | SERIAL PK | | -| code_hash | TEXT NOT NULL | | -| code_hint | TEXT NOT NULL | Display fragment only | -| note | TEXT NOT NULL | Why it was issued | -| created_by, used_by, revoked_by | INTEGER | User ids | -| created_at, expires_at | TIMESTAMPTZ NOT NULL | | -| used_at, revoked_at | TIMESTAMPTZ | | ### `user_phone_extensions` diff --git a/e2e/seed.js b/e2e/seed.js index acbbc5cb..9361ceab 100644 --- a/e2e/seed.js +++ b/e2e/seed.js @@ -70,10 +70,8 @@ var SETTINGS = { 'stt.model': 'e2e-stt', 'tts.model': 'e2e-tts', 'tts.voice': 'e2e-voice', - // Registration closed and invite-only, which is what production runs and - // what the auth-screen spec asserts the sign-in page reflects. - 'registration_enabled': 'true', - 'registration_invite_only': 'true' + // Registration open, so the auth-screen spec can see the register link. + 'registration_enabled': 'true' }; async function seedSettings() { diff --git a/e2e/tests/auth-screen.spec.js b/e2e/tests/auth-screen.spec.js index 90cda26d..dc395082 100644 --- a/e2e/tests/auth-screen.spec.js +++ b/e2e/tests/auth-screen.spec.js @@ -22,41 +22,34 @@ test.describe('Unauthenticated auth screen', () => { await expect(page.locator('#btn-login-continue')).toBeVisible(); // Later steps are present but not yet offered. await expect(page.locator('#login-password')).toBeHidden(); - await expect(page.locator('#login-code')).toBeHidden(); await expect(page.locator('#btn-local-login')).toBeHidden(); // main app body must be hidden while unauthenticated await expect(page.locator('#main-app')).toBeHidden(); }); - test('an email leads to the choice, and choosing a password reveals it', async ({ page }) => { + test('an email leads straight to the password', async ({ page }) => { await page.goto(E2E_BASE + '/'); await page.waitForSelector('#auth-screen', { timeout: 10000 }); await page.fill('#login-email', 'someone@ped-ai.test'); await page.click('#btn-login-continue'); - await expect(page.locator('#login-choice')).toBeVisible(); // The address is fixed once the flow has moved past it; "use a different // email" is how you go back, and it only appears after the first step. await expect(page.locator('#login-email')).toHaveJSProperty('readOnly', true); await expect(page.locator('#login-change-email')).toBeVisible(); - - await page.click('#btn-login-use-password'); await expect(page.locator('#login-password')).toBeVisible(); await expect(page.locator('#btn-local-login')).toBeVisible(); }); test('the register link follows the registration setting', async ({ page }) => { - // Hidden by default and shown only when registration is enabled. The seed - // sets registration_enabled true and invite_only true, which is what - // production runs: registration is open, but a code is required — so the - // link shows and the invite field is required. + // Hidden by default and shown only when registration is enabled, which the + // seed turns on. No invitation field: invitations are the SSO's. await page.goto(E2E_BASE + '/'); await page.waitForSelector('#auth-screen', { timeout: 10000 }); await expect(page.locator('#show-register')).toBeVisible(); await expect(page.locator('#register-form')).toHaveCount(1); await page.click('#show-register'); - await expect(page.locator('#reg-invite')).toBeVisible(); - await expect(page.locator('#reg-invite')).toHaveJSProperty('required', true); + await expect(page.locator('#reg-invite')).toHaveCount(0); }); test('register form DOM is wired correctly if manually unhidden', async ({ page }) => { diff --git a/migrations/1781100000000_drop-sign-in-codes.js b/migrations/1781100000000_drop-sign-in-codes.js new file mode 100644 index 00000000..70e98422 --- /dev/null +++ b/migrations/1781100000000_drop-sign-in-codes.js @@ -0,0 +1,15 @@ +// Sign-in codes and registration invitations are gone. Sign-in and sign-up +// happen at the SSO (sso.pedshub.com), which emails its own codes and issues +// its own invitation links, so these two tables recorded a path nobody can +// take any more. The invite-only switch goes with them. + +exports.up = pgm => pgm.sql(` + DROP TABLE IF EXISTS login_codes; + DROP TABLE IF EXISTS registration_invites; + DELETE FROM app_settings WHERE key = 'registration_invite_only'; +`); + +// The tables can be recreated by the migrations that introduced them; the +// codes they held were single-use and short-lived, so there is nothing to +// restore. +exports.down = () => {}; diff --git a/public/components/admin.html b/public/components/admin.html index bb2ab01e..c73cc848 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -19,11 +19,9 @@

Accounts

- +

Registration

@@ -32,34 +30,6 @@
-
- Invite only -
- -
-
- -
- -
- - - -
-
-

Codes stay readable here so you can copy one again — an invitation usually has to be given to somebody later than the moment it was made. A code that has been used, revoked or expired can be deleted.

-
- -
- -
- -
diff --git a/public/index.html b/public/index.html index c3eea3a2..5eea6cc9 100644 --- a/public/index.html +++ b/public/index.html @@ -70,28 +70,8 @@ - - - - - - -