mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-22 02:49:04 +02:00
feat: display country and city on qr code login approval screen
This commit is contained in:
@@ -104,6 +104,7 @@ func initServices(
|
||||
Signer: svc.jwtService,
|
||||
Reauth: svc.webauthnModule,
|
||||
AuditLog: svc.auditLogService,
|
||||
IPLocator: svc.geoLiteService,
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260723000000,
|
||||
"version": 20260727120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user