feat(auth): suspicious-login notify-and-approve gate for password login (PMS-658) #451

Merged
nrupard merged 1 commit from feat/PMS-658-suspicious-login-approval into main 2026-07-15 21:20:04 +02:00
Owner

Implements PMS-658.

Adds a suspicious-login notify-and-approve gate to mokosh-server's password login. When enabled and a login clears password/MFA but looks suspicious, the session and tokens are withheld: a single-use 6-digit code is emailed and the client completes the login by re-POSTing /auth/login with approval_code (mirrors the existing mfa_required re-POST). It never locks the account, and normal logins are untouched.

Design decisions (from the issue discussion)

  • Mechanism: emailed 6-digit code entered inline (mirrors the MFA re-POST), not an account lock.
  • Trigger: a new country (reuses the PMS-657 login_location_decision signal, needs IP2LOCATION_DB_PATH) OR a new device (a client-supplied device_id, hashed into user_login_devices; fail-open when absent, so it degrades to country-only). The first-ever country/device is baseline, not flagged, mirroring PMS-657.
  • Kill-switch: LOGIN_APPROVAL_ENABLED, default OFF - the gate can withhold a login, so it is opt-in per deployment for a staged rollout (PMS-289 lesson). Off = unchanged PMS-657 alert-only behaviour.

Scope (v1) and findings

  • Gates PASSWORD login. mokosh has NO magic-link / passwordless login (grepped), so the issue's magic-link example does not apply here; the real gate-able paths are password, Google, portal.
  • Google login gate is DEFERRED: its OAuth callback cannot inline re-POST a code and carries no device_id; it needs a dedicated completion endpoint (follow-up). Portal login and an IP signal are also follow-ups.
  • The bunyip-as-OP path cannot be gated here at all - those tokens are minted by bunyip and only verified per request; any approval UX for them belongs in bunyip.

Changes

  • Migrations 090_login_approvals (pending challenge: hashed single-use code, expiry, attempt cap) and 091_user_login_devices (known devices, hashed device_id), both with the explicit FORCE'd tenant_isolation RLS policy.
  • Types: LoginResponse.approval_required; LoginRequest.approval_code + device_id.
  • AuthService: the gate runs after password/MFA and before session/token mint - assess_login (country + device decision), issue_login_approval (store hashed code + email), verify_login_approval (match / attempt-cap / consume), record_login_success (record country + device on a cleared login). with_login_approval builder + LOGIN_APPROVAL_ENABLED wired through main.rs / create_api_router.
  • Email: send_login_approval_code default Mailer method (mirrors send_new_login_location).

Tests

Postgres integration (tests/login_approval.rs, device signal, no geoip needed): a new-device login returns approval_required with no tokens, a wrong code is rejected, the emailed code completes the login and records the device, and a known device is not re-flagged; plus a gate-disabled pass-through pinning the default-off behaviour.

Follow-ups

  • MAPPS: SPA support - send a stable device_id, and handle approval_required with an inline code field (mirrors the MFA field).
  • Google + portal login gating; IP-address signal.

Acceptance criteria

  • Anomalous logins detected across the covered path (new country + new device) - password login.
  • Suspicious sign-in triggers notify-and-approve, not a lock.
  • Normal sign-ins unaffected (gate off by default; a known country/device is not flagged).
  • Tests cover a flagged login and an approved recovery.
