diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 3b02c03c..4b8db863 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -104,6 +104,7 @@ func initServices( Signer: svc.jwtService, Reauth: svc.webauthnModule, AuditLog: svc.auditLogService, + IPLocator: svc.geoLiteService, AppConfig: svc.appConfigService, }) if err != nil { diff --git a/backend/internal/devicelogin/dto.go b/backend/internal/devicelogin/dto.go index d5b4f43c..faf669c3 100644 --- a/backend/internal/devicelogin/dto.go +++ b/backend/internal/devicelogin/dto.go @@ -24,5 +24,7 @@ type verificationInfoDto struct { UserCode string `json:"userCode"` Device string `json:"device"` IPAddress string `json:"ipAddress"` + Country string `json:"country"` + City string `json:"city"` ExpiresAt datatype.DateTime `json:"expiresAt"` } diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go index 38ba9444..2b7ebced 100644 --- a/backend/internal/devicelogin/module.go +++ b/backend/internal/devicelogin/module.go @@ -26,6 +26,10 @@ type AuditLogger interface { DeviceStringFromUserAgent(userAgent string) string } +type IPLocationResolver interface { + GetLocationByIP(ipAddress string) (country, city string, err error) +} + type AppConfigProvider interface { GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) } @@ -38,6 +42,7 @@ type Dependencies struct { Signer TokenService Reauth ReauthenticationTokenConsumer AuditLog AuditLogger + IPLocator IPLocationResolver AppConfig AppConfigProvider } @@ -47,7 +52,7 @@ type Module struct { } func New(deps Dependencies) (*Module, error) { - service := NewService(deps.Actors.Service(), deps.DB, deps.Signer, deps.Reauth, deps.AuditLog) + service := NewService(deps.Actors.Service(), deps.DB, deps.Signer, deps.Reauth, deps.AuditLog, deps.IPLocator) module := &Module{ service: service, handler: newHandler(service, deps.BaseURL, deps.AppConfig), diff --git a/backend/internal/devicelogin/service.go b/backend/internal/devicelogin/service.go index fbe16cce..dd430465 100644 --- a/backend/internal/devicelogin/service.go +++ b/backend/internal/devicelogin/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "strings" "time" @@ -35,22 +36,26 @@ type Service struct { signer TokenService reauth ReauthenticationTokenConsumer auditLog AuditLogger + ipLocator IPLocationResolver } type VerificationInfo struct { UserCode string Device string IPAddress string + Country string + City string ExpiresAt datatype.DateTime } -func NewService(actService *actor.Service, db *gorm.DB, signer TokenService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *Service { +func NewService(actService *actor.Service, db *gorm.DB, signer TokenService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, ipLocator IPLocationResolver) *Service { return &Service{ actService: actService, db: db, signer: signer, reauth: reauth, auditLog: auditLog, + ipLocator: ipLocator, } } @@ -111,10 +116,17 @@ func (s *Service) Inspect(ctx context.Context, code string) (VerificationInfo, e return VerificationInfo{}, err } + country, city, err := s.ipLocator.GetLocationByIP(result.IPAddress) + if err != nil { + slog.WarnContext(ctx, "Failed to get device login request IP location", slog.String("ip", result.IPAddress), slog.Any("error", err)) + } + return VerificationInfo{ UserCode: result.UserCode, Device: s.auditLog.DeviceStringFromUserAgent(result.UserAgent), IPAddress: result.IPAddress, + Country: country, + City: city, ExpiresAt: datatype.DateTime(result.ExpiresAt), }, nil } diff --git a/backend/internal/devicelogin/service_test.go b/backend/internal/devicelogin/service_test.go index 5ab349f1..918e8303 100644 --- a/backend/internal/devicelogin/service_test.go +++ b/backend/internal/devicelogin/service_test.go @@ -81,6 +81,16 @@ type fakeAuditLogger struct { entries []auditEntry } +type fakeIPLocationResolver struct { + country string + city string + err error +} + +func (f *fakeIPLocationResolver) GetLocationByIP(string) (string, string, error) { + return f.country, f.city, f.err +} + func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) { f.mu.Lock() defer f.mu.Unlock() @@ -138,6 +148,8 @@ func TestRequestLifecycle(t *testing.T) { require.Equal(t, request.Code, info.UserCode) require.Equal(t, "192.0.2.10", info.IPAddress) require.Equal(t, "Parsed Mozilla/5.0 Chrome/125.0.0.0", info.Device) + require.Equal(t, "Switzerland", info.Country) + require.Equal(t, "Zurich", info.City) err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof") require.NoError(t, err) @@ -475,16 +487,18 @@ func newServiceFixture(t *testing.T, db *gorm.DB) serviceFixture { t.Helper() signer := &fakeTokenService{} auditLog := &fakeAuditLogger{} + ipLocator := &fakeIPLocationResolver{country: "Switzerland", city: "Zurich"} reauth := &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"} var module *Module host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) { var err error module, err = New(Dependencies{ - DB: db, - Actors: host, - Signer: signer, - AuditLog: auditLog, - Reauth: reauth, + DB: db, + Actors: host, + Signer: signer, + AuditLog: auditLog, + IPLocator: ipLocator, + Reauth: reauth, }) require.NoError(t, err) }) @@ -519,10 +533,11 @@ func assertInvalidRequestError(t *testing.T, err error) { func persistentTestDependencies(db *gorm.DB) Dependencies { return Dependencies{ - DB: db, - Signer: &fakeTokenService{}, - AuditLog: &fakeAuditLogger{}, - Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}, + DB: db, + Signer: &fakeTokenService{}, + AuditLog: &fakeAuditLogger{}, + IPLocator: &fakeIPLocationResolver{}, + Reauth: &fakeReauthenticationTokenConsumer{expectedValue: "fresh-proof"}, } } diff --git a/frontend/src/lib/types/device-login.type.ts b/frontend/src/lib/types/device-login.type.ts index 8a33f1c4..5345cf69 100644 --- a/frontend/src/lib/types/device-login.type.ts +++ b/frontend/src/lib/types/device-login.type.ts @@ -13,6 +13,8 @@ export type DeviceLoginVerificationInfo = { userCode: string; device: string; ipAddress?: string; + country?: string; + city?: string; expiresAt: string; }; diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte index 0ee4e52e..e9a714b3 100644 --- a/frontend/src/routes/device/+page.svelte +++ b/frontend/src/routes/device/+page.svelte @@ -44,6 +44,11 @@ ); let codeComplete = $derived(normalizedUserCode.length === 8); let completed = $derived(success || deviceLoginOutcome !== undefined); + let deviceLoginLocation = $derived( + deviceLoginInfo?.city && deviceLoginInfo.country + ? `${deviceLoginInfo.city}, ${deviceLoginInfo.country}` + : deviceLoginInfo?.city || deviceLoginInfo?.country || m.unknown() + ); onMount(() => { if (data.code && $userStore) { @@ -191,6 +196,10 @@