From 9e72bc1c70506146919a21058f5e20b0f042082a Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 30 Jul 2026 00:22:31 +0200 Subject: [PATCH] feat: display country and city on qr code login approval screen --- .../internal/bootstrap/services_bootstrap.go | 1 + backend/internal/devicelogin/dto.go | 2 + backend/internal/devicelogin/module.go | 7 +- backend/internal/devicelogin/service.go | 14 +++- backend/internal/devicelogin/service_test.go | 33 +++++--- frontend/src/lib/types/device-login.type.ts | 2 + frontend/src/routes/device/+page.svelte | 9 +++ tests/resources/export/database.json | 2 +- tests/specs/one-time-access-token.spec.ts | 76 ++++++++++++++----- 9 files changed, 115 insertions(+), 31 deletions(-) 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 @@
{m.ip_address()}
{deviceLoginInfo.ipAddress || m.unknown()}
+
+
{m.approximate_location()}
+
{deviceLoginLocation}
+
diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 2051531a..8c699ee3 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20260723000000, + "version": 20260727120000, "tableOrder": [ "users", "user_groups", diff --git a/tests/specs/one-time-access-token.spec.ts b/tests/specs/one-time-access-token.spec.ts index 25506aad..4e7347b9 100644 --- a/tests/specs/one-time-access-token.spec.ts +++ b/tests/specs/one-time-access-token.spec.ts @@ -1,6 +1,7 @@ -import test, { expect } from '@playwright/test'; +import test, { expect, type Browser } from '@playwright/test'; import { oneTimeAccessTokens } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; +import { pathFromRoot } from '../utils/fs.util'; test.beforeEach(async () => await cleanupBackend()); @@ -14,19 +15,6 @@ test('Sign in with login code', async ({ page }) => { await page.waitForURL('/settings/account'); }); -test('Sign in with login code entered manually', async ({ page }) => { - const token = oneTimeAccessTokens.filter((t) => !t.expired)[0]; - await page.goto('/lc'); - - await page.getByText('I have a longer code').click(); - - await page.getByPlaceholder('Code').first().fill(token.token); - - await page.getByText('Submit').first().click(); - - await page.waitForURL('/settings/account'); -}); - test('Sign in with expired login code fails', async ({ page }) => { const token = oneTimeAccessTokens.filter((t) => t.expired)[0]; await page.goto(`/lc/${token.token}`); @@ -36,17 +24,67 @@ test('Sign in with expired login code fails', async ({ page }) => { ); }); -test('Sign in with login code entered manually fails', async ({ page }) => { - const token = oneTimeAccessTokens.filter((t) => t.expired)[0]; +test('Sign in with login code entered manually', async ({ page }) => { + const token = oneTimeAccessTokens.find((t) => !t.expired)!; await page.goto('/lc'); - await page.getByText('I have a longer code').click(); + await page.getByPlaceholder('Code').fill(token.token); + await page.getByText('Submit').click(); - await page.getByPlaceholder('Code').first().fill(token.token); + await page.waitForURL('/settings/account'); +}); - await page.getByText('Submit').first().click(); +test('Sign in with login code entered manually fails', async ({ page }) => { + const token = oneTimeAccessTokens.find((t) => t.expired)!; + await page.goto('/lc'); + + await page.getByPlaceholder('Code').fill(token.token); + await page.getByText('Submit').click(); await expect(page.getByRole('paragraph')).toHaveText( 'Token is invalid or expired. Please try again.' ); }); + +test('Sign in with login code entered manually when email login is enabled', async ({ + browser, + page +}) => { + await setEmailLoginEnabled(browser); + + const token = oneTimeAccessTokens.find((t) => !t.expired)!; + await page.goto('/lc'); + + await page.getByText('I have a longer code').click(); + await page.getByPlaceholder('Code').fill(token.token); + await page.getByText('Submit').click(); + + await page.waitForURL('/settings/account'); +}); + +async function setEmailLoginEnabled(browser: Browser) { + const context = await browser.newContext({ + baseURL: test.info().project.use.baseURL, + storageState: pathFromRoot('.tmp/auth/user.json') + }); + const page = await context.newPage(); + + try { + const configResponse = await page.request.get('/api/application-configuration/all'); + expect(configResponse.ok()).toBe(true); + + const config = Object.fromEntries( + ((await configResponse.json()) as Array<{ key: string; value: string }>).map( + ({ key, value }) => [key, value] + ) + ); + config.emailOneTimeAccessAsUnauthenticatedEnabled = 'true'; + + const updateResponse = await page.request.put('/api/application-configuration', { + data: config + }); + expect(updateResponse.ok()).toBe(true); + } finally { + await context.close(); + } +}