feat(auth): alert users on a new-country login (PMS-657) #449

Merged
nrupard merged 2 commits from feat/PMS-657-login-location-alerts into main 2026-07-14 17:56:07 +02:00
Owner

Summary

Detects a country-level login-location change on the legacy cookie login and notifies the user, per PMS-657 (country-level MVP; region/ASN deferred). Mirrors the bunyip BUNYIP-366 work for mokosh's own credential login.

On a successful login the client IP is resolved to an ISO 3166-1 alpha-2 country via an offline IP2Location LITE database and compared to the country recorded at the user's previous login. On a change (and only when the user has not opted out) mokosh sends a "new sign-in" email, then persists the new country. The first geolocatable login records the country silently, with no alert.

The whole check is best-effort: every failure (DB miss, unresolvable IP, mail send error) is logged and swallowed so it can never block a login, and it no-ops entirely when IP2LOCATION_DB_PATH is unset or the .BIN fails to load (kill switch).

Scope

The legacy credential login only: password (AuthService::login) and Google (login_with_google). The SPA path verifies bunyip-issued Resource-Server tokens and inherits detection from BUNYIP-366, so it is deliberately out of scope here.

Design

  • GeoIpService (src/utils/geoip.rs) wraps the IP2Location DB, the same LITE .BIN the ecosystem already deploys for dmarc-reporter and bunyip (/data/IP2LOCATION-LITE-DB11.BIN). Lookups are fully offline: no per-login external call, no client IP leaves the host.
  • AuthService::check_login_location runs off the existing update_last_login marker at both genuine-login branches. It deliberately does not hook token issuance, which is shared with refresh and would fire on every rotation.
  • Injected via a with_geoip setter (built from config in main.rs, threaded through create_api_router), so the three constructors and every test fixture keep working with the feature disabled and no signature churn.
  • Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified, broadcast) are ignored so a request arriving without a real client IP cannot register as a country change.
  • set_last_login_country is tenant-scoped via begin_with_tenant, carrying the RLS GUC exactly like update_last_login.

Schema

New migration 089_users_login_location_alerts.sql adds to users:

  • last_login_country TEXT, nullable (ISO2, capped at 64 chars).
  • login_location_alerts BOOLEAN NOT NULL DEFAULT TRUE (per-user opt-out).

Both are threaded through UserRow, the User model (mokosh-types), the From<UserRow> conversion, and all four user SELECT column lists.

Mail + config

  • New default Mailer::send_new_login_location composes a plain-text body and routes it through send_text, so LogMailer, SmtpMailer, and test mocks all inherit it with no per-impl change.
  • IP2LOCATION_DB_PATH (optional) -> AppConfig.ip2location_db_path. Unset disables the feature.

Tests

  • geoip::tests: country-code normalization rejects IP2Location's "-" placeholder and blanks, trims real ISO2 codes.
  • auth::service::tests::non_public_ip_detection: loopback / RFC1918 / link-local / unique-local / unspecified are non-public; routable v4/v6 are public.
  • auth::service::tests::login_location_decision_branches: None -> Record, same -> Unchanged, differ -> Alert.
  • fmt + clippy (-D warnings) + cargo test --lib (285 passed) green in the pinned rust-builder (rust 1.94.1).

Deploy note

Dormant until a deployment sets IP2LOCATION_DB_PATH to a readable LITE DB11 .BIN; otherwise a single startup log line and no behavior change.

#PMS-657

🤖 Generated with Claude Code

