feat(auth): suspicious-login notify-and-approve gate (BUNYIP-373) #372

Merged
nrupard merged 3 commits from feat/BUNYIP-373-suspicious-login-approval into main 2026-07-16 15:59:27 +02:00
Owner

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) and 9256804 (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 stable device_id, hashed, absent from the user's known devices). Gates the password and magic-link paths.

Flow

login / verify_magic_link compute 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-lived login_approval challenge 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

  • After the 2FA branch. The gate sits after the 2FA/trusted-device logic, so 2FA accounts (already second-factor protected) and valid trusted-device skips are never gated.
  • Fail-open. assess_login swallows every lookup error and returns "not flagged", so infrastructure trouble can never lock a user out.
  • Silent first baseline. The first device a user signs in from is recorded silently (like the first-login country), so turning the flag on never gates every user at once. Cold start is graceful.
  • Kill-switch off by default. LOGIN_APPROVAL_ENABLED defaults 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.
  • Same-channel caveat. The magic-link path is email-based and the approval code is also emailed, so for that path the second proof is same-channel. The user opted to gate it anyway (defense against a stolen device / new location even when the mailbox is intact); documented inline.

New surface

  • Tables login_approval_codes + login_devices (not RLS-scoped: written pre-auth like magic_link_tokens).
  • TokenRepository methods for both; AuthService::assess_login / begin_login_approval / record_login_device / complete_login_approval; LoginResult / MagicLinkResult gain an ApprovalRequired variant.
  • EmailService::send_login_approval_code + login_approval.{html,txt} Tera templates.
  • device_id added to the login + magic-link-verify request bodies (optional; absent = country-only signal).

Testing

just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets). Adds bunyip-api/tests/login_approval.rs, env-gated on RLS_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.

## 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) and `9256804` (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 stable `device_id`, hashed, absent from the user's known devices). Gates the **password** and **magic-link** paths. ## Flow `login` / `verify_magic_link` compute 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-lived `login_approval` challenge 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 - **After the 2FA branch.** The gate sits after the 2FA/trusted-device logic, so 2FA accounts (already second-factor protected) and valid trusted-device skips are never gated. - **Fail-open.** `assess_login` swallows every lookup error and returns "not flagged", so infrastructure trouble can never lock a user out. - **Silent first baseline.** The first device a user signs in from is recorded silently (like the first-login country), so turning the flag on never gates every user at once. Cold start is graceful. - **Kill-switch off by default.** `LOGIN_APPROVAL_ENABLED` defaults 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. - **Same-channel caveat.** The magic-link path is email-based and the approval code is also emailed, so for that path the second proof is same-channel. The user opted to gate it anyway (defense against a stolen device / new location even when the mailbox is intact); documented inline. ## New surface - Tables `login_approval_codes` + `login_devices` (not RLS-scoped: written pre-auth like `magic_link_tokens`). - `TokenRepository` methods for both; `AuthService::assess_login` / `begin_login_approval` / `record_login_device` / `complete_login_approval`; `LoginResult` / `MagicLinkResult` gain an `ApprovalRequired` variant. - `EmailService::send_login_approval_code` + `login_approval.{html,txt}` Tera templates. - `device_id` added to the login + magic-link-verify request bodies (optional; absent = country-only signal). ## Testing `just check-container` green (fmt + clippy `-D warnings` + `cargo test --workspace --all-targets`). Adds `bunyip-api/tests/login_approval.rs`, env-gated on `RLS_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-373
feat(auth): gate suspicious logins behind an emailed approval code (BUNYIP-373)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 29s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
9256804dff
Wire 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>
fix(auth): make the login-approval attempt cap and single-use race-free (BUNYIP-373)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 58s
Check / fmt + clippy + build + tests (pull_request) Successful in 18m52s
Create release / Create release from merged PR (pull_request) Has been skipped
4be752a49e
Address code-review findings on the suspicious-login gate. The wrong-code attempt cap and the single-use consumption were each a read-modify-write split across separate statements, so concurrent verify requests could every one read a stale attempts value, pass the `< max` check, and guess (pushing the counter past the cap), and two concurrent correct-code submissions could both mark the challenge used and both mint tokens.

Fold both into atomic UPDATEs. increment_login_approval_attempts now guards `attempts < max` inside the statement and RETURNs the new count; a new claim_login_approval_code atomically sets used_at only when the row is still unused, unexpired, and under the cap, returning whether this caller won the claim. Postgres serializes the row updates, so the cap holds under any concurrency and exactly one login can ever complete from a given challenge (a correct code arriving after the cap is still refused).

Also drop a blank device_id: "" or whitespace no longer registers as an "empty" device that every such client would share. assess_login trims and filters it, degrading to the country-only signal exactly like an absent id. The gate integration test now covers the blank-device case.

#BUNYIP-373

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-16 15:50:46 +02:00
nrupard deleted branch feat/BUNYIP-373-suspicious-login-approval 2026-07-16 15:59:27 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!372
No description provided.