feat(auth): alert users on a new-country login (LC-580) #550

Merged
nrupard merged 2 commits from feat/lc580-login-location-alerts into main 2026-07-14 20:21:08 +02:00
Owner

Summary

Detects a country-level login-location change on the Bunyip SSO callback and notifies the user, per LC-580 (country-level MVP; region/ASN deferred). The Lets-Chat analog of BUNYIP-366 / PMS-657 / VS-91, built by wiring up the orphaned login_alert scaffold.

On a successful login the trusted-proxy-aware session 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 alerts enabled) Lets-Chat emails a "new sign-in" alert, then persists the new country. The first geolocatable login records the country silently.

The alert is spliced into bunyip_sso::get_callback on a detached tokio::spawn, so the geoip lookup and any SMTP send never add latency to the login redirect. It no-ops entirely when IP2LOCATION_DB_PATH is unset or the .BIN fails to load (kill switch), and is best-effort throughout (every failure logged and swallowed).

Reuses the orphaned scaffold

The removed local-auth path left disconnected login-alert scaffold; this wires it up rather than building new:

  • templates/email/login_alert.{txt,html} + views/login_alert.rs view structs (extended with a country field; reworded from device to country semantics).
  • The per-user notify_login_alerts_enabled opt-out column + its existing settings setter (db::auth::set_notify_login_alerts_enabled, settings.rs).
  • The Mailer (send_multipart) and the recipient/verified/opt-in/mailer gating order from email::notification.

The dead device-fingerprint login_alert_devices table is left untouched.

New

  • Migration 0040_login_country.sql: users.last_login_country TEXT.
  • db::auth::get_last_login_country / set_last_login_country (dedicated helpers, no UserRecord/SELECT churn).
  • geoip.rs: GeoipResolver over ip2location, plus the pure is_public_ip filter and the record/unchanged/alert decision, with unit tests.
  • login_alert.rs: the orchestrator (maybe_alert).
  • AppState.geoip: Option<Arc<GeoipResolver>>, built from IP2LOCATION_DB_PATH in main.rs (mirrors the mailer/stt/llm from_env pattern; Arc because AppState is Clone).

Scope

Consistent with the sibling MVPs: last_login_country on the user row (not a separate raw-IP history table); country-only sensitivity; region/ASN deferred. Non-public IPs (loopback, RFC1918, unique-local, link-local, unspecified) are ignored, and repeated logins from the same new country do not re-alert.

