Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The README's feature list never mentioned accounts at all. It now says how somebody signs in — password or an emailed code, side by side — that passwords are argon2id with bcrypt rows rehashed on next sign-in, and that registration can be open, closed or invite-only with codes that are revocable while live and deletable only once spent. The browser-STT note now says why the Web Speech gate exists rather than only that it exists: Chrome and Edge send that audio to Google, so it is off unless a user turns it on. And the one thing that could not be demonstrated before now can. With SMTP configured, a real send returns true, and driving the sign-in screen in a browser — request a code, type it — returns 200 from /login-code/verify and enters the app. docs/authentication.md records that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
290 lines
13 KiB
Markdown
290 lines
13 KiB
Markdown
# Authentication & security
|
|
|
|
## Password hashing
|
|
|
|
- Primary: **argon2id**, memory cost 19 MiB, time cost 2, parallelism 1
|
|
(OWASP 2023 recommended profile).
|
|
- Fallback: **bcryptjs** (12 rounds) for legacy rows.
|
|
- Transparent migration: on successful login against a bcrypt hash, the
|
|
password is rehashed as argon2id and the row updated. Users migrate without
|
|
any action.
|
|
- The `argon2` package is loaded optionally — if not installed, registration
|
|
and password changes fall back to bcrypt without breaking.
|
|
|
|
## Token transport
|
|
|
|
Hybrid, chosen at request time by `src/utils/platform.js` based on User-Agent
|
|
and optional `X-Client` header:
|
|
|
|
| Client | Transport | Storage | JWT lifetime |
|
|
|---|---|---|---|
|
|
| Web browser | `ped_auth` httpOnly + `sameSite=lax` cookie | — (no client storage) | 30 d (sliding 24 h idle enforced server-side) |
|
|
| Capacitor app | `Authorization: Bearer <jwt>` | iOS Keychain / Android EncryptedSharedPreferences | 365 d (no idle check) |
|
|
|
|
`authMiddleware` reads Bearer first, falls back to cookie. An empty Bearer
|
|
string falls through to cookie parsing — fixes clients that always emit the
|
|
header.
|
|
|
|
## Session table
|
|
|
|
`user_sessions` is the authoritative source. Each row holds `token_hash`
|
|
(SHA-256 of the JWT), `user_id`, `ip_address`, `device_label`, `last_activity`.
|
|
|
|
Middleware on every authenticated request:
|
|
|
|
1. Verify JWT signature and expiry.
|
|
2. Look up `token_hash` in `user_sessions`. If missing and the user has any
|
|
other sessions → 401 "Session revoked". No sessions at all → fail open
|
|
(pre-migration users).
|
|
3. Compute idle (`NOW() - last_activity`).
|
|
- Web (`!isMobileClient`): if idle > 24 h → delete the session row, clear
|
|
cookie, return 401 with `idleTimeout: true`.
|
|
- Mobile: skip idle check.
|
|
4. On POST / PUT / DELETE / PATCH only, if idle > 10 min (throttle), update
|
|
`last_activity = NOW()` and re-set the cookie with a fresh 30-day maxAge
|
|
(cookie slides with activity). GET / HEAD do NOT extend the session —
|
|
prevents polling from defeating the idle policy.
|
|
|
|
Idle-timeout kicks write an `audit_log` entry with
|
|
`action='session_idle_timeout'` and the minute count, plus a `console.warn`
|
|
for Loki.
|
|
|
|
## Two-factor authentication
|
|
|
|
TOTP via `speakeasy`, 30-second step, verification window ±1 step.
|
|
|
|
### Backup codes
|
|
|
|
- Generated automatically on first 2FA enable (10 codes, 10 characters,
|
|
`XXXXX-XXXXX` format, excluded-characters alphabet: no `0/O/1/I`).
|
|
- Stored as bcrypt hashes in `users.totp_backup_codes` (JSON array).
|
|
- Consumed atomically on login via `SELECT … FOR UPDATE` transaction — race
|
|
between parallel attempts serializes correctly, a code can only succeed once.
|
|
- `POST /api/auth/2fa/backup-codes` regenerates the full set (requires current
|
|
password). `GET /api/auth/2fa/backup-codes/count` returns remaining count.
|
|
- Consumed codes are also logged in `audit_log` (`2fa_backup_code_used`).
|
|
- Cleared when 2FA is disabled.
|
|
|
|
## OIDC (Authorization Code + PKCE)
|
|
|
|
- Implemented with `openid-client`.
|
|
- State + PKCE verifier + nonce are bundled into an HMAC-signed token
|
|
(signed with `JWT_SECRET`) — stateless, survives restarts and scales
|
|
horizontally. 5-minute TTL.
|
|
- SSRF guard: issuer URL must use `https://` and not resolve to any private /
|
|
loopback / link-local IP. Blocks attacks like issuer set to
|
|
`http://169.254.169.254/` (AWS metadata).
|
|
- First-time link: requires `email_verified: true` claim from the IdP.
|
|
Missing or false → 401 with `error=email_unverified`. Prevents an
|
|
unverified-email SSO account from taking over an existing local account.
|
|
- Already-linked users with a DIFFERENT `oidc_sub` are refused
|
|
(`error=sub_mismatch`).
|
|
- Auto-create on first SSO: new user row, `email_verified=true`, password
|
|
column holds a random 32-byte hex string (not a hash). `canLocalAuth=false`
|
|
hides password/2FA/sessions UI for these users. Server-side endpoints
|
|
(`/change-password`, `/setup-2fa`) also reject with an SSO-aware message.
|
|
|
|
Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID.
|
|
|
|
## Logout and cross-tab sync
|
|
|
|
- `POST /api/auth/logout` deletes the current session row and clears the
|
|
cookie.
|
|
- Frontend broadcasts `{type:'logout'}` on `BroadcastChannel('pedscribe-auth')`;
|
|
sibling tabs drop UI and reload.
|
|
- `authFetch.js` installs a global `fetch` interceptor; any 401 on an
|
|
authenticated `/api/*` request triggers the same logout path.
|
|
- Service-worker caches are cleared on every logout (`caches.keys()` →
|
|
`caches.delete`).
|
|
|
|
## Rate limits
|
|
|
|
| Endpoint | Limit |
|
|
|---|---|
|
|
| `/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 |
|
|
| `/api/auth/change-password`, `/setup-2fa`, `/verify-2fa`, `/disable-2fa` | 20 / 15 min |
|
|
|
|
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:
|
|
- unknown email (runs a bcrypt compare against a fixed dummy hash to equalize timing)
|
|
- wrong password
|
|
- disabled account
|
|
|
|
`"Email not verified"` is still returned for unverified accounts — deemed a
|
|
necessary UX tradeoff over perfect indistinguishability.
|
|
|
|
## Sign-in codes
|
|
|
|
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_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.
|
|
|
|
## Turnstile (Cloudflare bot protection)
|
|
|
|
Applied to `/api/auth/register` and `/api/auth/forgot-password` when
|
|
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
|
|
|
|
`/api/auth/login` is deliberately **not** gated: the widget could not
|
|
reliably complete a challenge inside the Capacitor WebView, which locked
|
|
mobile users out of the app. Login is covered instead by its per-IP rate
|
|
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
|
|
|
|
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
|
|
the first time their form becomes visible — Turnstile does not reliably
|
|
complete a challenge inside a `display:none` container, and both forms start
|
|
hidden. Tokens are captured from the render callback, not read back out of
|
|
the injected `[name="cf-turnstile-response"]` input.
|
|
|
|
Note that the site key is currently **hardcoded** in `public/index.html`.
|
|
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
|
|
|
|
## Encryption at rest
|
|
|
|
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
|
|
`DATA_ENCRYPTION_KEY` env var (64 hex chars = 32 bytes; any other string is
|
|
SHA-256-derived with a warning). In production mode the server refuses to
|
|
start without it.
|
|
|
|
| Data | Encryption |
|
|
|---|---|
|
|
| Nextcloud access tokens (`users.nextcloud_token`) | AES-256-GCM via `encryptString`; legacy plaintext rows are detected and re-encrypted on next use |
|
|
| Audio backups (`audio_backups.audio_data`) | Gzipped, then AES-256-GCM with a `0x01` version byte prefix; legacy rows (no prefix) pass through unchanged |
|
|
| PHI in audit details | Redacted via `src/utils/redact.js` (SSN, phone, email, DoB regex patterns; 500-char cap; note-body heuristic truncation) before insert |
|
|
|
|
## HTTP security headers
|
|
|
|
Helmet defaults plus:
|
|
|
|
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
|
- Content-Security-Policy:
|
|
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
|
(do not add `unsafe-eval` unless a reviewed dependency requires it)
|
|
- `script-src-attr 'none'` (blocks inline event handlers)
|
|
- `frame-src 'self' challenges.cloudflare.com`
|
|
- `object-src 'none'`
|
|
- `X-Content-Type-Options: nosniff`
|
|
- Response bodies on 5xx use generic `'Request failed'`; full error stays
|
|
server-side in `logger.error` / Loki.
|
|
|
|
## File uploads
|
|
|
|
`src/routes/documents.js` accepts document uploads after:
|
|
1. Extension / MIME check.
|
|
2. Magic-byte sniff via `src/utils/fileType.js` — refuses mismatches (e.g., a
|
|
`.jpg` with a PHP payload).
|
|
|
|
## CORS
|
|
|
|
- Production (`NODE_ENV=production` or `APP_URL` set): refuses to start if
|
|
neither `APP_URL` nor `CORS_ORIGINS` is configured.
|
|
- Origin whitelist = union of `APP_URL` and comma-separated `CORS_ORIGINS`.
|
|
- Requests with no Origin header always pass (mobile, curl, server-to-server).
|
|
- `credentials: true` so the cookie travels on cross-origin web requests
|
|
from permitted origins.
|
|
|
|
## Roles
|
|
|
|
| Role | Access |
|
|
|---|---|
|
|
| `admin` | Everything. First registered user auto-promoted. |
|
|
| `moderator` | Learning Hub CMS + standard user features. |
|
|
| `user` | Clinical features, no admin routes. |
|
|
|
|
## Audit logging
|
|
|
|
Every auth-adjacent event is written to `audit_log` via a batched writer
|
|
(`src/utils/auditQueue.js`) — 1-second flush interval or 50-entry batch.
|
|
Drained on SIGTERM before pool close. Sent to Loki in parallel (fire-and-forget).
|
|
|
|
Common `action` values: `register`, `login`, `login_failed`, `login_blocked`,
|
|
`login_oidc`, `logout`, `email_verified`, `password_changed`,
|
|
`password_reset`, `2fa_enabled`, `2fa_backup_code_used`,
|
|
`2fa_backup_codes_regenerated`, `oidc_linked`, `session_idle_timeout`.
|
|
|
|
## Maintenance
|
|
|
|
`scripts/maintenance.js`:
|
|
- `npm run maint:check` — reports collation drift, row counts, index list
|
|
- `npm run maint:reindex` — `REINDEX DATABASE` + `ALTER DATABASE … REFRESH COLLATION VERSION` + `ANALYZE`
|
|
|
|
Run after any Postgres image upgrade. The startup drift check runs this
|
|
automatically when `pg_database.datcollversion` diverges from the library's
|
|
actual version.
|