feat(auth): alert users on a new-country login (BUNYIP-366) #369

Merged
nrupard merged 2 commits from feat/BUNYIP-366-login-location-alerts into main 2026-07-14 17:19:44 +02:00
Owner

Summary

Detects a country-level login-location change and emails the user, per BUNYIP-366 (country-level MVP; region/ASN deferred).

On a genuine login the client IP is resolved to an ISO 3166-1 alpha-2 country via an offline IP2Location LITE database and compared to the last country recorded for the user. On a change (and only when the user has not opted out) mokosh sends a "new sign-in from " 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, email 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).

Design

  • GeoIpService (crates/bunyip-domain/src/services/geoip.rs) wraps the IP2Location DB, the same LITE .BIN the ecosystem already deploys for dmarc-reporter (/data/IP2LOCATION-LITE-DB11.BIN). Lookups are fully offline: no per-login external call, no client IP sent to a third party.
  • AuthService::check_login_location runs off the existing UserRepository::update_last_login marker at every genuine login-success branch: password, trusted-device 2FA skip, magic link, 2FA completion, and both admin-invite-accept paths. It deliberately does not hook create_tokens, which is shared with silent refresh and would fire on every token rotation.
  • EmailService is now constructed ahead of AuthService in main.rs so it can be injected; GeoIpService is built from the optional config path and passed in as Option<Arc<..>>.
  • Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified, broadcast) are ignored so requests arriving without a real client IP cannot register as a country change.

Schema

New migration 20260714000010_add_login_location_alerts.sql adds to users:

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

Config

  • IP2LOCATION_DB_PATH (optional) -> Config.ip2location_db_path. Unset disables the feature.

Tests

  • geoip::tests: country-code normalization rejects IP2Location's "-" placeholder and blanks, trims and keeps real ISO2 codes.
  • auth::tests::non_public_ip_detection: loopback / RFC1918 / link-local / unique-local / unspecified are treated as non-public; routable v4/v6 are public.
  • just check-container green: fmt + clippy (-D warnings) + workspace lib tests.

Deploy note

The email alert only sends where IP2LOCATION_DB_PATH points at a readable LITE DB11 .BIN; otherwise the feature stays dormant with a single startup log line. No behavior change for deployments that do not set it.

#BUNYIP-366

🤖 Generated with Claude Code

## Summary Detects a country-level login-location change and emails the user, per BUNYIP-366 (country-level MVP; region/ASN deferred). On a genuine login the client IP is resolved to an ISO 3166-1 alpha-2 country via an offline IP2Location LITE database and compared to the last country recorded for the user. On a change (and only when the user has not opted out) mokosh sends a "new sign-in from <country>" 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, email 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). ## Design - `GeoIpService` (`crates/bunyip-domain/src/services/geoip.rs`) wraps the IP2Location DB, the same LITE `.BIN` the ecosystem already deploys for dmarc-reporter (`/data/IP2LOCATION-LITE-DB11.BIN`). Lookups are fully offline: no per-login external call, no client IP sent to a third party. - `AuthService::check_login_location` runs off the existing `UserRepository::update_last_login` marker at every genuine login-success branch: password, trusted-device 2FA skip, magic link, 2FA completion, and both admin-invite-accept paths. It deliberately does not hook `create_tokens`, which is shared with silent refresh and would fire on every token rotation. - `EmailService` is now constructed ahead of `AuthService` in `main.rs` so it can be injected; `GeoIpService` is built from the optional config path and passed in as `Option<Arc<..>>`. - Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified, broadcast) are ignored so requests arriving without a real client IP cannot register as a country change. ## Schema New migration `20260714000010_add_login_location_alerts.sql` adds to `users`: - `last_login_country` TEXT, nullable (ISO2, capped at 64 chars), the last country seen for the user. - `login_location_alerts` BOOLEAN NOT NULL DEFAULT TRUE, the per-user opt-out. ## Config - `IP2LOCATION_DB_PATH` (optional) -> `Config.ip2location_db_path`. Unset disables the feature. ## Tests - `geoip::tests`: country-code normalization rejects IP2Location's `"-"` placeholder and blanks, trims and keeps real ISO2 codes. - `auth::tests::non_public_ip_detection`: loopback / RFC1918 / link-local / unique-local / unspecified are treated as non-public; routable v4/v6 are public. - `just check-container` green: fmt + clippy (`-D warnings`) + workspace lib tests. ## Deploy note The email alert only sends where `IP2LOCATION_DB_PATH` points at a readable LITE DB11 `.BIN`; otherwise the feature stays dormant with a single startup log line. No behavior change for deployments that do not set it. #BUNYIP-366 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): alert users on a new-country login (BUNYIP-366)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 25s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
1ed8542589
On a genuine login, resolve the client IP to an ISO 3166-1 country via an offline IP2Location LITE database and compare it to the last country recorded for the user. When the country changes (and the user has not opted out) email a "new sign-in from <country>" alert, 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.

The new GeoIpService wraps the IP2Location DB (the same LITE .BIN the ecosystem already deploys for dmarc-reporter) and is injected into AuthService alongside EmailService, which is now constructed ahead of AuthService in main.rs. AuthService::check_login_location runs at every real login-success branch (password, trusted-device skip, magic link, 2FA completion, admin-invite accept) off the existing update_last_login marker, so a silent token refresh does not trigger it. Non-public client IPs (loopback, RFC1918, link-local, unique-local, unspecified) are ignored.

Schema: users gains last_login_country (nullable ISO2) and login_location_alerts (bool, default true opt-out). Config gains the optional ip2location_db_path from IP2LOCATION_DB_PATH. Adds the new_login_location email templates plus unit tests for country-code normalization and the non-public-IP filter.

#BUNYIP-366

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(auth): user-settable login-location opt-out, test alert decision
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 10m13s
Create release / Create release from merged PR (pull_request) Has been skipped
bfed45e2fe
Addresses two code-review findings on the BUNYIP-366 login-location-alert work (PR 369).

The login_location_alerts opt-out column was honored but had no way to be set. PUT /me/profile now accepts an optional login_location_alerts bool (absent leaves it unchanged; the column is NOT NULL so there is no clear state). It is applied via a dedicated UserRepository::set_login_location_alerts before the profile UPDATE, so the returned row reflects the new value and the existing update_profile callers (backup restore, QA seed) stay untouched.

The alert branch logic (first login records silently, same country no-ops, a change alerts) is now a pure login_location_decision(previous, current) -> LoginLocationDecision function, unit-tested for all three branches, instead of inline match arms reachable only through a DB plus mailer.

#BUNYIP-366

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:10:24 +02:00
nrupard deleted branch feat/BUNYIP-366-login-location-alerts 2026-07-14 17:19:44 +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!369
No description provided.