auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1 step) with node:crypto only, checked against the RFC's test vectors. Migration 0003: users.totp_recovery_hashes and totp_last_step. Admins turn it on from My account (the header's "Account"): scan the QR or type the key, confirm a code, get 10 recovery codes once. Login asks for the code only after the password is right (401 totp_required, no session); a wrong code is a failed login (rate limit, audit). A step signs in once (totp_last_step updated atomically) and a recovery code once (array_remove guarded by any()). Off and new codes need the password; another admin can turn it off for a lost phone, which signs that user out. Users list shows it (twoStep). Also, found by the full e2e chain on one shared database: - Linphone tokens: only the exact token issued is accepted. The last base64 character can carry ignored bits, so another spelling decoded to the same signed token. - Test scripts no longer assume a fresh system: m3-t3 picks Dan in the test box, m8-check dials whatever slot the park took (findslot = next), m10-t4 uses the admin's existing extension, m11-t1 clears the public IP on its own first (local nets are in the transports while one is set, and transports change only on a restart). e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes. Task: M11-T1
This commit is contained in:
1 parent
65e777ab41
commit
8c750c67ca
31 files changed
+4842
-43
No files matched your search
@@ -73,6 +73,8 @@ create table users (
|
||||
role user_role not null default 'user',
|
||||
is_active boolean not null default true,
|
||||
totp_secret_enc text, -- null = 2FA off (M11)
|
||||
totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
|
||||
totp_last_step bigint, -- last 30 s step signed in with: no replay (M11)
|
||||
must_change_password boolean not null default false,
|
||||
last_login_at timestamptz
|
||||
);
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
- Minimum 10 characters. Reject the 1,000 most common passwords (bundle a list in `auth/common-passwords.txt`).
|
||||
|
||||
### 2.2 Sessions (browser)
|
||||
- `POST /api/auth/login {email, password, totp?}` → on success, create a 32-byte random
|
||||
- `POST /api/auth/login {email, password, totp?}` (`totp`: the code or a recovery code; when 2FA is on
|
||||
and it's missing, 401 `totp_required`; wrong, 401 `totp_invalid`; M11-T1; also `GET /api/auth/totp`,
|
||||
`POST /api/auth/totp/setup|enable|disable|recovery-codes`, 08 §2.4) → on success, create a 32-byte random
|
||||
token. Store `sha256(token)` in `sessions.id` and set the cookie
|
||||
`sp_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600` (14 days).
|
||||
- Sliding expiry: if a session has less than 7 days left when used, extend it to 14 days.
|
||||
|
||||
@@ -51,6 +51,14 @@ aren't filtered. The setting is on Settings → Security, with the provisioning
|
||||
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
|
||||
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
|
||||
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
|
||||
- As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test):
|
||||
opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one
|
||||
code, get 10 recovery codes once (`k7m3q-x9tpw`; case and dashes don't matter). Login asks for
|
||||
the code only after the password is right (401 `totp_required`, no session); a wrong code is a
|
||||
failed login (rate limit, audit). Each 30 s step signs in once (`users.totp_last_step`, updated
|
||||
atomically) and each recovery code once (`array_remove` with an `any()` guard). Turning it off or
|
||||
making new codes needs the password. Another admin can turn it off for a lost phone
|
||||
(`POST /api/users/:id/reset-two-step`, signs that user out). `scripts/e2e/m11-t1-totp.mjs`.
|
||||
- As built (M11-T1): the headers come from an `onSend` hook on every response (UI, API, `/prov`);
|
||||
`connect-src` also allows `WEBRTC_WS_URL`'s origin when it's set. The dark-mode bootstrap moved
|
||||
from an inline `<script>` in `index.html` to `/theme.js`, so `script-src` stays `'self'`.
|
||||
|
||||
Reference in new issue
Block a user