Implements PMS-658. Adds a suspicious-login notify-and-approve gate to mokosh-server's password login. When enabled and a login clears password/MFA but looks suspicious, the session and tokens are withheld: a single-use 6-digit code is emailed and the client completes the login by re-POSTing `/auth/login` with `approval_code` (mirrors the existing `mfa_required` re-POST). It never locks the account, and normal logins are untouched. ## Design decisions (from the issue discussion) - Mechanism: emailed 6-digit code entered inline (mirrors the MFA re-POST), not an account lock. - Trigger: a new country (reuses the PMS-657 `login_location_decision` signal, needs `IP2LOCATION_DB_PATH`) OR a new device (a client-supplied `device_id`, hashed into `user_login_devices`; fail-open when absent, so it degrades to country-only). The first-ever country/device is baseline, not flagged, mirroring PMS-657. - Kill-switch: `LOGIN_APPROVAL_ENABLED`, default OFF - the gate can withhold a login, so it is opt-in per deployment for a staged rollout (PMS-289 lesson). Off = unchanged PMS-657 alert-only behaviour. ## Scope (v1) and findings - Gates PASSWORD login. mokosh has NO magic-link / passwordless login (grepped), so the issue's magic-link example does not apply here; the real gate-able paths are password, Google, portal. - Google login gate is DEFERRED: its OAuth callback cannot inline re-POST a code and carries no `device_id`; it needs a dedicated completion endpoint (follow-up). Portal login and an IP signal are also follow-ups. - The bunyip-as-OP path cannot be gated here at all - those tokens are minted by bunyip and only verified per request; any approval UX for them belongs in bunyip. ## Changes - Migrations `090_login_approvals` (pending challenge: hashed single-use code, expiry, attempt cap) and `091_user_login_devices` (known devices, hashed `device_id`), both with the explicit FORCE'd `tenant_isolation` RLS policy. - Types: `LoginResponse.approval_required`; `LoginRequest.approval_code` + `device_id`. - `AuthService`: the gate runs after password/MFA and before session/token mint - `assess_login` (country + device decision), `issue_login_approval` (store hashed code + email), `verify_login_approval` (match / attempt-cap / consume), `record_login_success` (record country + device on a cleared login). `with_login_approval` builder + `LOGIN_APPROVAL_ENABLED` wired through `main.rs` / `create_api_router`. - Email: `send_login_approval_code` default `Mailer` method (mirrors `send_new_login_location`). ## Tests Postgres integration (`tests/login_approval.rs`, device signal, no geoip needed): a new-device login returns `approval_required` with no tokens, a wrong code is rejected, the emailed code completes the login and records the device, and a known device is not re-flagged; plus a gate-disabled pass-through pinning the default-off behaviour. ## Follow-ups - MAPPS: SPA support - send a stable `device_id`, and handle `approval_required` with an inline code field (mirrors the MFA field). - Google + portal login gating; IP-address signal. ## Acceptance criteria - [x] Anomalous logins detected across the covered path (new country + new device) - password login. - [x] Suspicious sign-in triggers notify-and-approve, not a lock. - [x] Normal sign-ins unaffected (gate off by default; a known country/device is not flagged). - [x] Tests cover a flagged login and an approved recovery.
feat(auth): suspicious-login notify-and-approve gate for password login
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 39s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m39s
Integration / integration tests (pull_request) Successful in 5m41s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
5d9a6e0045
Adds a suspicious-login gate to the password login path. When enabled (LOGIN_APPROVAL_ENABLED, off by default) and a login clears password/MFA but looks suspicious - a new country (the PMS-657 signal) or a new device (a client-supplied device_id) - the session and tokens are withheld: a single-use 6-digit code is emailed and the client re-POSTs /auth/login with approval_code to finish, mirroring the mfa_required re-POST flow. It never locks the account, and a known country/device is not flagged, so normal logins are untouched.

The gate is opt-in per deployment (default off) because it can withhold a login; enabling it is a deliberate, staged rollout choice (the PMS-289 lesson). With the flag off, behaviour is exactly the prior PMS-657 alert-only path.

Scope: gates password login. mokosh has no magic-link/passwordless login, and bunyip-as-OP tokens cannot be gated here (they are minted by bunyip and only verified per request). Google login gating is deferred (its OAuth callback cannot inline re-POST a code and carries no device_id; it needs a dedicated completion endpoint), as are portal login and an IP-address signal.

Changes:
- Migrations 090_login_approvals (pending challenge: hashed single-use code, expiry, attempt cap) and 091_user_login_devices (known devices, hashed device_id), both with the explicit FORCE'd tenant_isolation RLS policy.
- Types: LoginResponse.approval_required; LoginRequest.approval_code + device_id.
- AuthService: the gate runs after password/MFA, before session/token mint - assess_login (country + device decision), issue_login_approval (store hashed code + email), verify_login_approval (match / attempt-cap / consume), record_login_success (record country + device on a cleared login). with_login_approval builder + LOGIN_APPROVAL_ENABLED wired through main.rs / create_api_router.
- Email: send_login_approval_code default Mailer method.
- Tests: tests/login_approval.rs (device signal, no geoip) covers a flagged login, wrong-code rejection, approved recovery, and known-device pass-through, plus a gate-disabled pass-through.

Follow-ups tracked separately: SPA support (device_id + approval_required UI); Google/portal gating; IP signal.

#PMS-658

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/PMS-658-suspicious-login-approval 2026-07-15 21:20:04 +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/mokosh-server!451
No description provided.