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/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/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/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/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/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/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/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/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/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/__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. 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__/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/__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/__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/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/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; + } +} 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/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/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 = { 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); + }); +});