diff --git a/.env.example b/.env.example index e25ea70e..d6047237 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,19 @@ SESSION_SECRET=session-secret-change-in-production # A daily cleanup job deletes rows whose expiry is older than this window. # LOGIN_SESSION_RETENTION_DAYS=30 +# Geo-location for the Active Sessions page (/account/devices) +# GEOIP_ENABLED — resolve each login session's IP to a coarse "City, Country" +# shown next to the device (default: false). Off by default and +# fail-open: when disabled, the DB file is missing/unreadable, or +# the IP is private/unresolvable, the location simply renders "—". +# GEOIP_DB_PATH — filesystem path to an operator-supplied MaxMind GeoLite2 City +# database (GeoLite2-City.mmdb). The lookup is a local +# memory-mapped read — no per-request network I/O and the user's +# IP never leaves the server. The .mmdb is NOT bundled (MaxMind +# license); download it free from a MaxMind account and mount it. +# GEOIP_ENABLED=false +# GEOIP_DB_PATH=/etc/authgate/GeoLite2-City.mmdb + # Session Cookie Lifetime # SESSION_MAX_AGE — session cookie lifetime in seconds (default: 3600 = 1 hour). # SESSION_IDLE_TIMEOUT — log the user out after this many seconds of inactivity diff --git a/.gitignore b/.gitignore index 71818104..3b5fe1de 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,11 @@ authgate-credentials.txt *.pem *.key +# MaxMind GeoLite2 database (operator-supplied; MaxMind license, never committed). +# The small public test fixture in testdata/ is exempt so geo tests can run. +*.mmdb +!internal/services/testdata/*.mmdb + # templ generated files *_templ.go api/* diff --git a/CLAUDE.md b/CLAUDE.md index 47578fd7..ed6c25a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ In addition to Device Code Flow, AuthGate supports Authorization Code Flow with - `AccessToken` - Unified storage for both access and refresh tokens (distinguished by `token_category` field) - `User.IsActive` - Boolean (default `true`) controlling whether a user may log in. Disabling revokes all of the user's tokens and `RequireAuth` clears any live session on the next request. Guards prevent self-disable and disabling the last _active_ admin. - `OAuthConnection` - Per-user binding to a third-party provider (GitHub, Gitea, GitLab, Microsoft). Stores provider tokens and `LastUsedAt`; admins can list/unlink them per user via `/admin/users/:id/connections`. -- `LoginSession` - Server-side registry of browser/device logins that backs the **Active Sessions** page (`/account/devices`) and remote sign-out. The session cookie carries an opaque SID; the row stores only its SHA-256 hash (`SIDHash`, never the raw SID) plus `UserID`, `IP`, `UserAgent`, parsed `Device`/`Browser`/`OS`, `Status` (`active`/`revoked`), `CreatedAt`, `LastSeenAt`, `ExpiresAt`. `middleware.loadUserFromSession` validates the SID against this table on every authenticated request (gated by `LOGIN_SESSION_TRACKING_ENABLED`): a revoked/missing row logs the user out, a SID-less legacy cookie is lazily backfilled, and `LastSeenAt`/`ExpiresAt` are slid forward on a ~60s throttle. UA parsing uses the pure-Go `github.com/mileusna/useragent` (no cgo). Distinct from `AccessToken` (the "Connected Apps" page) — the two data sources never mix. +- `LoginSession` - Server-side registry of browser/device logins that backs the **Active Sessions** page (`/account/devices`) and remote sign-out. The session cookie carries an opaque SID; the row stores only its SHA-256 hash (`SIDHash`, never the raw SID) plus `UserID`, `IP`, `UserAgent`, parsed `Device`/`Browser`/`OS`, optional `City`/`Country`, `Status` (`active`/`revoked`), `CreatedAt`, `LastSeenAt`, `ExpiresAt`. `middleware.loadUserFromSession` validates the SID against this table on every authenticated request (gated by `LOGIN_SESSION_TRACKING_ENABLED`): a revoked/missing row logs the user out, a SID-less legacy cookie is lazily backfilled, and `LastSeenAt`/`ExpiresAt` are slid forward on a ~60s throttle. UA parsing uses the pure-Go `github.com/mileusna/useragent` (no cgo). **Geo-location** (`City`/`Country`, shown as a `Location` column on the Active Sessions page) is **off by default** and resolved once at session creation by `services.GeoResolver` (`internal/services/geoip.go`) from the IP via a local MaxMind GeoLite2 City `.mmdb` (pure-Go `github.com/oschwald/geoip2-golang`, no cgo, no per-request network I/O, IP never leaves the server). Gated by `GEOIP_ENABLED` (default `false`) + `GEOIP_DB_PATH`; **fail-open**: disabled, missing/unreadable DB, or a private/unresolvable IP all store empty geo and render `—`. Display-only — never enters a token, claim, or authz decision. The `.mmdb` is operator-supplied and gitignored (tests use a small public fixture in `internal/services/testdata/`). See `docs/CONFIGURATION.md` "Geo-location for Active Sessions". Distinct from `AccessToken` (the "Connected Apps" page) — the two data sources never mix. ### Key Features diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0c121492..0bee5883 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -49,6 +49,10 @@ SESSION_SECRET=session-secret-change-in-production # Cookie encryption ke LOGIN_SESSION_TRACKING_ENABLED=true # Record each browser login in a server-side registry and validate the cookie SID per request (enables remote sign-out at /account/devices). Set false to disable and skip the auth-path SID lookup. LOGIN_SESSION_RETENTION_DAYS=30 # Daily cleanup deletes revoked/expired login-session rows older than this many days +# Geo-location for Active Sessions (off by default, fail-open) +GEOIP_ENABLED=false # Resolve each login session's IP to a coarse "City, Country" on /account/devices (default: false). Fail-open: disabled / missing DB / private IP all render "—". +GEOIP_DB_PATH= # Path to an operator-supplied MaxMind GeoLite2-City.mmdb. Local memory-mapped lookup — no network I/O, user IP never leaves the server. See "Geo-location for Active Sessions" below. + # Login page UX LOGIN_HIDE_PASSWORD_WITH_OAUTH=true # Collapse the username/password form behind an "Administrator login" toggle when OAuth providers are configured (default: true). Set false to always show the form. @@ -273,6 +277,30 @@ Security model: - **The active boolean stays truthful.** A non-owner still learns whether the token is active (`{"active": true}` vs `{"active": false}`); only the metadata is withheld. Reaching this path still requires authenticating as a valid confidential client. - **Breaking change vs prior releases.** Earlier versions returned full metadata to any authenticated client. After upgrading, a deployment that uses one client to introspect tokens issued to *other* clients (e.g. a central resource server) will lose that metadata. Set `INTROSPECTION_REQUIRE_OWNERSHIP=false` to restore the previous cross-client behavior byte-for-byte. The flag is a runtime kill switch — no redeploy is needed beyond the env change. +## Geo-location for Active Sessions + +The **Active Sessions** page (`/account/devices`) lists each browser/device login with its IP. A raw IP is hard for a user to recognize, so AuthGate can optionally show a coarse, human-readable **location** (e.g. `Taipei, Taiwan`) next to each session, resolved from the IP at login time. + +```bash +GEOIP_ENABLED=false # Enable IP→location resolution (default: false) +GEOIP_DB_PATH=/etc/authgate/GeoLite2-City.mmdb # Path to the operator-supplied GeoLite2 City database +``` + +How it works: + +- **Local database, no network calls.** Resolution reads a local MaxMind **GeoLite2 City** database (`.mmdb`) via the pure-Go `oschwald/geoip2-golang` library. Lookups are in-memory memory-mapped reads (~µs); **no per-request network I/O** and **the user's IP never leaves the server**. The build stays cgo-free (`CGO_ENABLED=0` cross-compile unaffected). +- **Resolve-on-create.** The location is resolved once when the login session is created and stored on the row (`City` / `Country`). Existing pre-feature rows show `—` until the user logs in again. +- **Off by default and fail-open.** When `GEOIP_ENABLED=false`, the DB file is missing/unreadable, or the IP is private/unresolvable, the location column simply renders `—` — exactly like how `Device`/`Browser`/`OS` fall back to `Unknown`. A bad path never aborts startup: AuthGate logs a single warning and disables geo. A startup log line reports which mode is live (`[geoip] enabled: …` or `[geoip] disabled: …`). +- **Display-only.** The location is advisory metadata for the user. It is **never** used in any authorization decision and never enters a token or claim. + +Supplying the database: + +1. Create a free [MaxMind account](https://www.maxmind.com/en/geolite2/signup) and accept the GeoLite2 EULA. +2. Download `GeoLite2-City.mmdb` (or generate it with MaxMind's `geoipupdate` tool). +3. Mount it into the container/host and point `GEOIP_DB_PATH` at it, then set `GEOIP_ENABLED=true`. + +The `.mmdb` is **not** bundled with AuthGate (MaxMind redistribution license; the file is ~60 MB). Keeping it external also lets operators refresh GeoLite2 on MaxMind's schedule without rebuilding the binary. To turn the feature off at any time, set `GEOIP_ENABLED=false` (instant runtime kill switch). + ## TLS / HTTPS AuthGate can serve HTTPS directly by setting two environment variables. When both are configured, the server listens on `SERVER_ADDR` using TLS. When both are empty (the default), it serves plain HTTP. Setting only one of the two is rejected at startup by `Config.Validate()` — this prevents silently falling back to HTTP when the operator meant to enable TLS. diff --git a/go.mod b/go.mod index 462c1ddf..49887dba 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mileusna/useragent v1.3.5 github.com/nicksnyder/go-i18n/v2 v2.6.1 + github.com/oschwald/geoip2-golang v1.13.0 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.20.1 github.com/redis/rueidis v1.0.75 @@ -116,6 +117,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 56c595c0..0c46ed86 100644 --- a/go.sum +++ b/go.sum @@ -214,6 +214,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index e4b433b4..45bb7e48 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -85,9 +85,11 @@ func initializeServices( clientService, ) dashboardService := services.NewDashboardService(db, auditService) + geoResolver := services.NewGeoResolver(cfg.GeoIPEnabled, cfg.GeoIPDBPath) loginSessionService := services.NewLoginSessionService( db, auditService, + geoResolver, time.Duration(cfg.SessionMaxAge)*time.Second, time.Duration(cfg.SessionRememberMeMaxAge)*time.Second, ) diff --git a/internal/config/config.go b/internal/config/config.go index e77a65df..883736e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -213,6 +213,14 @@ type Config struct { LoginSessionRetentionDays int // Retention/sweep window in days for revoked + expired login-session rows (default: 30) LoginHidePasswordWithOAuth bool // Hide the username/password form behind a toggle when OAuth providers are configured (default: true) + // Geo-location for Active Sessions (login-session rows). Off by default and + // fail-open: when disabled, the DB file is missing/unreadable, or an IP is + // private/unresolvable, the location is simply blank. Resolution is a local + // memory-mapped read of an operator-supplied MaxMind GeoLite2 City database; + // no per-request network I/O and the user's IP never leaves the server. + GeoIPEnabled bool // Enable IP→location resolution for login sessions (default: false) + GeoIPDBPath string // Filesystem path to the operator-supplied GeoLite2-City.mmdb (default: "") + // Device code settings DeviceCodeExpiration time.Duration PollingInterval int // seconds @@ -494,6 +502,8 @@ func Load() *Config { LoginSessionTrackingEnabled: getEnvBool("LOGIN_SESSION_TRACKING_ENABLED", true), LoginSessionRetentionDays: getEnvInt("LOGIN_SESSION_RETENTION_DAYS", 30), LoginHidePasswordWithOAuth: getEnvBool("LOGIN_HIDE_PASSWORD_WITH_OAUTH", true), + GeoIPEnabled: getEnvBool("GEOIP_ENABLED", false), + GeoIPDBPath: getEnv("GEOIP_DB_PATH", ""), DeviceCodeExpiration: 30 * time.Minute, PollingInterval: 5, DatabaseDriver: driver, diff --git a/internal/handlers/device_sessions.go b/internal/handlers/device_sessions.go index 0b2774f2..14fbf62f 100644 --- a/internal/handlers/device_sessions.go +++ b/internal/handlers/device_sessions.go @@ -114,6 +114,8 @@ func (h *DeviceSessionHandler) ListDevices(c *gin.Context) { Browser: ls.Browser, OS: ls.OS, IP: ls.IP, + City: ls.City, + Country: ls.Country, CreatedAt: ls.CreatedAt, LastSeenAt: ls.LastSeenAt, IsCurrent: currentHash != "" && currentHash == ls.SIDHash, diff --git a/internal/handlers/device_sessions_test.go b/internal/handlers/device_sessions_test.go index 18e9fbbc..3a7a2b43 100644 --- a/internal/handlers/device_sessions_test.go +++ b/internal/handlers/device_sessions_test.go @@ -15,6 +15,7 @@ import ( "github.com/go-authgate/authgate/internal/models" "github.com/go-authgate/authgate/internal/services" "github.com/go-authgate/authgate/internal/store" + "github.com/go-authgate/authgate/internal/templates" "github.com/go-authgate/authgate/internal/token" "github.com/gin-contrib/sessions" @@ -51,16 +52,32 @@ const ( ) func setupDeviceSessionTest(t *testing.T) (*store.Store, *services.LoginSessionService) { + t.Helper() + return setupDeviceSessionTestWithGeo(t, services.NewNoopGeoResolver()) +} + +func setupDeviceSessionTestWithGeo( + t *testing.T, + geo services.GeoResolver, +) (*store.Store, *services.LoginSessionService) { t.Helper() gin.SetMode(gin.TestMode) s, err := store.New(context.Background(), "sqlite", ":memory:", &config.Config{}) require.NoError(t, err) svc := services.NewLoginSessionService( - s, services.NewNoopAuditService(), time.Hour, 30*24*time.Hour, + s, + services.NewNoopAuditService(), + geo, + time.Hour, + 30*24*time.Hour, ) return s, svc } +// geoFixturePath is MaxMind's public GeoLite2 City test fixture, owned by the +// services package's testdata. 81.2.69.142 → London, United Kingdom. +const geoFixturePath = "../services/testdata/GeoLite2-City-Test.mmdb" + // newDeviceRouter wires the device-session routes with a cookie session whose // SID is currentSID (the "this device" SID) and an authenticated user context. func newDeviceRouter( @@ -261,6 +278,70 @@ func TestEstablishLoginSession_RevokesPriorRow(t *testing.T) { assert.False(t, svc.SIDMatches(oldSID, &remaining[0])) } +// TestDevices_LocationColumn_Rendered covers verification #1 end-to-end at the +// user-observable level: with geo enabled and a known public IP, the rendered +// Active Sessions page shows the resolved "City, Country" in the Location cell. +func TestDevices_LocationColumn_Rendered(t *testing.T) { + geo := services.NewGeoResolver(true, geoFixturePath) + _, svc := setupDeviceSessionTestWithGeo(t, geo) + ctx := context.Background() + userID := uuid.New().String() + + sid, err := svc.Establish(ctx, services.EstablishParams{ + UserID: userID, IP: "81.2.69.142", UserAgent: uaChromeMac, // London, United Kingdom + }) + require.NoError(t, err) + + handler := NewDeviceSessionHandler(svc) + r := newDeviceRouter(handler, userID, sid) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/account/devices", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "London, United Kingdom") + assert.Contains(t, body, `data-label="Location"`) +} + +// TestDevices_LocationColumn_BlankWhenDisabled covers verification #2/#3: with +// geo off (no-op resolver) the page still renders, the Location column exists, +// and no place name leaks — the cell falls back to the em-dash placeholder. +func TestDevices_LocationColumn_BlankWhenDisabled(t *testing.T) { + _, svc := setupDeviceSessionTestWithGeo(t, services.NewNoopGeoResolver()) + ctx := context.Background() + userID := uuid.New().String() + + sid, err := svc.Establish(ctx, services.EstablishParams{ + UserID: userID, IP: "81.2.69.142", UserAgent: uaChromeMac, + }) + require.NoError(t, err) + + handler := NewDeviceSessionHandler(svc) + r := newDeviceRouter(handler, userID, sid) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/account/devices", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, `data-label="Location"`) + assert.NotContains(t, body, "London") + assert.Contains(t, body, "—") // em-dash placeholder for the empty location +} + +// TestLocationLabel unit-tests the graceful degradation of the Location label +// composed by the LoginSessionDisplay view model. +func TestLocationLabel(t *testing.T) { + loc := func(city, country string) string { + return templates.LoginSessionDisplay{City: city, Country: country}.Location() + } + assert.Equal(t, "Taipei, Taiwan", loc("Taipei", "Taiwan")) + assert.Equal(t, "Taiwan", loc("", "Taiwan")) + assert.Equal(t, "Taipei", loc("Taipei", "")) + assert.Empty(t, loc("", "")) +} + // TestDevices_IsolationFromConnectedApps covers e2e test #3's isolation // assertion: OAuth tokens never leak into the devices page, and login sessions // never leak into the connected-apps page — the two data sources are disjoint. diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 33f397f4..75cda7c3 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -23,6 +23,7 @@ account.devices.col_device: Device account.devices.col_first_seen: First Seen account.devices.col_ip: IP Address account.devices.col_last_active: Last Active +account.devices.col_location: Location account.devices.col_os: OS account.devices.count: '{{.Count}} Devices' account.devices.current_session: Current session diff --git a/internal/i18n/locales/zh-CN.yaml b/internal/i18n/locales/zh-CN.yaml index c26f8d43..ac019f09 100644 --- a/internal/i18n/locales/zh-CN.yaml +++ b/internal/i18n/locales/zh-CN.yaml @@ -23,6 +23,7 @@ account.devices.col_device: 设备 account.devices.col_first_seen: 首次出现 account.devices.col_ip: IP 地址 account.devices.col_last_active: 最近活动 +account.devices.col_location: 位置 account.devices.col_os: 操作系统 account.devices.count: '{{.Count}} 台设备' account.devices.current_session: 当前会话 diff --git a/internal/i18n/locales/zh-TW.yaml b/internal/i18n/locales/zh-TW.yaml index c670fc4e..ff576f40 100644 --- a/internal/i18n/locales/zh-TW.yaml +++ b/internal/i18n/locales/zh-TW.yaml @@ -23,6 +23,7 @@ account.devices.col_device: 裝置 account.devices.col_first_seen: 首次出現 account.devices.col_ip: IP 位址 account.devices.col_last_active: 最近活動 +account.devices.col_location: 位置 account.devices.col_os: 作業系統 account.devices.count: '{{.Count}} 部裝置' account.devices.current_session: 目前的工作階段 diff --git a/internal/middleware/login_session_auth_test.go b/internal/middleware/login_session_auth_test.go index 5da8e064..57f6d111 100644 --- a/internal/middleware/login_session_auth_test.go +++ b/internal/middleware/login_session_auth_test.go @@ -36,7 +36,11 @@ func setupLoginSessionAuth( require.NoError(t, s.CreateUser(user)) loginSvc := services.NewLoginSessionService( - s, services.NewNoopAuditService(), time.Hour, 30*24*time.Hour, + s, + services.NewNoopAuditService(), + services.NewNoopGeoResolver(), + time.Hour, + 30*24*time.Hour, ) return userSvc, loginSvc, s, user } diff --git a/internal/models/login_session.go b/internal/models/login_session.go index 17efb9cc..faf70dd6 100644 --- a/internal/models/login_session.go +++ b/internal/models/login_session.go @@ -34,6 +34,11 @@ type LoginSession struct { Device string `gorm:"type:varchar(100)"` Browser string `gorm:"type:varchar(100)"` OS string `gorm:"type:varchar(100)"` + // City and Country are coarse, display-only geo labels resolved from IP at + // login time (best-effort, English). Empty when geo is disabled, the IP is + // private/unresolvable, or no DB is configured — rendered as "—" in the UI. + City string `gorm:"type:varchar(100)"` + Country string `gorm:"type:varchar(100)"` // 'active' or 'revoked' Status string `gorm:"not null;default:'active';index:idx_login_session_user_status,priority:2"` CreatedAt time.Time // first seen diff --git a/internal/services/geoip.go b/internal/services/geoip.go new file mode 100644 index 00000000..e670d05d --- /dev/null +++ b/internal/services/geoip.go @@ -0,0 +1,102 @@ +package services + +import ( + "errors" + "fmt" + "log" + "net" + + "github.com/oschwald/geoip2-golang" +) + +// geoEnglish is the locale key used to read human-readable place names from the +// MaxMind record. GeoLite2 ships names in several languages; we surface only the +// English label (display-only metadata) to keep storage flat and predictable. +const geoEnglish = "en" + +// GeoLocation holds the coarse, display-only place labels resolved from an IP. +// Both fields are best-effort and may be empty (private/unknown IP, geo disabled, +// or DB miss) — callers treat an empty value the same as "Unknown". +type GeoLocation struct { + City string + Country string +} + +// GeoResolver maps an IP string to a coarse GeoLocation. Implementations never +// panic and fall back to an empty GeoLocation for any IP they cannot resolve, +// mirroring the fail-open posture of ParseUserAgent. +type GeoResolver interface { + Resolve(ip string) GeoLocation +} + +// noopGeoResolver is the fail-open fallback used when geo is disabled or the DB +// could not be opened. Its Resolve always returns an empty GeoLocation (same +// idea as NewNoopAuditService). +type noopGeoResolver struct{} + +// Resolve always returns an empty GeoLocation. +func (noopGeoResolver) Resolve(string) GeoLocation { return GeoLocation{} } + +// NewNoopGeoResolver returns a resolver that resolves every IP to an empty +// GeoLocation. Exposed for tests and for callers that want geo explicitly off. +func NewNoopGeoResolver() GeoResolver { return noopGeoResolver{} } + +// mmdbGeoResolver resolves IPs against a local MaxMind GeoLite2 City database. +// The reader memory-maps the file, so Resolve is a local in-memory lookup with +// no per-request network I/O and the user's IP never leaves the server. +type mmdbGeoResolver struct { + reader *geoip2.Reader +} + +// Resolve looks up the IP in the local DB and returns its English city/country +// labels. It is panic-free and fails open: an unparseable IP, a private/loopback +// address with no DB entry, or any lookup error yields an empty GeoLocation. +func (r *mmdbGeoResolver) Resolve(ip string) GeoLocation { + parsed := net.ParseIP(ip) + if parsed == nil { + return GeoLocation{} + } + rec, err := r.reader.City(parsed) + if err != nil { + return GeoLocation{} + } + return GeoLocation{ + City: rec.City.Names[geoEnglish], + Country: rec.Country.Names[geoEnglish], + } +} + +// newMMDBGeoResolver opens the GeoLite2 City database at dbPath. It returns an +// error (rather than logging) so callers can decide whether a failure is fatal; +// the bootstrap-facing NewGeoResolver turns that error into a warn-and-disable. +func newMMDBGeoResolver(dbPath string) (*mmdbGeoResolver, error) { + if dbPath == "" { + return nil, errors.New("GEOIP_DB_PATH is empty") + } + reader, err := geoip2.Open(dbPath) + if err != nil { + return nil, fmt.Errorf("open geoip db %q: %w", dbPath, err) + } + return &mmdbGeoResolver{reader: reader}, nil +} + +// NewGeoResolver constructs the GeoResolver wired into the login-session service. +// It is fail-open and default-off: when enabled is false it returns a no-op +// resolver; when enabled is true it opens the DB at dbPath, and on any failure +// (empty path, missing/unreadable file) it logs a warning and falls back to the +// no-op resolver so the server still boots. It emits a single startup log line +// describing which mode is live. +func NewGeoResolver(enabled bool, dbPath string) GeoResolver { + if !enabled { + log.Printf("[geoip] disabled (GEOIP_ENABLED=false)") + return noopGeoResolver{} + } + r, err := newMMDBGeoResolver(dbPath) + if err != nil { + log.Printf("[geoip] disabled: %v", err) + return noopGeoResolver{} + } + md := r.reader.Metadata() + log.Printf("[geoip] enabled: db=%s type=%s nodes=%d", dbPath, md.DatabaseType, md.NodeCount) + return r +} diff --git a/internal/services/geoip_test.go b/internal/services/geoip_test.go new file mode 100644 index 00000000..626af92c --- /dev/null +++ b/internal/services/geoip_test.go @@ -0,0 +1,69 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testGeoDBPath is MaxMind's small public GeoLite2 City test fixture, checked +// into testdata/. Known entries used below: 81.2.69.142 → London, United +// Kingdom; 175.16.199.0 → Changchun, China. Private/loopback IPs have no entry. +const testGeoDBPath = "testdata/GeoLite2-City-Test.mmdb" + +func TestMMDBGeoResolver_Resolve_KnownIP(t *testing.T) { + r, err := newMMDBGeoResolver(testGeoDBPath) + require.NoError(t, err) + + loc := r.Resolve("81.2.69.142") + assert.Equal(t, "London", loc.City) + assert.Equal(t, "United Kingdom", loc.Country) + + loc = r.Resolve("175.16.199.0") + assert.Equal(t, "Changchun", loc.City) + assert.Equal(t, "China", loc.Country) +} + +func TestMMDBGeoResolver_Resolve_PrivateAndInvalid(t *testing.T) { + r, err := newMMDBGeoResolver(testGeoDBPath) + require.NoError(t, err) + + // Private/loopback addresses have no entry in the test DB → empty. + for _, ip := range []string{"127.0.0.1", "192.168.1.1", "::1"} { + loc := r.Resolve(ip) + assert.Empty(t, loc.City, "city for %s", ip) + assert.Empty(t, loc.Country, "country for %s", ip) + } + + // Unparseable IP and empty string fail open to an empty location. + assert.Equal(t, GeoLocation{}, r.Resolve("not-an-ip")) + assert.Equal(t, GeoLocation{}, r.Resolve("")) +} + +func TestNewGeoResolver_DisabledIsNoop(t *testing.T) { + r := NewGeoResolver(false, testGeoDBPath) + assert.Equal(t, GeoLocation{}, r.Resolve("81.2.69.142")) +} + +func TestNewGeoResolver_MissingDBFailsOpen(t *testing.T) { + // Enabled but the DB path is bogus → fail open to a no-op resolver, no panic. + r := NewGeoResolver(true, "testdata/does-not-exist.mmdb") + assert.Equal(t, GeoLocation{}, r.Resolve("81.2.69.142")) + + // Enabled with an empty path also falls back cleanly. + r = NewGeoResolver(true, "") + assert.Equal(t, GeoLocation{}, r.Resolve("81.2.69.142")) +} + +func TestNewGeoResolver_EnabledResolves(t *testing.T) { + r := NewGeoResolver(true, testGeoDBPath) + loc := r.Resolve("81.2.69.142") + assert.Equal(t, "London", loc.City) + assert.Equal(t, "United Kingdom", loc.Country) +} + +func TestNewNoopGeoResolver(t *testing.T) { + r := NewNoopGeoResolver() + assert.Equal(t, GeoLocation{}, r.Resolve("81.2.69.142")) +} diff --git a/internal/services/login_session.go b/internal/services/login_session.go index aa3de10d..056e5522 100644 --- a/internal/services/login_session.go +++ b/internal/services/login_session.go @@ -30,24 +30,31 @@ const loginSessionTouchInterval = 60 * time.Second type LoginSessionService struct { store core.Store auditService core.AuditLogger + geo GeoResolver sessionTTL time.Duration // standard cookie lifetime → row ExpiresAt rememberTTL time.Duration // "remember me" cookie lifetime → row ExpiresAt } // NewLoginSessionService constructs a LoginSessionService. sessionTTL and // rememberTTL should mirror the standard and "remember me" cookie max-ages so a -// row's ExpiresAt tracks its cookie's lifetime. +// row's ExpiresAt tracks its cookie's lifetime. A nil geo resolver defaults to +// the no-op resolver (geo disabled), keeping the service fail-open. func NewLoginSessionService( s core.Store, auditService core.AuditLogger, + geo GeoResolver, sessionTTL, rememberTTL time.Duration, ) *LoginSessionService { if auditService == nil { auditService = NewNoopAuditService() } + if geo == nil { + geo = NewNoopGeoResolver() + } return &LoginSessionService{ store: s, auditService: auditService, + geo: geo, sessionTTL: sessionTTL, rememberTTL: rememberTTL, } @@ -91,6 +98,7 @@ func (s *LoginSessionService) Establish( rawSID = base64.RawURLEncoding.EncodeToString(raw) ua := ParseUserAgent(params.UserAgent) + geo := s.geo.Resolve(params.IP) now := time.Now() // Clamp to the model's varchar widths. The User-Agent is attacker-supplied // and unbounded; without this, an over-long value fails the INSERT on @@ -106,6 +114,8 @@ func (s *LoginSessionService) Establish( Device: clampToColumn(ua.Device, 100), Browser: clampToColumn(ua.Browser, 100), OS: clampToColumn(ua.OS, 100), + City: clampToColumn(geo.City, 100), + Country: clampToColumn(geo.Country, 100), Status: models.LoginSessionStatusActive, CreatedAt: now, LastSeenAt: now, diff --git a/internal/services/login_session_test.go b/internal/services/login_session_test.go index 76ce6a85..7c93d549 100644 --- a/internal/services/login_session_test.go +++ b/internal/services/login_session_test.go @@ -14,10 +14,18 @@ import ( ) func setupLoginSessionService(t *testing.T) (*store.Store, *LoginSessionService) { + t.Helper() + return setupLoginSessionServiceWithGeo(t, NewNoopGeoResolver()) +} + +func setupLoginSessionServiceWithGeo( + t *testing.T, + geo GeoResolver, +) (*store.Store, *LoginSessionService) { t.Helper() s, err := store.New(context.Background(), "sqlite", ":memory:", &config.Config{}) require.NoError(t, err) - svc := NewLoginSessionService(s, NewNoopAuditService(), time.Hour, 30*24*time.Hour) + svc := NewLoginSessionService(s, NewNoopAuditService(), geo, time.Hour, 30*24*time.Hour) return s, svc } @@ -47,6 +55,67 @@ func TestLoginSession_EstablishAndGetActive(t *testing.T) { assert.NotEqual(t, sid, ls.SIDHash) } +// TestLoginSession_Establish_PopulatesGeo is the happy path: with a real +// GeoResolver and a known public IP, Establish persists City/Country on the row. +func TestLoginSession_Establish_PopulatesGeo(t *testing.T) { + geo, err := newMMDBGeoResolver(testGeoDBPath) + require.NoError(t, err) + _, svc := setupLoginSessionServiceWithGeo(t, geo) + ctx := context.Background() + userID := uuid.New().String() + + sid, err := svc.Establish(ctx, EstablishParams{ + UserID: userID, + IP: "81.2.69.142", // London, United Kingdom in the test DB + UserAgent: "curl/8.4.0", + }) + require.NoError(t, err) + + ls, err := svc.GetActiveBySID(ctx, sid) + require.NoError(t, err) + require.NotNil(t, ls) + assert.Equal(t, "London", ls.City) + assert.Equal(t, "United Kingdom", ls.Country) +} + +// TestLoginSession_Establish_GeoFallback covers the fail-open paths: the no-op +// resolver (geo disabled) and a private IP both leave City/Country empty. +func TestLoginSession_Establish_GeoFallback(t *testing.T) { + ctx := context.Background() + + // Geo disabled (no-op resolver) → empty City/Country even for a public IP. + _, disabled := setupLoginSessionServiceWithGeo(t, NewNoopGeoResolver()) + sid, err := disabled.Establish(ctx, EstablishParams{ + UserID: uuid.New().String(), + IP: "81.2.69.142", + UserAgent: "curl/8.4.0", + }) + require.NoError(t, err) + ls, err := disabled.GetActiveBySID(ctx, sid) + require.NoError(t, err) + require.NotNil(t, ls) + assert.Empty(t, ls.City) + assert.Empty(t, ls.Country) + + // Geo enabled but a private/loopback IP has no entry → still empty. + geo, err := newMMDBGeoResolver(testGeoDBPath) + require.NoError(t, err) + _, enabled := setupLoginSessionServiceWithGeo(t, geo) + for _, ip := range []string{"127.0.0.1", "192.168.1.1", "::1"} { + sid, err := enabled.Establish(ctx, EstablishParams{ + UserID: uuid.New().String(), + IP: ip, + UserAgent: "curl/8.4.0", + }) + require.NoError(t, err) + ls, err := enabled.GetActiveBySID(ctx, sid) + require.NoError(t, err) + require.NotNil(t, ls) + assert.Empty(t, ls.City, "city for %s", ip) + assert.Empty(t, ls.Country, "country for %s", ip) + } +} + func TestLoginSession_GetActiveBySID_EmptyAndUnknown(t *testing.T) { _, svc := setupLoginSessionService(t) ctx := context.Background() diff --git a/internal/services/testdata/GeoLite2-City-Test.mmdb b/internal/services/testdata/GeoLite2-City-Test.mmdb new file mode 100644 index 00000000..4549847a Binary files /dev/null and b/internal/services/testdata/GeoLite2-City-Test.mmdb differ diff --git a/internal/templates/account_devices.templ b/internal/templates/account_devices.templ index 92e5d95e..f8a10ca9 100644 --- a/internal/templates/account_devices.templ +++ b/internal/templates/account_devices.templ @@ -45,6 +45,7 @@ templ AccountDevices(props DevicesPageProps) { { i18n.T(ctx, "account.devices.col_browser") } { i18n.T(ctx, "account.devices.col_os") } { i18n.T(ctx, "account.devices.col_ip") } + { i18n.T(ctx, "account.devices.col_location") } { i18n.T(ctx, "account.devices.col_first_seen") } { i18n.T(ctx, "account.devices.col_last_active") } { i18n.T(ctx, "account.devices.col_actions") } @@ -100,6 +101,14 @@ templ DeviceTableRow(device LoginSessionDisplay, csrfToken string) { } + + + if loc := device.Location(); loc != "" { + { loc } + } else { + + } + { i18n.T(ctx, "account.devices.col_first_seen") } diff --git a/internal/templates/props.go b/internal/templates/props.go index 0641d813..3a0ee454 100644 --- a/internal/templates/props.go +++ b/internal/templates/props.go @@ -172,11 +172,27 @@ type LoginSessionDisplay struct { Browser string OS string IP string + City string // coarse geo city (best-effort, may be empty) + Country string // coarse geo country (best-effort, may be empty) CreatedAt time.Time // first seen LastSeenAt time.Time // last active IsCurrent bool // true for the device making the request ("This device") } +// Location composes a coarse "City, Country" label from the geo fields, +// degrading to country-only or city-only when one is missing. Returns "" when +// neither resolved (geo disabled, private IP, or DB miss) — rendered as "—". +func (d LoginSessionDisplay) Location() string { + parts := make([]string, 0, 2) + if d.City != "" { + parts = append(parts, d.City) + } + if d.Country != "" { + parts = append(parts, d.Country) + } + return strings.Join(parts, ", ") +} + // DevicesPageProps contains properties for the Active Sessions (devices) page. type DevicesPageProps struct { BaseProps