Identity security
Hardening layered on top of Identity and authentication. Everything is Postgres-only (no Redis).
Password policy
One validator, app.core.security.validate_password(password, *, email=None): 12 to 256
characters, not in a small common-password denylist (trailing digits/punctuation are ignored, so
Password123! is rejected), not repetitive, not equal to the email local part. It applies when a
password is set (first set, change, reset, admin create/patch) - never at login, so older
weaker passwords (the seeded dev password) keep working until changed. Violations are 400;
a too-short value in a request body is 422.
Lockout
5 failed password logins in 15 minutes lock the account for 15 minutes
(LOCKOUT_MAX_FAILURES, LOCKOUT_WINDOW_MINUTES, LOCKOUT_DURATION_MINUTES in settings).
Unknown email, wrong password, locked and deactivated accounts all return the same 401 Invalid email or password after the same work (a password verify), so message and timing do not
reveal which it was. Failures are counted in rate_limit_hits (bucket login_failed) and mirrored in
users.failed_login_count; success, a password reset or authentication.unlock_user(db, user, actor=...) clears them. A successful login stamps last_login_at / last_login_ip.
Rate limits
app/core/rate_limit.py: sliding window over the rate_limit_hits table, serialised per key with
an advisory lock; over the limit answers 429 with Retry-After. Old rows are purged
opportunistically. Defaults (RATE_LIMIT_<NAME>_LIMIT / _WINDOW, RATE_LIMIT_ENABLED=false
disables all):
| Bucket | Applies to | Default |
|---|---|---|
login_ip, login_email | /auth/login | 50 / 5 min per IP, 10 / 15 min per email |
refresh | /auth/refresh | 120 / min per IP |
subscriber_request, subscriber_verify | subscriber OTP | 10 and 20 / 10 min per IP |
password_reset_request_ip, _email | reset request | 10 and 3 / hour |
password_reset_confirm | reset confirm | 10 / 15 min per IP |
mfa_verify | /auth/mfa/verify | 15 / 5 min per IP |
reauth | password / MFA step-up in /me/* | 10 / 15 min per user |
The client IP comes from the socket; set TRUST_PROXY_HEADERS=true only behind a proxy that
overwrites X-Forwarded-For (first hop is used). Behind a proxy without it every user shares one IP.
Request context and audit
RequestContextMiddleware honours a valid X-Request-ID (else generates one) and echoes it on the
response; client IP and user agent are available through app.core.request_context. Every
record_audit row now stores ip, user_agent, request_id.
Authentication events (audit_service.record_auth_event) are rows with domain auth, entity = the
user, tag auth, tenant_id NULL, and payload.reason where relevant. Actions: login_success,
login_failure (reason: bad_password, locked, inactive), logout, refresh_reuse,
password_changed, password_reset_requested, password_reset, session_revoked,
mfa_enrolled, mfa_disabled, mfa_failed, account_locked, account_unlocked. A failed login
for an unknown email writes no row (it is logged). Users read their own via
GET /me/security/activity.
Sessions
A session is a refresh-token family. Login/refresh stamp ip, user_agent, last_seen_at;
family_created_at survives rotation. GET /me/sessions, DELETE /me/sessions/{id} (the family id),
DELETE /me/sessions (all others), POST /me/password (keeps the current session). Admins use
sessions_service.revoke_all_sessions(db, user, actor=...).
Password reset
POST /auth/password-reset/request always answers 202 {"ok": true} (no enumeration, 60 s
cooldown, email sent in the background). The emailed link is
{DASHBOARD_URL}/reset-password?token=...; the token is single use, valid 1 hour, stored hashed.
POST /auth/password-reset/confirm applies the policy, revokes every refresh token, clears any
lockout and is audited. Works for every role, subscribers included.
MFA (TOTP)
Optional for every role. Enrol (POST /me/mfa/enroll returns the secret and otpauth:// URI; the
secret is stored Fernet-encrypted with MFA_ENCRYPTION_KEY, derived from JWT_SECRET in local,
required elsewhere), then POST /me/mfa/confirm {code} enables it and returns 10 one-time recovery
codes (shown once, stored as HMACs). With MFA on, POST /auth/login returns 200 {ok: false, mfa_required: true, mfa_token} and no cookies; POST /auth/mfa/verify {mfa_token, code | recovery_code} completes the login. The mfa_token is a 5-minute JWT with its own audience (it
cannot be used as an access token), single use through mfa_challenges, dead after 5 wrong codes.
The emailed subscriber code flow also yields an mfa_token for MFA users. Disable and
recovery-code regeneration need the password plus a code; mfa_service.admin_reset force-disables.
SSO extension point
app/services/identity_providers.py defines the IdentityProvider protocol, an (empty) registry
and link_or_login_external(db, provider, profile): it finds a user by (provider, subject) in
user_identities, or links a provider-verified email to an existing active user. It never
creates users. A future route calls authentication.finish_login(db, user, method="sso:<name>")
(which honours MFA). GET /auth/providers lists registered providers (empty today).