Tests

  • geoip::tests: country-code normalization (rejects IP2Location's "-"/blanks), the non-public-IP filter, and the record/unchanged/alert decision.
  • Verified green in the pinned rust-builder: fmt + clippy (-D warnings) on both the standalone and saas feature sets (compiling all test targets, so every AppState literal is checked) + the geoip unit tests.

Diff note

The bulk of the changed files is mechanical: every test's inline AppState literal gains geoip: None (the repo builds full inline AppState literals per test rather than sharing a helper). The substantive change is ~11 files.

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.

#LC-580

🤖 Generated with Claude Code

## Summary Detects a country-level login-location change on the Bunyip SSO callback and notifies the user, per LC-580 (country-level MVP; region/ASN deferred). The Lets-Chat analog of BUNYIP-366 / PMS-657 / VS-91, built by wiring up the orphaned `login_alert` scaffold. On a successful login the trusted-proxy-aware session 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 alerts enabled) Lets-Chat emails a "new sign-in" alert, then persists the new country. The first geolocatable login records the country silently. The alert is spliced into `bunyip_sso::get_callback` on a detached `tokio::spawn`, so the geoip lookup and any SMTP send never add latency to the login redirect. It no-ops entirely when `IP2LOCATION_DB_PATH` is unset or the `.BIN` fails to load (kill switch), and is best-effort throughout (every failure logged and swallowed). ## Reuses the orphaned scaffold The removed local-auth path left disconnected login-alert scaffold; this wires it up rather than building new: - `templates/email/login_alert.{txt,html}` + `views/login_alert.rs` view structs (extended with a `country` field; reworded from device to country semantics). - The per-user `notify_login_alerts_enabled` opt-out column + its existing settings setter (`db::auth::set_notify_login_alerts_enabled`, `settings.rs`). - The `Mailer` (`send_multipart`) and the recipient/verified/opt-in/mailer gating order from `email::notification`. The dead device-fingerprint `login_alert_devices` table is left untouched. ## New - Migration `0040_login_country.sql`: `users.last_login_country TEXT`. - `db::auth::get_last_login_country` / `set_last_login_country` (dedicated helpers, no `UserRecord`/SELECT churn). - `geoip.rs`: `GeoipResolver` over `ip2location`, plus the pure `is_public_ip` filter and the `record`/`unchanged`/`alert` decision, with unit tests. - `login_alert.rs`: the orchestrator (`maybe_alert`). - `AppState.geoip: Option<Arc<GeoipResolver>>`, built from `IP2LOCATION_DB_PATH` in `main.rs` (mirrors the mailer/stt/llm `from_env` pattern; `Arc` because `AppState` is `Clone`). ## Scope Consistent with the sibling MVPs: `last_login_country` on the user row (not a separate raw-IP history table); country-only sensitivity; region/ASN deferred. Non-public IPs (loopback, RFC1918, unique-local, link-local, unspecified) are ignored, and repeated logins from the same new country do not re-alert. ## Tests - `geoip::tests`: country-code normalization (rejects IP2Location's `"-"`/blanks), the non-public-IP filter, and the record/unchanged/alert decision. - Verified green in the pinned rust-builder: fmt + clippy (`-D warnings`) on **both** the standalone and saas feature sets (compiling all test targets, so every AppState literal is checked) + the geoip unit tests. ## Diff note The bulk of the changed files is mechanical: every test's inline `AppState` literal gains `geoip: None` (the repo builds full inline `AppState` literals per test rather than sharing a helper). The substantive change is ~11 files. ## 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. #LC-580 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): alert users on a new-country login (LC-580)
All checks were successful
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 7s
check-secrets / TruffleHog (pull_request) Successful in 9s
Check / clippy + fmt + tests (pull_request) Successful in 3m56s
11ee2e0abf
On a genuine SSO login, resolve the trusted-proxy-aware session 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 alerts enabled) email a "new sign-in" alert, then persist the new country. The first geolocatable login records the country silently. The whole thing is spliced into the Bunyip SSO callback on a detached task, so the geoip lookup and any SMTP send never add latency to the login redirect, and it no-ops entirely when IP2LOCATION_DB_PATH is unset or the .BIN fails to load.

Reuses the orphaned login_alert scaffold left by the removed local-auth path: the login_alert email templates + view structs (extended with a country field), the per-user notify_login_alerts_enabled opt-out (already wired, with an existing settings setter), the Mailer, and the recipient/verified/opt-in/mailer gating pattern from email::notification. The dead device-fingerprint login_alert_devices table is left untouched.

New: migration 0040 adds users.last_login_country; db::auth get/set_last_login_country helpers; a pure geoip module (GeoipResolver over ip2location, plus the non-public-IP filter and the record/unchanged/alert decision) with unit tests; a login_alert orchestrator module; an AppState.geoip field (Option<Arc<GeoipResolver>>, built from IP2LOCATION_DB_PATH in main.rs, mirroring the mailer/stt/llm from_env pattern).

Scope, consistent with the sibling BUNYIP-366 / PMS-657 / VS-91 country-level MVPs: last_login_country on the user row (not a separate raw-IP history table); country-only sensitivity; region/ASN deferred. Non-public IPs are ignored and repeated logins from the same new country do not re-alert.

The bulk of the diff is mechanical: every test's inline AppState literal gains geoip: None. Verified green (fmt + clippy -D warnings on both standalone and saas + geoip unit tests) in the pinned rust-builder.

#LC-580

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(auth): show the full country name in the login-location alert (LC-580)
All checks were successful
check-secrets / TruffleHog (push) Successful in 6s
check-secrets / Kingfisher (push) Successful in 7s
check-secrets / Nosey parker (push) Successful in 7s
check-secrets / Nosey parker (pull_request) Successful in 8s
check-secrets / Kingfisher (pull_request) Successful in 10s
check-secrets / TruffleHog (pull_request) Successful in 10s
Check / clippy + fmt + tests (pull_request) Successful in 3m50s
Create release / Create release from merged PR (pull_request) Has been skipped
f40722132b
The alert email showed the ISO 3166-1 alpha-2 code ("US") rather than the country name. IP2Location records already carry the full name (long_name), so GeoipResolver::resolve now returns both the code and the name (ResolvedCountry). The change comparison and the stored last_login_country stay on the stable ISO code; only the email display uses the name (e.g. "United States"), falling back to the code when the DB carries no usable long name.

#LC-580

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/lc580-login-location-alerts 2026-07-14 20:21:09 +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/lets-chat!550
No description provided.