From 01689f77bca2ff395105da1c9d2bb5a284f4fcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:11 +0200 Subject: [PATCH 1/4] feat(security): require fresh verification before destructive account actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account deletion and the keep-account data reset now demand a recent second-factor re-verification for accounts that have a second factor active; single-factor accounts are unaffected and keep the typed- confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled, which is cookie-only by construction — a Bearer transport for an MFA-enrolled account cannot satisfy step-up and surfaces the auth.stepup.required envelope so the UI can launch a re-verification flow. --- src/app/api/settings/account/route.ts | 13 +- src/app/api/settings/data/route.ts | 12 +- .../require-fresh-mfa-if-enrolled.test.ts | 117 ++++++++++++++++++ src/lib/api-handler.ts | 28 +++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index d5f0d3da3..28d6e584d 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -3,7 +3,11 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; @@ -11,9 +15,14 @@ export const dynamic = "force-dynamic"; /** * Permanently delete the user account and ALL associated data. * Cascading deletes in the schema handle all related records. + * + * v1.23 — for an account with a second factor active this requires a fresh + * step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed + * confirmation, so a hijacked live session cannot erase the record. Accounts + * without MFA are unaffected and keep the typed-confirmation-only contract. */ export const DELETE = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); let confirm = ""; try { diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index b537b765e..e938a843b 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -2,7 +2,11 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -10,9 +14,13 @@ export const dynamic = "force-dynamic"; /** * Delete all user-owned health/integration data while keeping the account. + * + * v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account + * deletion route); single-factor accounts keep the typed-confirmation-only + * contract. */ export const DELETE = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); let confirm = ""; try { diff --git a/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts new file mode 100644 index 000000000..a05a523a2 --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa-if-enrolled.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirror require-fresh-mfa.test.ts mocking so the api-handler module loads +// without a live DB / request scope. +vi.mock("@/lib/db", () => ({ + prisma: { + session: { findUnique: vi.fn() }, + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { + requireFreshMfaIfEnrolled, + StepUpRequiredError, + HttpError, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "../api-handler"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { headers } from "next/headers"; +import { auditLog } from "@/lib/auth/audit"; + +const MFA_USER = { + id: "user-1", + role: "USER" as const, + username: "u", + totpConfirmedAt: new Date("2020-01-01"), +}; +const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null }; + +beforeEach(() => { + vi.resetAllMocks(); + headersGet.mockReturnValue(null); + // resetAllMocks wipes the inline mock factory implementation too; restore + // the headers() wrapper so requireAuth's Bearer branch can read the header. + vi.mocked(headers).mockImplementation( + async () => ({ get: headersGet }) as never, + ); + // resetAllMocks wipes the factory mockResolvedValue; auditLog is awaited + // via `.catch(...)` in the Bearer path, so it must return a thenable. + vi.mocked(auditLog).mockResolvedValue(undefined); +}); + +function mockCookieSession(user: unknown) { + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user, + } as never); +} + +describe("requireFreshMfaIfEnrolled", () => { + it("passes a non-MFA user straight through without a freshness read", async () => { + mockCookieSession(PLAIN_USER); + const ctx = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); + + it("passes an MFA user with a fresh verification", async () => { + mockCookieSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), + } as never); + const ctx = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); + expect(ctx.user.id).toBe("user-1"); + }); + + it("blocks an MFA user without a fresh verification", async () => { + mockCookieSession(MFA_USER); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + await expect( + requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS), + ).rejects.toBeInstanceOf(StepUpRequiredError); + }); + + it("a Bearer token can never satisfy step-up for an MFA-enrolled account", async () => { + // requireAuth resolves the Bearer caller (no cookie), but the step-up read + // is cookie-only — getSession returns null inside requireFreshMfa. + vi.mocked(getSession).mockResolvedValue(null as never); + headersGet.mockImplementation((n) => + n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null, + ); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + + await expect( + requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS), + ).rejects.toBeInstanceOf(HttpError); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index d0761558f..e95b4ca80 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -557,6 +557,34 @@ export async function requireFreshMfa( return { ...sessionData, mfaVerifiedAt: verifiedAt }; } +/** + * v1.23 — conditional step-up for destructive account actions. + * + * Resolves the caller with the standard `requireAuth()` (cookie OR Bearer). + * For an account WITHOUT a confirmed second factor the caller passes straight + * through — a single-factor user is intentionally unaffected, so account + * deletion / data reset keeps its existing typed-confirmation-only contract. + * For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs + * `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled + * account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot + * satisfy step-up, surfacing `StepUpRequiredError` (401, + * `errorCode: "auth.stepup.required"`) so the UI launches a re-verification. + * + * Gating only the MFA-enrolled cohort keeps the boundary structural: a + * hijacked live cookie session for an MFA user cannot nuke the record without + * a fresh factor, while users who never opted into MFA are not forced through + * a flow they have no way to complete. + */ +export async function requireFreshMfaIfEnrolled( + maxAgeSeconds: number, +): Promise { + const auth = await requireAuth(); + if (auth.user.totpConfirmedAt) { + await requireFreshMfa(maxAgeSeconds); + } + return auth; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. From b882698a44cf5d80bdb63fe2539f3fee1f98bfb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:20 +0200 Subject: [PATCH 2/4] feat(auth): warn on breached passwords at password set Check a newly chosen password against HaveIBeenPwned through the k-anonymity range API: only the first five hex characters of the SHA-1 digest leave the host, the suffix is matched locally, and Add-Padding hides the bucket size. A confirmed match is rejected so a fresh credential is never a known-breached one; the check fails open, so an unreachable HIBP never blocks the operation. Covers registration, password change, and admin reset. Existing passwords are never retroactively blocked at login. Also lands the six-locale strings for the password-breach notice and the account-security surfaces that follow. --- messages/de.json | 42 ++++++++- messages/en.json | 42 ++++++++- messages/es.json | 42 ++++++++- messages/fr.json | 42 ++++++++- messages/it.json | 42 ++++++++- messages/pl.json | 42 ++++++++- .../admin/users/[id]/reset-password/route.ts | 12 +++ src/app/api/auth/password/route.ts | 14 +++ src/lib/auth/__tests__/hibp.test.ts | 90 +++++++++++++++++++ src/lib/auth/hibp.ts | 77 ++++++++++++++++ 10 files changed, 427 insertions(+), 18 deletions(-) create mode 100644 src/lib/auth/__tests__/hibp.test.ts create mode 100644 src/lib/auth/hibp.ts diff --git a/messages/de.json b/messages/de.json index 523158b31..8f7d31981 100644 --- a/messages/de.json +++ b/messages/de.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.", "passwordMinLength": "Min. 12 Zeichen", "passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.", - "privacyPolicy": "Datenschutzerklärung" + "privacyPolicy": "Datenschutzerklärung", + "passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes." }, "dashboard": { "title": "Dashboard", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben." + "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "security": { + "sessionsTitle": "Aktive Sitzungen", + "sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.", + "sessionsLoadError": "Aktive Sitzungen konnten nicht geladen werden.", + "sessionsEmpty": "Keine aktiven Sitzungen.", + "currentSession": "Dieses Gerät", + "lastActive": "Zuletzt aktiv {time}", + "unknownLocation": "Unbekannter Ort", + "revokeSession": "Abmelden", + "revokeError": "Anfrage konnte nicht abgeschlossen werden. Bitte erneut versuchen.", + "signOutEverywhere": "Überall sonst abmelden", + "signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet.", + "activityTitle": "Sicherheitsaktivität", + "activityDescription": "Letzte Anmeldungen, Sicherheitsänderungen und Datenexporte deines Kontos.", + "activityEmpty": "Noch keine Sicherheitsaktivität.", + "activityLoadError": "Sicherheitsaktivität konnte nicht geladen werden.", + "action": { + "newDevice": "Anmeldung von neuem Gerät", + "loginFailed": "Fehlgeschlagene Anmeldung", + "login": "Anmeldung", + "passwordChange": "Passwort geändert", + "mfa": "Zwei-Faktor-Änderung", + "sessionRevoke": "Sitzung abgemeldet", + "token": "API-Token-Änderung", + "accountDelete": "Konto gelöscht", + "dataClear": "Daten zurückgesetzt", + "export": "Datenexport", + "other": "Sicherheitsereignis" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Mess-Erinnerung", "eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.", "eventMedicationIntakeSync": "Einnahme-Synchronisierung", - "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird." + "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", + "security": { + "newDeviceTitle": "Neue Anmeldung bei deinem Konto", + "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", + "unknownLocation": "unbekannter Ort" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index 16ec52b02..5da217315 100644 --- a/messages/en.json +++ b/messages/en.json @@ -263,7 +263,8 @@ "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", "passwordTooShort": "Password must be at least {minLength} characters long.", - "privacyPolicy": "Privacy policy" + "privacyPolicy": "Privacy policy", + "passwordBreached": "This password has appeared in known data breaches. Please choose a different one." }, "dashboard": { "title": "Dashboard", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above." + "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "security": { + "sessionsTitle": "Active sessions", + "sessionsDescription": "Devices and browsers currently signed in to your account.", + "sessionsLoadError": "Active sessions could not be loaded.", + "sessionsEmpty": "No active sessions.", + "currentSession": "This device", + "lastActive": "Last active {time}", + "unknownLocation": "Unknown location", + "revokeSession": "Sign out", + "revokeError": "Could not complete the request. Please try again.", + "signOutEverywhere": "Sign out everywhere else", + "signOutEverywhereDone": "Signed out {count} other session(s).", + "activityTitle": "Security activity", + "activityDescription": "Recent sign-ins, security changes, and data exports on your account.", + "activityEmpty": "No security activity yet.", + "activityLoadError": "Security activity could not be loaded.", + "action": { + "newDevice": "New-device sign-in", + "loginFailed": "Failed sign-in", + "login": "Sign-in", + "passwordChange": "Password changed", + "mfa": "Two-factor change", + "sessionRevoke": "Session signed out", + "token": "API token change", + "accountDelete": "Account deleted", + "dataClear": "Data reset", + "export": "Data export", + "other": "Security event" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Measurement reminder", "eventMeasurementReminderDesc": "Sent when a tracked measurement is due.", "eventMedicationIntakeSync": "Medication intake sync", - "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped." + "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", + "security": { + "newDeviceTitle": "New sign-in to your account", + "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", + "unknownLocation": "unknown location" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index 8bea74277..4548ca6b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.", "passwordMinLength": "Mín. 12 caracteres", "passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.", - "privacyPolicy": "Política de privacidad" + "privacyPolicy": "Política de privacidad", + "passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra." }, "dashboard": { "title": "Panel", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba." + "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "security": { + "sessionsTitle": "Sesiones activas", + "sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.", + "sessionsLoadError": "No se pudieron cargar las sesiones activas.", + "sessionsEmpty": "No hay sesiones activas.", + "currentSession": "Este dispositivo", + "lastActive": "Última actividad {time}", + "unknownLocation": "Ubicación desconocida", + "revokeSession": "Cerrar sesión", + "revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.", + "signOutEverywhere": "Cerrar sesión en los demás dispositivos", + "signOutEverywhereDone": "Se cerraron {count} sesión(es).", + "activityTitle": "Actividad de seguridad", + "activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.", + "activityEmpty": "Aún no hay actividad de seguridad.", + "activityLoadError": "No se pudo cargar la actividad de seguridad.", + "action": { + "newDevice": "Inicio desde nuevo dispositivo", + "loginFailed": "Inicio de sesión fallido", + "login": "Inicio de sesión", + "passwordChange": "Contraseña cambiada", + "mfa": "Cambio de doble factor", + "sessionRevoke": "Sesión cerrada", + "token": "Cambio de token de API", + "accountDelete": "Cuenta eliminada", + "dataClear": "Datos restablecidos", + "export": "Exportación de datos", + "other": "Evento de seguridad" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Recordatorio de medición", "eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.", "eventMedicationIntakeSync": "Sincronización de tomas", - "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis." + "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", + "security": { + "newDeviceTitle": "Nuevo inicio de sesión en tu cuenta", + "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", + "unknownLocation": "ubicación desconocida" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index d943773ff..fe6010b7f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.", "passwordMinLength": "Min. 12 caractères", "passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.", - "privacyPolicy": "Politique de confidentialité" + "privacyPolicy": "Politique de confidentialité", + "passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre." }, "dashboard": { "title": "Tableau de bord", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus." + "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "security": { + "sessionsTitle": "Sessions actives", + "sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.", + "sessionsLoadError": "Impossible de charger les sessions actives.", + "sessionsEmpty": "Aucune session active.", + "currentSession": "Cet appareil", + "lastActive": "Dernière activité {time}", + "unknownLocation": "Lieu inconnu", + "revokeSession": "Déconnecter", + "revokeError": "Impossible de traiter la demande. Veuillez réessayer.", + "signOutEverywhere": "Se déconnecter partout ailleurs", + "signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).", + "activityTitle": "Activité de sécurité", + "activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.", + "activityEmpty": "Aucune activité de sécurité pour l'instant.", + "activityLoadError": "Impossible de charger l'activité de sécurité.", + "action": { + "newDevice": "Connexion depuis un nouvel appareil", + "loginFailed": "Échec de connexion", + "login": "Connexion", + "passwordChange": "Mot de passe modifié", + "mfa": "Modification de la double authentification", + "sessionRevoke": "Session déconnectée", + "token": "Modification de jeton API", + "accountDelete": "Compte supprimé", + "dataClear": "Données réinitialisées", + "export": "Export de données", + "other": "Événement de sécurité" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Rappel de mesure", "eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.", "eventMedicationIntakeSync": "Synchronisation des prises", - "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée." + "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", + "security": { + "newDeviceTitle": "Nouvelle connexion à votre compte", + "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", + "unknownLocation": "lieu inconnu" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 38c204fbb..8b3867b5c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.", "passwordMinLength": "Min. 12 caratteri", "passwordTooShort": "La password deve avere almeno {minLength} caratteri.", - "privacyPolicy": "Informativa sulla privacy" + "privacyPolicy": "Informativa sulla privacy", + "passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra." }, "dashboard": { "title": "Cruscotto", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra." + "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "security": { + "sessionsTitle": "Sessioni attive", + "sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.", + "sessionsLoadError": "Impossibile caricare le sessioni attive.", + "sessionsEmpty": "Nessuna sessione attiva.", + "currentSession": "Questo dispositivo", + "lastActive": "Ultima attività {time}", + "unknownLocation": "Posizione sconosciuta", + "revokeSession": "Disconnetti", + "revokeError": "Impossibile completare la richiesta. Riprova.", + "signOutEverywhere": "Disconnetti ovunque", + "signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.", + "activityTitle": "Attività di sicurezza", + "activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.", + "activityEmpty": "Nessuna attività di sicurezza.", + "activityLoadError": "Impossibile caricare l'attività di sicurezza.", + "action": { + "newDevice": "Accesso da nuovo dispositivo", + "loginFailed": "Accesso non riuscito", + "login": "Accesso", + "passwordChange": "Password modificata", + "mfa": "Modifica autenticazione a due fattori", + "sessionRevoke": "Sessione disconnessa", + "token": "Modifica token API", + "accountDelete": "Account eliminato", + "dataClear": "Dati azzerati", + "export": "Esportazione dati", + "other": "Evento di sicurezza" + } + } }, "admin": { "title": "Administration", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Promemoria misurazione", "eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.", "eventMedicationIntakeSync": "Sincronizzazione assunzioni", - "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata." + "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", + "security": { + "newDeviceTitle": "Nuovo accesso al tuo account", + "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", + "unknownLocation": "posizione sconosciuta" + } }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..0232877e1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -263,7 +263,8 @@ "passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.", "passwordMinLength": "Min. 12 znaków", "passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.", - "privacyPolicy": "Polityka prywatności" + "privacyPolicy": "Polityka prywatności", + "passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne." }, "dashboard": { "title": "Pulpit", @@ -4968,7 +4969,37 @@ "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej." + "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "security": { + "sessionsTitle": "Aktywne sesje", + "sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.", + "sessionsLoadError": "Nie udało się załadować aktywnych sesji.", + "sessionsEmpty": "Brak aktywnych sesji.", + "currentSession": "To urządzenie", + "lastActive": "Ostatnia aktywność {time}", + "unknownLocation": "Nieznana lokalizacja", + "revokeSession": "Wyloguj", + "revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.", + "signOutEverywhere": "Wyloguj na pozostałych urządzeniach", + "signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.", + "activityTitle": "Aktywność zabezpieczeń", + "activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.", + "activityEmpty": "Brak aktywności zabezpieczeń.", + "activityLoadError": "Nie udało się załadować aktywności zabezpieczeń.", + "action": { + "newDevice": "Logowanie z nowego urządzenia", + "loginFailed": "Nieudane logowanie", + "login": "Logowanie", + "passwordChange": "Zmieniono hasło", + "mfa": "Zmiana uwierzytelniania dwuskładnikowego", + "sessionRevoke": "Wylogowano sesję", + "token": "Zmiana tokenu API", + "accountDelete": "Konto usunięte", + "dataClear": "Zresetowano dane", + "export": "Eksport danych", + "other": "Zdarzenie zabezpieczeń" + } + } }, "admin": { "title": "Administracja", @@ -5955,7 +5986,12 @@ "eventMeasurementReminder": "Przypomnienie o pomiarze", "eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.", "eventMedicationIntakeSync": "Synchronizacja przyjęć leku", - "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta." + "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", + "security": { + "newDeviceTitle": "Nowe logowanie do Twojego konta", + "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", + "unknownLocation": "nieznana lokalizacja" + } }, "i18n": { "maintainershipBanner": { diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index add5d7cd5..2da8adf47 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler( async ( @@ -65,6 +67,16 @@ export const POST = apiHandler( ); } + // v1.23 — reject an admin-set password found in a known breach corpus + // (HIBP k-anonymity). Fail-open on an unreachable HIBP. + const breach = await checkPasswordBreach(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); await prisma.user.update({ where: { id }, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 828304e8a..38cf11b42 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -19,6 +19,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -73,6 +75,18 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } + // v1.23 — reject a newly chosen password that appears in a known breach + // corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable) + // never blocks the change. Only the user's chosen NEW password is checked — + // existing credentials are never retroactively blocked at login. + const breach = await checkPasswordBreach(newPassword); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const newHash = await hashPassword(newPassword); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/auth/__tests__/hibp.test.ts b/src/lib/auth/__tests__/hibp.test.ts new file mode 100644 index 000000000..5d81bd3da --- /dev/null +++ b/src/lib/auth/__tests__/hibp.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHash } from "node:crypto"; + +vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ getEvent: () => null })); + +import { checkPasswordBreach } from "../hibp"; +import { safeFetch } from "@/lib/safe-fetch"; + +const PASSWORD = "correct horse battery staple"; +const DIGEST = createHash("sha1") + .update(PASSWORD, "utf8") + .digest("hex") + .toUpperCase(); +const PREFIX = DIGEST.slice(0, 5); +const SUFFIX = DIGEST.slice(5); + +function textResponse(body: string, ok = true): Response { + return { + ok, + text: async () => body, + } as unknown as Response; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("checkPasswordBreach — k-anonymity", () => { + it("sends ONLY the 5-char prefix and never the full hash or suffix", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse(`${SUFFIX}:42\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:9`), + ); + + await checkPasswordBreach(PASSWORD); + + const url = vi.mocked(safeFetch).mock.calls[0][0] as string; + expect(url).toBe(`https://api.pwnedpasswords.com/range/${PREFIX}`); + // The suffix and full digest must never leave the process. + expect(url).not.toContain(SUFFIX); + expect(url).not.toContain(DIGEST); + // Only 5 hex chars after the range/ segment. + const sent = url.split("/range/")[1]; + expect(sent).toHaveLength(5); + expect(sent).toMatch(/^[0-9A-F]{5}$/); + + // Add-Padding header is set so the response size doesn't leak the bucket. + const init = vi.mocked(safeFetch).mock.calls[0][1] as RequestInit; + expect((init.headers as Record)["Add-Padding"]).toBe( + "true", + ); + // requirePublicHost is pinned. + const opts = vi.mocked(safeFetch).mock.calls[0][2] as { + requirePublicHost?: boolean; + }; + expect(opts.requirePublicHost).toBe(true); + }); + + it("flags a breached password from a matching suffix", async () => { + vi.mocked(safeFetch).mockResolvedValue(textResponse(`${SUFFIX}:1337`)); + const res = await checkPasswordBreach(PASSWORD); + expect(res).toEqual({ breached: true, count: 1337 }); + }); + + it("matches the suffix case-insensitively", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse(`${SUFFIX.toLowerCase()}:5`), + ); + const res = await checkPasswordBreach(PASSWORD); + expect(res?.breached).toBe(true); + }); + + it("reports not-breached when the suffix is absent", async () => { + vi.mocked(safeFetch).mockResolvedValue( + textResponse("0000000000000000000000000000000000A:3"), + ); + const res = await checkPasswordBreach(PASSWORD); + expect(res).toEqual({ breached: false, count: 0 }); + }); + + it("fails open (null) on a non-200 response", async () => { + vi.mocked(safeFetch).mockResolvedValue(textResponse("", false)); + expect(await checkPasswordBreach(PASSWORD)).toBeNull(); + }); + + it("fails open (null) when the request throws (HIBP down)", async () => { + vi.mocked(safeFetch).mockRejectedValue(new Error("network")); + expect(await checkPasswordBreach(PASSWORD)).toBeNull(); + }); +}); diff --git a/src/lib/auth/hibp.ts b/src/lib/auth/hibp.ts new file mode 100644 index 000000000..6bc7ec6df --- /dev/null +++ b/src/lib/auth/hibp.ts @@ -0,0 +1,77 @@ +/** + * v1.23 — HaveIBeenPwned password-breach check via the k-anonymity range API. + * + * Privacy contract (the whole point of k-anonymity): the password is SHA-1'd + * locally and ONLY the first 5 hex characters of the uppercase digest leave the + * server. The remaining 35-character suffix is matched against the response + * locally; the full hash and the password itself never cross the network. The + * request carries `Add-Padding: true` so the response size does not leak which + * prefix bucket was queried. + * + * Resilience: any failure (egress disabled, HIBP down, timeout, non-200, + * malformed body) returns `null` — "unknown". Callers treat `null` as + * fail-open and never block on it; only a confirmed breach (`breached: true`) + * is actionable. The check is therefore safe to run inline at password-set + * time without making HIBP a hard dependency of account management. + */ +import { createHash } from "node:crypto"; +import { safeFetch } from "@/lib/safe-fetch"; +import { getEvent } from "@/lib/logging/context"; + +const HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/"; + +export interface BreachCheckResult { + breached: boolean; + /** Number of times the password appears in the corpus (0 when not found). */ + count: number; +} + +/** + * Returns the breach status of `password`, or `null` when the check could not + * be completed (fail-open). Never throws. + */ +export async function checkPasswordBreach( + password: string, +): Promise { + try { + const digest = createHash("sha1") + .update(password, "utf8") + .digest("hex") + .toUpperCase(); + const prefix = digest.slice(0, 5); + const suffix = digest.slice(5); + + const res = await safeFetch( + `${HIBP_RANGE_URL}${prefix}`, + { + method: "GET", + headers: { "Add-Padding": "true" }, + signal: AbortSignal.timeout(4000), + }, + // The host is a fixed, app-controlled public endpoint, but pin the + // public-host / DNS-rebinding guard anyway — it is a hard egress, and the + // SSRF floor is cheap. + { requirePublicHost: true }, + ); + + if (!res.ok) return null; + + const body = await res.text(); + // Each line: "<35-hex-suffix>:". Compare case-insensitively. + for (const line of body.split("\n")) { + const sep = line.indexOf(":"); + if (sep < 0) continue; + const lineSuffix = line.slice(0, sep).trim().toUpperCase(); + if (lineSuffix === suffix) { + const count = parseInt(line.slice(sep + 1).trim(), 10); + return { breached: true, count: Number.isFinite(count) ? count : 1 }; + } + } + return { breached: false, count: 0 }; + } catch (err) { + getEvent()?.addWarning( + `HIBP breach check unavailable (fail-open): ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} From bed65886995e0b46e91ffcc809275ee8290cbb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:31 +0200 Subject: [PATCH 3/4] feat(security): alert on a new-device sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a completed sign-in (password, second-factor, or passkey) from a device or location not seen before, send one notification through the dispatcher cascade, honouring the user's per-channel preferences via a new SECURITY_ALERT event type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the coarse User-Agent family, and a country/ASN-level geo signal, stored in the new user_known_devices ledger (migration 0203). The first sighting of a fingerprint alerts and records; later sightings only bump last-seen and stay silent. The raw User-Agent and IP are never persisted — only the salted hash and a coarse label. Registration records its device silently so a fresh account does not alert on its own first session. The whole path is fire-and-forget and never blocks or fails a sign-in. --- .../0203_v123_known_devices/migration.sql | 33 ++++ prisma/schema.prisma | 28 ++++ .../api/auth/passkey/login-verify/route.ts | 4 + src/app/api/auth/register/route.ts | 24 +++ .../auth/__tests__/device-fingerprint.test.ts | 117 ++++++++++++++ src/lib/auth/__tests__/login-alert.test.ts | 133 ++++++++++++++++ src/lib/auth/device-fingerprint.ts | 118 +++++++++++++++ src/lib/auth/login-alert.ts | 143 ++++++++++++++++++ src/lib/auth/login-response.ts | 7 + src/lib/notifications/types.ts | 11 ++ 10 files changed, 618 insertions(+) create mode 100644 prisma/migrations/0203_v123_known_devices/migration.sql create mode 100644 src/lib/auth/__tests__/device-fingerprint.test.ts create mode 100644 src/lib/auth/__tests__/login-alert.test.ts create mode 100644 src/lib/auth/device-fingerprint.ts create mode 100644 src/lib/auth/login-alert.ts diff --git a/prisma/migrations/0203_v123_known_devices/migration.sql b/prisma/migrations/0203_v123_known_devices/migration.sql new file mode 100644 index 000000000..d461c6bb3 --- /dev/null +++ b/prisma/migrations/0203_v123_known_devices/migration.sql @@ -0,0 +1,33 @@ +-- v1.23 — new-device sign-in alert dedupe ledger. +-- +-- One row per (user, coarse device fingerprint). The fingerprint +-- (`device_hash`) is a one-way SHA-256 hash of +-- `userId | normalised-User-Agent | coarse-location-or-asn`. The raw +-- User-Agent and IP are NEVER stored — the table holds only the salted hash +-- (the userId is part of the digest input, so the same browser on two +-- accounts produces two unrelated hashes) plus an optional coarse, +-- human-readable `label` for the security-activity surface (e.g. +-- "Firefox on macOS — Berlin, DE"; the label omits the IP). +-- +-- A successful sign-in upserts on (user_id, device_hash): the first sighting +-- of a hash inserts the row and fires one new-device notification through the +-- dispatcher; subsequent sightings only bump `last_seen_at` and stay silent. + +CREATE TABLE "user_known_devices" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "device_hash" TEXT NOT NULL, + "label" TEXT, + "first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_known_devices_pkey" PRIMARY KEY ("id") +); + +-- Dedupe is per-user: a coarse fingerprint can never be inserted twice for +-- one account, so "first sighting → alert" is a single unique-constrained +-- upsert with no race window. +CREATE UNIQUE INDEX "user_known_devices_user_id_device_hash_key" ON "user_known_devices"("user_id", "device_hash"); +CREATE INDEX "user_known_devices_user_id_idx" ON "user_known_devices"("user_id"); + +ALTER TABLE "user_known_devices" ADD CONSTRAINT "user_known_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf517cb88..9b974e000 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -575,6 +575,7 @@ model User { mfaChallenges MfaChallenge[] mfaRecoveryCodes MfaRecoveryCode[] webauthnMfaCredentials WebauthnMfaCredential[] + knownDevices UserKnownDevice[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] @@ -875,6 +876,33 @@ model WebauthnMfaCredential { @@map("webauthn_mfa_credentials") } +// v1.23 — login-alert dedupe ledger. One row per (user, coarse device +// fingerprint). The fingerprint is a one-way SHA-256 hash of +// `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw +// User-Agent or IP, so the table holds no directly identifying request +// metadata (`deviceHash` is salted by the userId, so the same browser on two +// accounts yields two unrelated hashes). `label` is a coarse, human-readable +// descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE"); +// it intentionally omits the IP. First sighting of a hash fires one new-device +// notification through the dispatcher; later sightings only bump `lastSeenAt`. +model UserKnownDevice { + id String @id @default(cuid()) + userId String @map("user_id") + deviceHash String @map("device_hash") + label String? + firstSeenAt DateTime @default(now()) @map("first_seen_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Dedupe is per-user: the same coarse fingerprint can never be inserted + // twice for one account, so the "first sighting → alert" decision is a + // single unique-constrained upsert. + @@unique([userId, deviceHash]) + @@index([userId]) + @@map("user_known_devices") +} + model AuthChallenge { id String @id @default(cuid()) userId String? @map("user_id") // null for registration diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 18178dd64..c041ab07f 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,5 +1,6 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { prisma } from "@/lib/db"; @@ -76,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ipAddress: ip, }); + // v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin). + void recordSignInDevice({ userId: user.id, ip, userAgent: ua }); + annotate({ action: { name: "auth.login.passkey" } }); if ( diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index e97fcaebe..fb74e611e 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,6 +6,7 @@ import { import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { isValidTimezone, resolveServerDefaultTimezone, @@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => { 422, ); } + + // v1.23 — reject a registration password found in a known breach corpus + // (HIBP k-anonymity). Fail-open on an unreachable HIBP. + const breach = await checkPasswordBreach(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); // v1.15.20 — consume the invite LAST, after every other validation, @@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => { const ua = request.headers.get("user-agent"); await createSession(user.id, true, ip, ua); + // v1.23 — record the registering device silently so the account's first + // login from this same device does not immediately fire a "new device" + // alert. A genuinely new device later still alerts on its first sighting. + void recordSignInDevice({ + userId: user.id, + ip, + userAgent: ua, + alertOnNew: false, + }); + // Stamp the consumer onto the invite (informational, best-effort — // the use itself was already counted atomically above). if (inviteId) { diff --git a/src/lib/auth/__tests__/device-fingerprint.test.ts b/src/lib/auth/__tests__/device-fingerprint.test.ts new file mode 100644 index 000000000..5bfbff2f0 --- /dev/null +++ b/src/lib/auth/__tests__/device-fingerprint.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + coarseDeviceLabel, + normaliseUserAgent, + coarseLocationSignal, + computeDeviceHash, + maskIp, +} from "../device-fingerprint"; + +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; +const FIREFOX_MAC_MINOR = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"; +const CHROME_WIN = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; + +describe("coarseDeviceLabel", () => { + it("reduces a UA to family + platform", () => { + expect(coarseDeviceLabel(FIREFOX_MAC)).toBe("Firefox on macOS"); + expect(coarseDeviceLabel(CHROME_WIN)).toBe("Chrome on Windows"); + }); + it("handles a missing UA", () => { + expect(coarseDeviceLabel(null)).toBe("Unknown device"); + expect(coarseDeviceLabel("")).toBe("Unknown device"); + }); +}); + +describe("coarseLocationSignal", () => { + it("reduces a city string to its country token", () => { + expect(coarseLocationSignal("Berlin, DE")).toBe("DE"); + }); + it("returns empty for no location", () => { + expect(coarseLocationSignal(null)).toBe(""); + }); +}); + +describe("computeDeviceHash", () => { + it("is deterministic and a hex SHA-256 (no raw UA/IP leak)", () => { + const h = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(h).toMatch(/^[0-9a-f]{64}$/); + // The hash must not embed the raw UA. + expect(h).not.toContain("Firefox"); + expect(h).not.toContain("Mozilla"); + expect( + computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }), + ).toBe(h); + }); + + it("is invariant to a minor browser-version bump (no false new-device)", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC_MINOR, + coarseSignal: "DE", + }); + expect(a).toBe(b); + }); + + it("is salted per user — same browser, different account ⇒ different hash", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u2", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + expect(a).not.toBe(b); + }); + + it("treats a different country as a new device", () => { + const a = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "DE", + }); + const b = computeDeviceHash({ + userId: "u1", + userAgent: FIREFOX_MAC, + coarseSignal: "US", + }); + expect(a).not.toBe(b); + }); +}); + +describe("normaliseUserAgent", () => { + it("matches the coarse label so the hash input is stable", () => { + expect(normaliseUserAgent(FIREFOX_MAC)).toBe("Firefox on macOS"); + }); +}); + +describe("maskIp", () => { + it("masks the host portion of an IPv4", () => { + expect(maskIp("203.0.113.42")).toBe("203.0.x.x"); + }); + it("masks an IPv6 down to two hextets", () => { + expect(maskIp("2001:db8:1234:5678::1")).toBe("2001:db8::"); + }); + it("returns null for a blank IP", () => { + expect(maskIp(null)).toBeNull(); + expect(maskIp("")).toBeNull(); + }); +}); diff --git a/src/lib/auth/__tests__/login-alert.test.ts b/src/lib/auth/__tests__/login-alert.test.ts new file mode 100644 index 000000000..f64d43aa8 --- /dev/null +++ b/src/lib/auth/__tests__/login-alert.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + userKnownDevice: { + findUnique: vi.fn(), + update: vi.fn(), + create: vi.fn(), + }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), + lookupIpAsn: vi.fn(() => null), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ dispatched: true }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: () => ({ t: (k: string) => k }), +})); + +vi.mock("@/lib/logging/context", () => ({ getEvent: () => null })); + +import { recordSignInDevice } from "../login-alert"; +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "en", + } as never); +}); + +describe("recordSignInDevice — new-device dedupe", () => { + it("first sighting records the device AND fires one alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: false, alerted: true }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; + expect(payload.eventType).toBe("SECURITY_ALERT"); + expect(payload.userId).toBe("u1"); + }); + + it("second sighting of the same device is silent (no alert)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({ + id: "kd-1", + } as never); + vi.mocked(prisma.userKnownDevice.update).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(prisma.userKnownDevice.update).toHaveBeenCalledTimes(1); + expect(prisma.userKnownDevice.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("records silently when alertOnNew is false (registration)", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + alertOnNew: false, + }); + + expect(res).toEqual({ known: false, alerted: false }); + expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("a unique-index race on create does not double-alert", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockRejectedValue( + new Error("unique violation"), + ); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: true, alerted: false }); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it("never persists the raw User-Agent — only the salted hash + coarse label", async () => { + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); + + await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + const data = vi.mocked(prisma.userKnownDevice.create).mock.calls[0][0].data; + expect(data.deviceHash).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(data)).not.toContain("Mozilla"); + expect(JSON.stringify(data)).not.toContain("203.0.113.9"); + // The coarse label is the family + platform descriptor. + expect(data.label).toContain("Firefox on macOS"); + }); +}); diff --git a/src/lib/auth/device-fingerprint.ts b/src/lib/auth/device-fingerprint.ts new file mode 100644 index 000000000..ecb645d1f --- /dev/null +++ b/src/lib/auth/device-fingerprint.ts @@ -0,0 +1,118 @@ +/** + * v1.23 — coarse device fingerprinting shared by the active-session list and + * the new-device sign-in alert. + * + * The fingerprint that backs login-alert dedupe is deliberately COARSE and + * one-way: a SHA-256 over `userId | normalised-User-Agent | coarse-signal`. + * The raw User-Agent and IP never leave this module into storage — only the + * salted hash (userId-salted, so the same browser on two accounts hashes + * differently) and a coarse human-readable label. A minor-version UA bump or a + * differently-formatted IP must NOT mint a "new device", so the UA is reduced + * to its family + platform and the geo signal to a country/ASN granularity. + */ +import { createHash } from "node:crypto"; + +/** + * Reduce a User-Agent to a stable family + platform descriptor, e.g. + * "Firefox on macOS". Minor versions, build numbers, and engine noise are + * dropped so a routine browser update does not register as a new device. + * Returns "Unknown device" when the UA is missing or unrecognised. + */ +export function coarseDeviceLabel( + userAgent: string | null | undefined, +): string { + const ua = (userAgent ?? "").trim(); + if (!ua) return "Unknown device"; + + const browser = /\bEdg(?:e|A|iOS)?\//.test(ua) + ? "Edge" + : /\bOPR\/|\bOpera\b/.test(ua) + ? "Opera" + : /\bFirefox\//.test(ua) + ? "Firefox" + : /\bChrome\//.test(ua) && !/\bChromium\//.test(ua) + ? "Chrome" + : /\bChromium\//.test(ua) + ? "Chromium" + : /\bSafari\//.test(ua) && /\bVersion\//.test(ua) + ? "Safari" + : /HealthLog-iOS/i.test(ua) + ? "HealthLog iOS app" + : null; + + const platform = /\bWindows NT\b/.test(ua) + ? "Windows" + : /\b(iPhone|iPad|iPod)\b/.test(ua) + ? "iOS" + : /\bMac OS X\b|\bMacintosh\b/.test(ua) + ? "macOS" + : /\bAndroid\b/.test(ua) + ? "Android" + : /\bLinux\b/.test(ua) + ? "Linux" + : null; + + if (browser && platform) return `${browser} on ${platform}`; + if (browser) return browser; + if (platform) return platform; + return "Unknown device"; +} + +/** + * Reduce a User-Agent to the stable string fed into the dedupe hash. Uses the + * coarse label so the hash is invariant to minor-version churn. + */ +export function normaliseUserAgent( + userAgent: string | null | undefined, +): string { + return coarseDeviceLabel(userAgent); +} + +/** + * Reduce a resolved geo string to a coarse country-level signal, e.g. + * "Berlin, DE" → "DE". Falls back to the whole string when there is no + * trailing country token, and to "" when no location resolved. Kept coarse so + * a user moving across a city/region does not register as a new device while a + * cross-country sign-in still does. + */ +export function coarseLocationSignal( + location: string | null | undefined, +): string { + const loc = (location ?? "").trim(); + if (!loc) return ""; + const parts = loc.split(",").map((p) => p.trim()); + return parts[parts.length - 1] || loc; +} + +/** + * One-way, per-user device fingerprint. The userId is part of the digest so + * the same browser on two accounts yields two unrelated hashes (no + * cross-account correlation), and the raw inputs are never recoverable. + */ +export function computeDeviceHash(params: { + userId: string; + userAgent: string | null | undefined; + coarseSignal: string; +}): string { + const material = `${params.userId}|${normaliseUserAgent(params.userAgent)}|${params.coarseSignal}`; + return createHash("sha256").update(material).digest("hex"); +} + +/** + * Mask an IP for the user-facing security surfaces: keep enough to recognise + * the network, drop the host. IPv4 → first two octets; IPv6 → first two + * hextets. Returns null for a null/blank input. + */ +export function maskIp(ip: string | null | undefined): string | null { + const raw = (ip ?? "").trim(); + if (!raw) return null; + if (raw.includes(":")) { + const groups = raw.split(":").filter((g) => g.length > 0); + return `${groups.slice(0, 2).join(":")}::`; + } + const octets = raw.split("."); + if (octets.length === 4) { + return `${octets[0]}.${octets[1]}.x.x`; + } + return raw; +} diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts new file mode 100644 index 000000000..4da185087 --- /dev/null +++ b/src/lib/auth/login-alert.ts @@ -0,0 +1,143 @@ +/** + * v1.23 — new-device / new-location sign-in alert. + * + * Called on every completed sign-in (password, MFA-verify, passkey). Computes + * a coarse, one-way device fingerprint (see `device-fingerprint.ts`) and: + * - first sighting of the fingerprint → record it AND fire one notification + * through the dispatcher cascade (respecting the user's per-channel prefs), + * - any later sighting → bump `lastSeenAt` only, stay silent (dedupe). + * + * The whole thing is best-effort and is invoked fire-and-forget from the login + * paths: a failure here must never block or fail a sign-in. The raw IP/UA are + * never persisted — only the salted hash + a coarse label (and the audit row, + * which already records IP/geo for `auth.*` events). + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { lookupIpLocation, lookupIpAsn } from "@/lib/geo"; +import { + coarseDeviceLabel, + coarseLocationSignal, + computeDeviceHash, +} from "@/lib/auth/device-fingerprint"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getEvent } from "@/lib/logging/context"; + +function isLocale(value: string | null | undefined): value is Locale { + return ( + value === "de" || + value === "en" || + value === "fr" || + value === "es" || + value === "it" || + value === "pl" + ); +} + +export interface RecordSignInResult { + known: boolean; + alerted: boolean; +} + +export async function recordSignInDevice(params: { + userId: string; + ip: string | null; + userAgent: string | null; + /** + * When false the device is recorded silently with no notification — used by + * account registration, where the very first device should not trigger a + * "new device" alert the moment the account is created. + */ + alertOnNew?: boolean; +}): Promise { + const { userId, ip, userAgent } = params; + const alertOnNew = params.alertOnNew ?? true; + + try { + // Coarse geo signal — best-effort. The location resolver is online-first + // with its own timeout; a miss falls back to the ASN number so a device + // on the same network still dedupes even without a city. + let location: string | null = null; + try { + location = await lookupIpLocation(ip); + } catch { + location = null; + } + const coarseSignal = + coarseLocationSignal(location) || lookupIpAsn(ip)?.asn?.toString() || ""; + + const deviceHash = computeDeviceHash({ userId, userAgent, coarseSignal }); + const deviceLabel = coarseDeviceLabel(userAgent); + const storedLabel = location ? `${deviceLabel} — ${location}` : deviceLabel; + + const existing = await prisma.userKnownDevice.findUnique({ + where: { userId_deviceHash: { userId, deviceHash } }, + select: { id: true }, + }); + + if (existing) { + await prisma.userKnownDevice + .update({ + where: { id: existing.id }, + data: { lastSeenAt: new Date() }, + }) + .catch(() => {}); + return { known: true, alerted: false }; + } + + // First sighting. Insert; if a concurrent first-login races us to the + // unique (userId, deviceHash) index, swallow it and treat as already known + // so we never double-alert. + try { + await prisma.userKnownDevice.create({ + data: { userId, deviceHash, label: storedLabel }, + }); + } catch { + return { known: true, alerted: false }; + } + + if (!alertOnNew) return { known: false, alerted: false }; + + await auditLog("auth.login.new_device", { + userId, + ipAddress: ip, + details: { device: deviceLabel }, + }); + + // Localise against the recipient's own locale, then fan out through the + // dispatcher (which applies the user's per-channel SECURITY_ALERT prefs). + let locale: Locale = defaultLocale; + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + if (isLocale(row?.locale)) locale = row.locale; + } catch { + locale = defaultLocale; + } + const { t } = getServerTranslator(locale); + const locationText = + location ?? t("notifications.security.unknownLocation"); + + await dispatchNotification({ + eventType: "SECURITY_ALERT", + userId, + title: t("notifications.security.newDeviceTitle"), + message: t("notifications.security.newDeviceBody", { + device: deviceLabel, + location: locationText, + }), + urgent: true, + }); + + return { known: false, alerted: true }; + } catch (err) { + getEvent()?.addWarning( + `recordSignInDevice failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return { known: false, alerted: false }; + } +} diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index 519c1f81d..d0998afd8 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -26,6 +26,7 @@ import { isCookielessNativeCaller, } from "@/lib/auth/native-client"; import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; import type { User } from "@/generated/prisma/client"; export interface FinishLoginParams { @@ -54,6 +55,12 @@ export async function finishLogin( ): Promise { const { user, request, ip, userAgent, source, mfaVerified } = params; + // v1.23 — new-device / new-location alert. Fire-and-forget: never let the + // device ledger or the dispatcher add latency to — or fail — a sign-in. Runs + // for every transport (web cookie + native bundle) since the factor checks + // are already done by the time finishLogin is reached. + void recordSignInDevice({ userId: user.id, ip, userAgent }); + if ( shouldIssueBearerToken(request.headers) || isNativeClientRequest(request.headers) diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index b338b566c..860e78af6 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -94,6 +94,13 @@ export const EVENT_TYPES = [ // user-visible Telegram message on every dose would be noise). Routed // through the dedicated intake-sync sender, not the alert cascade. "MEDICATION_INTAKE_SYNC", + // v1.23 — account-security alert (new-device / new-location sign-in). Fired + // by the login completion path on the first sighting of a coarse device + // fingerprint. ON by default at the channel layer: a sign-in the user did + // not make is exactly the case they need to hear about, so silence-by-default + // would defeat the feature. An explicit per-channel `NotificationPreference` + // row still wins for a user who opts out. + "SECURITY_ALERT", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -151,6 +158,10 @@ export const EVENT_DEFAULT_ENABLED: Record = { // toggle: this is plumbing that keeps the user's own devices coherent, // not a notification the user chooses to receive. MEDICATION_INTAKE_SYNC: true, + // v1.23 — ON by default; a new-device sign-in is a security event the user + // needs surfaced. An explicit per-channel `NotificationPreference` row + // (enabled=false) still suppresses it for a user who opts out. + SECURITY_ALERT: true, }; export const CHANNEL_TYPE_LABELS: Record = { From 9269043e91b483b3bbac799fa12269a940fdf98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:34:43 +0200 Subject: [PATCH 4/4] feat(security): list active sessions, sign out everywhere, and show security activity Add the user-facing account-security surface in Settings: - Active sessions (closes #64): GET /api/auth/me/sessions lists each browser login with a coarse device label, masked IP, resolved location, the sliding last-active stamp, and the current-session marker. DELETE the collection signs out everywhere else (keeps the current session, revokes other sessions and native refresh tokens, leaves API tokens alone); DELETE one revokes a single session, scoped to the owner so a foreign id 404s. - A sliding last-active stamp on the session, written throttled and fire-and-forget off the request-resolution path. - Security activity: GET /api/auth/me/security-activity, the shared read over the audit log filtered to sign-ins, second-factor changes, password changes, session revocations, exports, and deletions, with masked IPs. OpenAPI regenerated for the new endpoints. --- docs/api/openapi.yaml | 246 ++++++++++++++++++ .../api/auth/me/security-activity/route.ts | 70 +++++ src/app/api/auth/me/sessions/[id]/route.ts | 41 +++ .../auth/me/sessions/__tests__/route.test.ts | 142 ++++++++++ src/app/api/auth/me/sessions/route.ts | 105 ++++++++ .../settings/account-section/index.tsx | 8 + .../settings/security-activity-card.tsx | 125 +++++++++ .../settings/security-sessions-card.tsx | 183 +++++++++++++ .../auth/__tests__/security-activity.test.ts | 64 +++++ src/lib/auth/security-activity.ts | 41 +++ src/lib/auth/session.ts | 65 +++++ src/lib/openapi/routes/auth.ts | 144 +++++++++- src/lib/query-keys/auth.ts | 15 ++ .../sessions-and-known-devices.test.ts | 131 ++++++++++ 14 files changed, 1379 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/security-activity/route.ts create mode 100644 src/app/api/auth/me/sessions/[id]/route.ts create mode 100644 src/app/api/auth/me/sessions/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/sessions/route.ts create mode 100644 src/components/settings/security-activity-card.tsx create mode 100644 src/components/settings/security-sessions-card.tsx create mode 100644 src/lib/auth/__tests__/security-activity.test.ts create mode 100644 src/lib/auth/security-activity.ts create mode 100644 tests/integration/sessions-and-known-devices.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4055a7446..648c082e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1554,6 +1554,81 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/sessions: + get: + tags: + - Auth + summary: List active web sessions + description: "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device + label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from + /api/auth/me/devices (notification devices)." + responses: + "200": + description: Active sessions for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Auth + summary: Sign out everywhere else + description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current + session. API tokens are not touched (manage those under /settings/api-tokens). + responses: + "200": + description: Other sessions revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SignOutEverywhereEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/auth/me/sessions/{id}: + delete: + tags: + - Auth + summary: Revoke a single web session + description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never + another user's row). + responses: + "200": + description: Session revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRevokeEnvelope" + "401": *a1 + "404": + description: Session not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/auth/me/security-activity: + get: + tags: + - Auth + summary: List recent account-security activity + description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with + timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the + AuditLog store; no event detail bodies are surfaced." + responses: + "200": + description: Recent security events for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/SecurityActivityEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -11011,6 +11086,177 @@ components: - data - error additionalProperties: false + SessionListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SessionListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SessionListResponse: + type: object + properties: + sessions: + type: array + items: + type: object + properties: + id: + type: string + device: + type: string + description: Coarse device label derived from the User-Agent. + ipMasked: + anyOf: + - type: string + - type: "null" + description: IP with the host portion masked — never the full address. + location: + anyOf: + - type: string + - type: "null" + description: Resolved coarse location, when available. + lastActiveAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + isCurrent: + type: boolean + description: True for the session making this request. + required: + - id + - device + - ipMasked + - location + - lastActiveAt + - createdAt + - isCurrent + additionalProperties: false + required: + - sessions + additionalProperties: false + SignOutEverywhereEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SignOutEverywhereResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SignOutEverywhereResponse: + type: object + properties: + sessionsRevoked: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Number of OTHER sessions removed (the current one is kept). + required: + - sessionsRevoked + additionalProperties: false + SessionRevokeEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SecurityActivityResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SecurityActivityResponse: + type: object + properties: + events: + type: array + items: + type: object + properties: + action: + type: string + description: Audit action name, e.g. auth.login.password. + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + location: + anyOf: + - type: string + - type: "null" + ipMasked: + anyOf: + - type: string + - type: "null" + description: Host-masked IP. + carrier: + anyOf: + - type: string + - type: "null" + required: + - action + - createdAt + - location + - ipMasked + - carrier + additionalProperties: false + required: + - events + additionalProperties: false SyncStateEnvelope: type: object properties: diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts new file mode 100644 index 000000000..b7966da2e --- /dev/null +++ b/src/app/api/auth/me/security-activity/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/auth/me/security-activity + * + * v1.23 — the SHARED user-facing security-activity feed. Returns the caller's + * recent account-security audit events (logins, MFA, password change, token / + * session revocations, exports, deletions) with timestamp + resolved location + * + MASKED IP. The IP is never returned in full and no event detail body is + * surfaced — only the action name, time, and coarse geo. Consumed by the + * account-security surface and the privacy dashboard. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { securityActivityWhere } from "@/lib/auth/security-activity"; +import { maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export interface SecurityActivityDTO { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const limitParam = Number( + request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT, + ); + const limit = + Number.isFinite(limitParam) && limitParam > 0 + ? Math.min(Math.floor(limitParam), MAX_LIMIT) + : DEFAULT_LIMIT; + + const rows = await prisma.auditLog.findMany({ + where: securityActivityWhere(user.id), + orderBy: { createdAt: "desc" }, + take: limit, + select: { + action: true, + createdAt: true, + location: true, + ipAddress: true, + carrier: true, + }, + }); + + const events: SecurityActivityDTO[] = rows.map((r) => ({ + action: r.action, + createdAt: r.createdAt.toISOString(), + location: r.location ?? null, + ipMasked: maskIp(r.ipAddress), + carrier: r.carrier ?? null, + })); + + annotate({ + action: { name: "auth.security_activity.list" }, + meta: { event_count: events.length }, + }); + + return apiSuccess({ events }); +}); diff --git a/src/app/api/auth/me/sessions/[id]/route.ts b/src/app/api/auth/me/sessions/[id]/route.ts new file mode 100644 index 000000000..4a98aed9d --- /dev/null +++ b/src/app/api/auth/me/sessions/[id]/route.ts @@ -0,0 +1,41 @@ +/** + * DELETE /api/auth/me/sessions/[id] — revoke a single active web session. + * + * v1.23 — the deletion is scoped to the authenticated user (the Prisma `where` + * narrows on both id AND userId), so a caller can never revoke another user's + * session by guessing an id. A 404 is returned for a non-existent or + * not-owned id so the surface cannot probe foreign session ids. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { destroySessionById } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export const DELETE = apiHandler( + async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => { + const { user, session } = await requireAuth(); + const { id } = await ctx.params; + + const removed = await destroySessionById(user.id, id); + if (!removed) { + return apiError("Session not found", 404); + } + + await auditLog("auth.session.revoke", { + userId: user.id, + ipAddress: getClientIp(request), + details: { self: id === session.id }, + }); + + annotate({ + action: { name: "auth.session.revoke" }, + meta: { self: id === session.id }, + }); + + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts new file mode 100644 index 000000000..57ad51f46 --- /dev/null +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { session: { findMany: vi.fn() } }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), + destroyOtherSessions: vi.fn(), + destroySessionById: vi.fn(), +})); + +vi.mock("@/lib/geo", () => ({ + lookupIpLocation: vi.fn(async () => "Berlin, DE"), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, DELETE } from "../route"; +import { DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { + getSession, + destroyOtherSessions, + destroySessionById, +} from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) }, + user: { id: "user-1", username: "u", role: "USER" as const }, +}; + +function del(): NextRequest { + return new NextRequest("http://localhost/api/auth/me/sessions", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/auth/me/sessions", () => { + it("401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(401); + }); + + it("lists sessions with masked IP, location, and the current marker", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.session.findMany).mockResolvedValue([ + { + id: "sess-current", + ipAddress: "203.0.113.7", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", + lastActiveAt: new Date(), + createdAt: new Date(), + }, + { + id: "sess-other", + ipAddress: "198.51.100.4", + userAgent: + "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36", + lastActiveAt: null, + createdAt: new Date(), + }, + ] as never); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + const list = body.data.sessions; + expect(list).toHaveLength(2); + expect(list[0]).toMatchObject({ + id: "sess-current", + device: "Firefox on macOS", + ipMasked: "203.0.x.x", + location: "Berlin, DE", + isCurrent: true, + }); + expect(list[1].isCurrent).toBe(false); + // The full IP never appears in the response body. + expect(JSON.stringify(body)).not.toContain("203.0.113.7"); + }); +}); + +describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { + it("revokes other sessions and reports the count", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 }); + + const res = await DELETE(del()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.sessionsRevoked).toBe(3); + expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current"); + }); +}); + +describe("DELETE /api/auth/me/sessions/[id]", () => { + function ctx(id: string) { + return { params: Promise.resolve({ id }) }; + } + function delReq(id: string): NextRequest { + return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, { + method: "DELETE", + }); + } + + it("revokes a single owned session", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(true); + const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other")); + expect(res.status).toBe(200); + expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other"); + }); + + it("404 for a non-existent or not-owned id", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(destroySessionById).mockResolvedValue(false); + const res = await DELETE_ONE(delReq("nope"), ctx("nope")); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts new file mode 100644 index 000000000..bb3fcd05e --- /dev/null +++ b/src/app/api/auth/me/sessions/route.ts @@ -0,0 +1,105 @@ +/** + * GET /api/auth/me/sessions — list the user's active web sessions. + * DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER + * session (and native refresh tokens), keeping + * the caller's current session. Closes #64. + * + * v1.23 — the user-facing session/device-management surface. Distinct from + * `/api/auth/me/devices`, which lists APNs / Web-Push notification devices; + * this lists the authenticated `Session` rows (one per browser login). The list + * carries the masked IP + resolved coarse location + a coarse device label + + * the sliding `lastActiveAt` — never the full IP, never a credential. The + * caller's own row is flagged `isCurrent` so the UI can mark "this device". + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { destroyOtherSessions } from "@/lib/auth/session"; +import { lookupIpLocation } from "@/lib/geo"; +import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint"; + +export const dynamic = "force-dynamic"; + +export interface SessionDTO { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export const GET = apiHandler(async () => { + const { user, session } = await requireAuth(); + + const sessions = await prisma.session.findMany({ + where: { userId: user.id, expiresAt: { gt: new Date() } }, + orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } }, + select: { + id: true, + ipAddress: true, + userAgent: true, + lastActiveAt: true, + createdAt: true, + }, + }); + + // Resolve location once per distinct IP (the resolver caches internally, but + // de-duping keeps the await count to the number of unique networks). + const uniqueIps = Array.from( + new Set( + sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip), + ), + ); + const locationByIp = new Map(); + await Promise.all( + uniqueIps.map(async (ip) => { + try { + locationByIp.set(ip, await lookupIpLocation(ip)); + } catch { + locationByIp.set(ip, null); + } + }), + ); + + const result: SessionDTO[] = sessions.map((s) => ({ + id: s.id, + device: coarseDeviceLabel(s.userAgent), + ipMasked: maskIp(s.ipAddress), + location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null, + lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null, + createdAt: s.createdAt.toISOString(), + isCurrent: s.id === session.id, + })); + + annotate({ + action: { name: "auth.session.list" }, + meta: { session_count: result.length }, + }); + + return apiSuccess({ sessions: result }); +}); + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireAuth(); + + const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id); + + await auditLog("auth.session.revoke_others", { + userId: user.id, + ipAddress: getClientIp(request), + details: { sessionsRevoked }, + }); + + annotate({ + action: { name: "auth.session.revoke_others" }, + meta: { sessions_revoked: sessionsRevoked }, + }); + + return apiSuccess({ sessionsRevoked }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 4fc5df757..7ba648646 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -39,6 +39,8 @@ import { } from "./account-section-utils"; import { PasskeyListSection } from "./passkey-list-section"; import { AvatarSection } from "./avatar-section"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -589,6 +591,12 @@ export function AccountSection() { + {/* v1.23 — active-session management (issue #64) + the security-activity + feed. Both sit in the account/security block under the credential + controls above. */} + + + {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → Erweitert. It is a maintenance / reset action, not a profile or security control, so it sits beside Research Mode + the danger zone. */} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx new file mode 100644 index 000000000..7f7426984 --- /dev/null +++ b/src/components/settings/security-activity-card.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Security activity". + * + * A read-only feed of the user's recent account-security events (sign-ins, MFA + * changes, password changes, session revocations, exports, deletions) from the + * shared `GET /api/auth/me/security-activity` endpoint. Each row shows a + * readable action label, the resolved location, the masked IP, and the time. + */ +import { useQuery } from "@tanstack/react-query"; +import { Loader2, ShieldCheck } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface ActivityRow { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +} + +/** Map an audit action onto a stable, translatable label key. */ +function actionLabelKey(action: string): string { + if (action === "auth.login.new_device") + return "settings.security.action.newDevice"; + if (action === "auth.login.failed") + return "settings.security.action.loginFailed"; + if (action.startsWith("auth.login") || action === "auth.register") + return "settings.security.action.login"; + if (action.startsWith("auth.password")) + return "settings.security.action.passwordChange"; + if (action.startsWith("auth.mfa")) return "settings.security.action.mfa"; + if (action.startsWith("auth.session")) + return "settings.security.action.sessionRevoke"; + if (action.startsWith("auth.token") || action.startsWith("auth.bearer")) + return "settings.security.action.token"; + if (action === "user.account.delete") + return "settings.security.action.accountDelete"; + if (action === "user.data.clear") return "settings.security.action.dataClear"; + if (action.includes("export")) return "settings.security.action.export"; + return "settings.security.action.other"; +} + +export function SecurityActivityCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.securityActivity(), + queryFn: () => + apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), + enabled: isAuthenticated, + }); + + const events = data?.events ?? []; + + return ( + + +
+

+ {t("settings.security.activityDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.activityLoadError")} +

+ )} + + {!isLoading && !isError && events.length === 0 && ( +

+ {t("settings.security.activityEmpty")} +

+ )} + + {events.length > 0 && ( +
    + {events.map((e, idx) => ( +
  • +
    +

    + {t(actionLabelKey(e.action))} +

    +

    + {[ + e.location ?? t("settings.security.unknownLocation"), + e.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    +
    +

    + {new Date(e.createdAt).toLocaleString()} +

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx new file mode 100644 index 000000000..77f98cabb --- /dev/null +++ b/src/components/settings/security-sessions-card.tsx @@ -0,0 +1,183 @@ +"use client"; + +/** + * v1.23 — Settings → account → "Active sessions" (issue #64). + * + * Lists the user's active web sessions (device label, masked IP, resolved + * location, last-active time, current-device marker) and offers a per-session + * "sign out" plus a "sign out everywhere else" that revokes every other + * session (and native device logins) while keeping the current one. All reads + * + writes route through the centralised query-key factory. + */ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MonitorSmartphone } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiDelete } from "@/lib/api/api-fetch"; + +interface SessionRow { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +} + +export function SecuritySessionsCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.sessions(), + queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), + enabled: isAuthenticated, + }); + + const revokeOne = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`), + onSuccess: () => { + setStatus(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const revokeOthers = useMutation({ + mutationFn: () => + apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"), + onSuccess: (res) => { + setStatus( + t("settings.security.signOutEverywhereDone", { + count: res?.sessionsRevoked ?? 0, + }), + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.securityActivity(), + }); + }, + onError: () => setStatus(t("settings.security.revokeError")), + }); + + const sessions = data?.sessions ?? []; + const hasOthers = sessions.some((s) => !s.isCurrent); + + return ( + + +
+

+ {t("settings.security.sessionsDescription")} +

+ + {isLoading && ( + + )} + + {isError && ( +

+ {t("settings.security.sessionsLoadError")} +

+ )} + + {!isLoading && !isError && sessions.length === 0 && ( +

+ {t("settings.security.sessionsEmpty")} +

+ )} + + {sessions.length > 0 && ( +
    + {sessions.map((s) => ( +
  • +
    +

    + {s.device} + {s.isCurrent && ( + + {t("settings.security.currentSession")} + + )} +

    +

    + {[ + s.location ?? t("settings.security.unknownLocation"), + s.ipMasked, + ] + .filter(Boolean) + .join(" · ")} +

    + {s.lastActiveAt && ( +

    + {t("settings.security.lastActive", { + time: new Date(s.lastActiveAt).toLocaleString(), + })} +

    + )} +
    + {!s.isCurrent && ( + + )} +
  • + ))} +
+ )} + + {hasOthers && ( +
+ +
+ )} + + {status && ( +

+ {status} +

+ )} +
+
+ ); +} diff --git a/src/lib/auth/__tests__/security-activity.test.ts b/src/lib/auth/__tests__/security-activity.test.ts new file mode 100644 index 000000000..7575e15f0 --- /dev/null +++ b/src/lib/auth/__tests__/security-activity.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { + matchesSecurityActivity, + securityActivityWhere, +} from "../security-activity"; + +describe("matchesSecurityActivity", () => { + it("includes every auth.* event", () => { + for (const a of [ + "auth.login.password", + "auth.login.failed", + "auth.login.new_device", + "auth.mfa.verify", + "auth.password.change", + "auth.session.revoke", + "auth.session.revoke_others", + "auth.token.autoissue.native", + "auth.bearer.success", + "auth.register", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes export actions", () => { + for (const a of [ + "export.download", + "health-record.export", + "user.export.full-backup", + "user.export.measurements", + ]) { + expect(matchesSecurityActivity(a)).toBe(true); + } + }); + + it("includes the erasure actions", () => { + expect(matchesSecurityActivity("user.account.delete")).toBe(true); + expect(matchesSecurityActivity("user.data.clear")).toBe(true); + }); + + it("excludes unrelated audit actions", () => { + for (const a of [ + "measurement.create", + "medication.intake.bulk", + "coach.budget.exceeded", + "withings.sync.ok", + "consent.ai.web", + ]) { + expect(matchesSecurityActivity(a)).toBe(false); + } + }); +}); + +describe("securityActivityWhere", () => { + it("scopes by userId and mirrors the predicate clauses", () => { + const where = securityActivityWhere("user-1"); + expect(where.userId).toBe("user-1"); + expect(where.OR).toEqual([ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ]); + }); +}); diff --git a/src/lib/auth/security-activity.ts b/src/lib/auth/security-activity.ts new file mode 100644 index 000000000..f687a30f3 --- /dev/null +++ b/src/lib/auth/security-activity.ts @@ -0,0 +1,41 @@ +/** + * v1.23 — security-activity feed. The SHARED read both the account-security + * surface and the data-sovereignty privacy dashboard consume: a user-scoped, + * read-only window onto the `AuditLog` rows that matter for account security — + * logins, MFA events, password changes, token/session revocations, exports, + * and deletions. No new storage: it reuses the existing + * `@@index([userId, action, createdAt(sort: Desc)])`. + */ + +/** + * Whether an audit action belongs in the user-facing security-activity feed. + * Pure so the contract can be unit-tested without a DB: + * - every `auth.*` event (login, mfa, password, token, bearer, session, + * register), + * - every export action (`export.download`, `health-record.export`, + * `user.export.*` — all contain "export"), + * - the two erasure actions (`user.account.delete`, `user.data.clear`). + */ +export function matchesSecurityActivity(action: string): boolean { + return ( + action.startsWith("auth.") || + action.includes("export") || + action === "user.account.delete" || + action === "user.data.clear" + ); +} + +/** + * Prisma `where` mirror of `matchesSecurityActivity`, scoped to one user. Kept + * alongside the predicate so the two never drift. + */ +export function securityActivityWhere(userId: string) { + return { + userId, + OR: [ + { action: { startsWith: "auth." } }, + { action: { contains: "export" } }, + { action: { in: ["user.account.delete", "user.data.clear"] } }, + ], + }; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 7a2c73e77..83295b4b8 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -8,6 +8,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +// v1.23 — throttle window for the user-facing active-session list's "last +// seen" stamp. A write only happens when the prior stamp is older than this, +// so the high-churn `getSession` path costs at most one extra UPDATE per +// session per window rather than one per request. +const LAST_ACTIVE_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes + /** * v1.4.22 C4 — flag cookie mirroring the user's `onboardingCompletedAt` * status. Set to `"pending"` while the field is null and cleared once @@ -147,6 +153,22 @@ export async function getSession(): Promise<{ }); } + // v1.23 — sliding "last seen" stamp for the active-session list. Throttled + // (only when the prior stamp is stale) and fire-and-forget so it never adds + // latency or a failure mode to the request-resolution path. + const lastActive = session.lastActiveAt; + if ( + !lastActive || + lastActive.getTime() < Date.now() - LAST_ACTIVE_THROTTLE_MS + ) { + void prisma.session + .update({ + where: { id: session.id }, + data: { lastActiveAt: new Date() }, + }) + .catch(() => {}); + } + return { session: { id: session.id, expiresAt: session.expiresAt }, user: session.user, @@ -191,3 +213,46 @@ export async function destroyAllSessions(userId: string): Promise { }), ]); } + +/** + * v1.23 — "sign out everywhere" for the user-facing active-session surface + * (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's + * CURRENT cookie session alive so clicking the button doesn't log the user out + * of the device they pressed it on, and it does NOT revoke `ApiToken`s — those + * are long-lived programmatic credentials the user manages separately under + * /settings/api-tokens, not "sessions" in the device-list sense. It DOES revoke + * every native-client `RefreshToken` (each is a device login) so a signed-in + * phone/tablet is dropped too, matching the "everywhere" promise. + * + * Returns the number of OTHER web sessions removed so the surface can confirm. + */ +export async function destroyOtherSessions( + userId: string, + currentSessionId: string, +): Promise<{ sessionsRevoked: number }> { + const [deleted] = await prisma.$transaction([ + prisma.session.deleteMany({ + where: { userId, id: { not: currentSessionId } }, + }), + prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + ]); + return { sessionsRevoked: deleted.count }; +} + +/** + * v1.23 — revoke a single web session by id, scoped to the owning user so a + * caller can never delete another user's session row. Returns whether a row + * was actually removed (false → not found or not owned). + */ +export async function destroySessionById( + userId: string, + sessionId: string, +): Promise { + const result = await prisma.session.deleteMany({ + where: { id: sessionId, userId }, + }); + return result.count > 0; +} diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 28736b8af..959913b13 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -13,7 +13,7 @@ import { totpConfirmSchema, mfaDisableSchema, } from "@/lib/validations/mfa"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -129,6 +129,61 @@ const mfaToggleResponse = z .object({ enabled: z.boolean() }) .meta({ id: "MfaToggleResponse" }); +// ── v1.23 active sessions + security activity shapes ────────────────── + +const sessionListResponse = z + .object({ + sessions: z.array( + z.object({ + id: z.string(), + device: z + .string() + .describe("Coarse device label derived from the User-Agent."), + ipMasked: z + .string() + .nullable() + .describe( + "IP with the host portion masked — never the full address.", + ), + location: z + .string() + .nullable() + .describe("Resolved coarse location, when available."), + lastActiveAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + isCurrent: z + .boolean() + .describe("True for the session making this request."), + }), + ), + }) + .meta({ id: "SessionListResponse" }); + +const signOutEverywhereResponse = z + .object({ + sessionsRevoked: z + .number() + .int() + .describe("Number of OTHER sessions removed (the current one is kept)."), + }) + .meta({ id: "SignOutEverywhereResponse" }); + +const securityActivityResponse = z + .object({ + events: z.array( + z.object({ + action: z + .string() + .describe("Audit action name, e.g. auth.login.password."), + createdAt: z.iso.datetime({ offset: true }), + location: z.string().nullable(), + ipMasked: z.string().nullable().describe("Host-masked IP."), + carrier: z.string().nullable(), + }), + ), + }) + .meta({ id: "SecurityActivityResponse" }); + export const authPaths: NonNullable = { "/api/auth/login": { post: { @@ -320,4 +375,91 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/me/sessions": { + get: { + tags: ["Auth"], + summary: "List active web sessions", + description: + "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from /api/auth/me/devices (notification devices).", + responses: { + "200": { + description: "Active sessions for the caller.", + content: { + "application/json": { + schema: dataEnvelope(sessionListResponse, "SessionListEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Sign out everywhere else", + description: + "v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).", + responses: { + "200": { + description: "Other sessions revoked.", + content: { + "application/json": { + schema: dataEnvelope( + signOutEverywhereResponse, + "SignOutEverywhereEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/sessions/{id}": { + delete: { + tags: ["Auth"], + summary: "Revoke a single web session", + description: + "v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).", + responses: { + "200": { + description: "Session revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "SessionRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: "Session not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/security-activity": { + get: { + tags: ["Auth"], + summary: "List recent account-security activity", + description: + "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.", + responses: { + "200": { + description: "Recent security events for the caller.", + content: { + "application/json": { + schema: dataEnvelope( + securityActivityResponse, + "SecurityActivityEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index d02beb3fe..1e5836a0b 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -33,6 +33,21 @@ export const authKeys = { passkeys: () => ["passkeys"] as const, + /** + * v1.23 — the user-facing active-session list behind + * `GET /api/auth/me/sessions`. Revoking a single session or signing out + * everywhere invalidates this key so the surface re-reads the live set. + */ + sessions: () => ["auth", "sessions"] as const, + + /** + * v1.23 — the shared security-activity feed behind + * `GET /api/auth/me/security-activity` (logins, MFA, password change, + * session revoke, exports, deletions). Consumed by the account-security + * surface and the privacy dashboard. + */ + securityActivity: () => ["auth", "security-activity"] as const, + userAiProvider: () => ["user", "ai-provider"] as const, userProfile: () => ["user", "profile"] as const, /** diff --git a/tests/integration/sessions-and-known-devices.test.ts b/tests/integration/sessions-and-known-devices.test.ts new file mode 100644 index 000000000..2ea8d6743 --- /dev/null +++ b/tests/integration/sessions-and-known-devices.test.ts @@ -0,0 +1,131 @@ +/** + * Integration test — v1.23 active-session management + known-device ledger + * against a real Postgres (migration 0203 applied by the container harness). + * + * Covers the DB-layer contracts the unit mocks can't pin: + * - destroyOtherSessions keeps the current session, deletes the rest, revokes + * native refresh tokens, and leaves API tokens untouched. + * - destroySessionById is scoped to the owning user (no cross-user delete). + * - the (userId, deviceHash) unique index enforces the login-alert dedupe. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function makeUser(username: string) { + return getPrismaClient().user.create({ + data: { username, email: `${username}@example.test` }, + }); +} + +describe("destroyOtherSessions", () => { + it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("sess-owner"); + + const current = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const refresh = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: "rt-hash-1", + expiresAt: new Date(Date.now() + 1e6), + }, + }); + const apiToken = await prisma.apiToken.create({ + data: { + userId: user.id, + name: "automation", + tokenHash: "at-hash-1", + permissions: ["*"], + }, + }); + + const result = await destroyOtherSessions(user.id, current.id); + expect(result.sessionsRevoked).toBe(2); + + const remaining = await prisma.session.findMany({ + where: { userId: user.id }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe(current.id); + + const rt = await prisma.refreshToken.findUnique({ + where: { id: refresh.id }, + }); + expect(rt?.revokedAt).not.toBeNull(); + + const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } }); + expect(at?.revoked).toBe(false); + }); +}); + +describe("destroySessionById", () => { + it("revokes an owned session and refuses a cross-user id", async () => { + const prisma = getPrismaClient(); + const owner = await makeUser("owner"); + const other = await makeUser("intruder"); + + const ownerSession = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + const otherSession = await prisma.session.create({ + data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) }, + }); + + // Cross-user attempt: owner tries to delete the intruder's session. + expect(await destroySessionById(owner.id, otherSession.id)).toBe(false); + expect( + await prisma.session.findUnique({ where: { id: otherSession.id } }), + ).not.toBeNull(); + + // Owned delete succeeds. + expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true); + expect( + await prisma.session.findUnique({ where: { id: ownerSession.id } }), + ).toBeNull(); + }); +}); + +describe("UserKnownDevice unique index", () => { + it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => { + const prisma = getPrismaClient(); + const user = await makeUser("device-owner"); + + await prisma.userKnownDevice.create({ + data: { + userId: user.id, + deviceHash: "hash-abc", + label: "Firefox on macOS", + }, + }); + + await expect( + prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-abc" }, + }), + ).rejects.toThrow(); + + // A different hash for the same user inserts fine. + await prisma.userKnownDevice.create({ + data: { userId: user.id, deviceHash: "hash-def" }, + }); + + const rows = await prisma.userKnownDevice.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(2); + }); +});