Skip to main content

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):

BucketApplies toDefault
login_ip, login_email/auth/login50 / 5 min per IP, 10 / 15 min per email
refresh/auth/refresh120 / min per IP
subscriber_request, subscriber_verifysubscriber OTP10 and 20 / 10 min per IP
password_reset_request_ip, _emailreset request10 and 3 / hour
password_reset_confirmreset confirm10 / 15 min per IP
mfa_verify/auth/mfa/verify15 / 5 min per IP
reauthpassword / 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).