## Summary Detects a country-level login-location change on the legacy cookie login and notifies the user, per PMS-657 (country-level MVP; region/ASN deferred). Mirrors the bunyip BUNYIP-366 work for mokosh's own credential login. On a successful login the client IP is resolved to an ISO 3166-1 alpha-2 country via an offline IP2Location LITE database and compared to the country recorded at the user's previous login. On a change (and only when the user has not opted out) mokosh sends a "new sign-in" email, then persists the new country. The first geolocatable login records the country silently, with no alert. The whole check is best-effort: every failure (DB miss, unresolvable IP, mail send error) is logged and swallowed so it can never block a login, and it no-ops entirely when `IP2LOCATION_DB_PATH` is unset or the `.BIN` fails to load (kill switch). ## Scope The legacy credential login only: password (`AuthService::login`) and Google (`login_with_google`). The SPA path verifies bunyip-issued Resource-Server tokens and inherits detection from BUNYIP-366, so it is deliberately out of scope here. ## Design - `GeoIpService` (`src/utils/geoip.rs`) wraps the IP2Location DB, the same LITE `.BIN` the ecosystem already deploys for dmarc-reporter and bunyip (`/data/IP2LOCATION-LITE-DB11.BIN`). Lookups are fully offline: no per-login external call, no client IP leaves the host. - `AuthService::check_login_location` runs off the existing `update_last_login` marker at both genuine-login branches. It deliberately does not hook token issuance, which is shared with refresh and would fire on every rotation. - Injected via a `with_geoip` setter (built from config in `main.rs`, threaded through `create_api_router`), so the three constructors and every test fixture keep working with the feature disabled and no signature churn. - Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified, broadcast) are ignored so a request arriving without a real client IP cannot register as a country change. - `set_last_login_country` is tenant-scoped via `begin_with_tenant`, carrying the RLS GUC exactly like `update_last_login`. ## Schema New migration `089_users_login_location_alerts.sql` adds to `users`: - `last_login_country` TEXT, nullable (ISO2, capped at 64 chars). - `login_location_alerts` BOOLEAN NOT NULL DEFAULT TRUE (per-user opt-out). Both are threaded through `UserRow`, the `User` model (mokosh-types), the `From<UserRow>` conversion, and all four user SELECT column lists. ## Mail + config - New default `Mailer::send_new_login_location` composes a plain-text body and routes it through `send_text`, so `LogMailer`, `SmtpMailer`, and test mocks all inherit it with no per-impl change. - `IP2LOCATION_DB_PATH` (optional) -> `AppConfig.ip2location_db_path`. Unset disables the feature. ## Tests - `geoip::tests`: country-code normalization rejects IP2Location's `"-"` placeholder and blanks, trims real ISO2 codes. - `auth::service::tests::non_public_ip_detection`: loopback / RFC1918 / link-local / unique-local / unspecified are non-public; routable v4/v6 are public. - `auth::service::tests::login_location_decision_branches`: None -> Record, same -> Unchanged, differ -> Alert. - fmt + clippy (`-D warnings`) + `cargo test --lib` (285 passed) green in the pinned rust-builder (rust 1.94.1). ## Deploy note Dormant until a deployment sets `IP2LOCATION_DB_PATH` to a readable LITE DB11 `.BIN`; otherwise a single startup log line and no behavior change. #PMS-657 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): alert users on a new-country login (PMS-657)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m9s
Check / fmt + clippy + build + tests (pull_request) Successful in 2m29s
Integration / integration tests (pull_request) Successful in 10m53s
100ade106a
On a successful legacy cookie login, resolve the client IP to an ISO 3166-1 country via an offline IP2Location LITE database and compare it to the country recorded at the user's previous login. When the country changes (and the user has not opted out) send a "new sign-in" email, then persist the new country. The first geolocatable login records the country silently. The whole check is best-effort: every failure is logged and swallowed so it can never block a login, and it no-ops entirely when IP2LOCATION_DB_PATH is unset or the .BIN fails to load. Scope is the legacy credential login (password + Google); the SPA path verifies bunyip-issued tokens and inherits BUNYIP-366.

New GeoIpService (src/utils/geoip.rs) wraps the IP2Location DB, the same LITE .BIN the ecosystem already deploys for dmarc-reporter and bunyip. It is injected into AuthService via a with_geoip setter (built from config in main.rs, threaded through create_api_router), so test fixtures keep the feature disabled with no constructor churn. check_login_location runs off the existing update_last_login marker at both genuine-login branches (password and Google), so a silent token refresh does not trigger it. Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified) are ignored, and the tenant-scoped set_last_login_country write carries the RLS GUC like update_last_login.

Schema: migration 089 adds users.last_login_country (nullable ISO2) and users.login_location_alerts (bool, default true opt-out), threaded through UserRow, the User model, and every user SELECT. The alert body is a new default Mailer::send_new_login_location composing send_text, so every mailer inherits it. Config gains the optional ip2location_db_path from IP2LOCATION_DB_PATH. Unit tests cover the country-code normalization, the non-public-IP filter, and the record/unchanged/alert decision.

#PMS-657

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(auth): user-settable login-location opt-out, non-blocking alert (PMS-657)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 38s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m38s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 5m38s
e778b46ee4
Addresses three code-review findings on the PMS-657 login-location-alert work (PR 449).

The login_location_alerts opt-out was honored but had no way to be set. PUT /me (update_current_user -> UpdateUserRequest -> AuthService::update_user) now accepts an optional login_location_alerts bool, folded into the existing dynamic UPDATE builder (absent leaves it unchanged).

The alert send was awaited inline on the login path via the direct mailer, so a slow or failing SMTP round-trip added latency to (and could silently drop) the login-country-change response. It is now sent on a detached tokio task, so it never blocks or fails the login. The direct mailer is kept rather than the notifications dispatcher on purpose: the auth.* templates are seeded for the default tenant only (migrations 021 / 030) and dispatch resolves rules strictly per tenant, so a queued dispatch would silently drop the alert for every non-default tenant, whereas the direct send covers all tenants. Retry is intentionally not added (best-effort security signal).

The alert email now carries a link. send_new_login_location takes a security_link and renders it in the body; check_login_location passes the SPA base URL (no deep-linkable sessions route exists beyond /reset-password/<token>, which needs a token), and the body directs the user to review sessions and change their password there.

#PMS-657

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-14 17:55:33 +02:00
nrupard deleted branch feat/PMS-657-login-location-alerts 2026-07-14 17:56:08 +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!449
No description provided.