feat: display country and city on qr code login approval screen

This commit is contained in:
Elias Schneider
2026-07-30 00:22:31 +02:00
parent 0c27f733b9
commit 9e72bc1c70
9 changed files with 115 additions and 31 deletions
@@ -104,6 +104,7 @@ func initServices(
Signer: svc.jwtService,
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogService,
IPLocator: svc.geoLiteService,
AppConfig: svc.appConfigService,
})
if err != nil {
+2
View File
@@ -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"`
}
+6 -1
View File
@@ -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),
+13 -1
View File
@@ -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
}
+24 -9
View File
@@ -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"},
}
}
@@ -13,6 +13,8 @@ export type DeviceLoginVerificationInfo = {
userCode: string;
device: string;
ipAddress?: string;
country?: string;
city?: string;
expiresAt: string;
};
+9
View File
@@ -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 @@
<dt class="text-muted-foreground">{m.ip_address()}</dt>
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.approximate_location()}</dt>
<dd class="text-right font-medium">{deviceLoginLocation}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260723000000,
"version": 20260727120000,
"tableOrder": [
"users",
"user_groups",
+57 -19
View File
@@ -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();
}
}