feat(auth): suspicious-login notify-and-approve gate (BUNYIP-373) #372
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/BUNYIP-373-suspicious-login-approval"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements the suspicious-login notify-and-approve gate for bunyip (BUNYIP-373), the OP-side counterpart of mokosh's PMS-658. When enabled, a login that looks suspicious withholds tokens and emails a single-use 6-digit code; the user re-submits the code to finish signing in, mirroring the existing 2FA challenge handshake. This is a notify-and-approve gate, not an account lock.
Two commits:
ac330a4(foundation: migration, challenge JWT, config flag) and9256804(the domain gate + handler + email + tests).What triggers the gate
For a non-2FA login, suspicious = new sign-in country (reuses the BUNYIP-366
login_location_decision+ IP2Location resolution) OR new device (a client-supplied stabledevice_id, hashed, absent from the user's known devices). Gates the password and magic-link paths.Flow
login/verify_magic_linkcompute the risk just before minting tokens. If flagged, no tokens are issued: a 6-digit code (SHA-256 hashed, 15-minute TTL, single-use, 5-attempt cap) is stored against a short-livedlogin_approvalchallenge JWT and emailed, and the response returns{ requires_approval: true, challenge_token }. The client POSTs{ challenge_token, code }to the new/auth/login-approval/verify, which verifies the code, mints tokens exactly as a normal login, and records the approved country + device as the new baseline.Design decisions
assess_loginswallows every lookup error and returns "not flagged", so infrastructure trouble can never lock a user out.LOGIN_APPROVAL_ENABLEDdefaults off. With it off the feature is completely inert (no device tracking, no challenges), so login behaviour is byte-for-byte unchanged for current deployments. This is the deliberate PMS-289 lesson: a block-capable auth change ships opt-in for a staged rollout.New surface
login_approval_codes+login_devices(not RLS-scoped: written pre-auth likemagic_link_tokens).TokenRepositorymethods for both;AuthService::assess_login/begin_login_approval/record_login_device/complete_login_approval;LoginResult/MagicLinkResultgain anApprovalRequiredvariant.EmailService::send_login_approval_code+login_approval.{html,txt}Tera templates.device_idadded to the login + magic-link-verify request bodies (optional; absent = country-only signal).Testing
just check-containergreen (fmt + clippy-D warnings+cargo test --workspace --all-targets). Addsbunyip-api/tests/login_approval.rs, env-gated onRLS_TEST_DATABASE_URL(skips in CI, which has no Postgres) covering: the new-device gate (baseline silent, second device withheld, known device passes), the kill-switch-off no-op, and the code-verify / baseline-record / single-use round trip.Lays the safe, inert foundation for the suspicious-login notify-and-approve gate (the bunyip equivalent of mokosh PMS-658). All behind LOGIN_APPROVAL_ENABLED (default off), so login behaviour is unchanged until the gate logic is wired. - Migration 20260715000020: login_approval_codes (pending challenge: hashed single-use code, expiry, attempt cap, captured country/ip/device context) and login_devices (per-user known devices, hashed client device_id). Not RLS-scoped, since both are written pre-auth like magic_link_tokens / password_reset_tokens. Validated against Postgres. - JwtService::create/verify_login_approval_challenge_token: a 15-min "login_approval" challenge JWT mirroring the 2FA challenge, so the re-submit flow can carry {challenge_token, code} exactly like verify_2fa. - Config::login_approval_enabled from LOGIN_APPROVAL_ENABLED (default false), threaded into AuthService::new. `just check-container` green. Domain gate (assess country+device before create_tokens on the password + magic-link paths, ApprovalRequired result, complete_login_approval, handlers + verify endpoint, email template, tests) is the next step. #BUNYIP-373Wire the suspicious-login notify-and-approve gate into the password and magic-link login paths, the bunyip counterpart of mokosh's PMS-658. When LOGIN_APPROVAL_ENABLED is set and a non-2FA login looks suspicious (a new sign-in country, reusing the BUNYIP-366 signal, or a new device keyed on a client-supplied device_id), the login mints no tokens: a single-use 6-digit code is emailed and a short-lived login_approval challenge is returned, mirroring the 2FA handshake. The client re-submits {challenge_token, code} to POST /auth/login-approval/verify, which verifies the code, mints tokens, and records the approved country + device as the new baseline. The gate sits after the 2FA branch in both login and verify_magic_link, so 2FA accounts (already second-factor protected) and trusted-device skips are never gated. assess_login fails open on any lookup failure, and the whole feature is inert with the flag off (the default), so login behaviour is unchanged for existing deployments. The first device a user signs in from is a silent baseline (like the first-login country), so enabling the flag never gates every user at once. The emailed code is stored only as a SHA-256 hash, is single-use with a 15-minute TTL, and is capped at 5 wrong attempts per challenge. Adds the login_approval_codes + login_devices tables (migration landed in the foundation commit), TokenRepository methods for both, EmailService::send_login_approval_code plus login_approval.{html,txt} templates, and an env-gated integration test covering the new-device gate, the kill-switch-off no-op, and the verify / baseline-record / single-use round trip. #BUNYIP-373 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>