mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: integrate the v1.23 sessions and login-alert wave
This commit is contained in:
commit
4c4231838c
38 files changed
+2590
-23
No files matched your search
@@ -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:
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
+39
-3
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | null>();
|
||||
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 });
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.23 — active-session management (issue #64) + the security-activity
|
||||
feed. Both sit in the account/security block under the credential
|
||||
controls above. */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
@@ -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 (
|
||||
<SettingsCard data-slot="settings-security-activity-card">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.security.activityTitle")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.activityDescription")}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("settings.security.activityLoadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && events.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.security.activityEmpty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ul className="divide-border divide-y">
|
||||
{events.map((e, idx) => (
|
||||
<li
|
||||
key={`${e.action}-${e.createdAt}-${idx}`}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
data-slot="security-activity-row"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{t(actionLabelKey(e.action))}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{[
|
||||
e.location ?? t("settings.security.unknownLocation"),
|
||||
e.ipMasked,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground shrink-0 text-xs">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<SettingsCard data-slot="settings-security-sessions-card">
|
||||
<SettingsCardHeader
|
||||
icon={MonitorSmartphone}
|
||||
title={t("settings.security.sessionsTitle")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.sessionsDescription")}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("settings.security.sessionsLoadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sessions.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.security.sessionsEmpty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<ul className="divide-border divide-y">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 py-3"
|
||||
data-slot="security-session-row"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{s.device}
|
||||
{s.isCurrent && (
|
||||
<span className="text-success ml-2 text-xs font-normal">
|
||||
{t("settings.security.currentSession")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{[
|
||||
s.location ?? t("settings.security.unknownLocation"),
|
||||
s.ipMasked,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{s.lastActiveAt && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.lastActive", {
|
||||
time: new Date(s.lastActiveAt).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!s.isCurrent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0"
|
||||
onClick={() => revokeOne.mutate(s.id)}
|
||||
disabled={revokeOne.isPending}
|
||||
>
|
||||
{t("settings.security.revokeSession")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasOthers && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => revokeOthers.mutate()}
|
||||
disabled={revokeOthers.isPending}
|
||||
>
|
||||
{revokeOthers.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.security.signOutEverywhere")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<p role="status" className="text-muted-foreground text-right text-sm">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AuthContext> {
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>)["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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"] } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<BreachCheckResult | null> {
|
||||
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>:<count>". 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;
|
||||
}
|
||||
}
|
||||
@@ -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<RecordSignInResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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)
|
||||
|
||||
@@ -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"] } },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
@@ -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<EventType, boolean> = {
|
||||
// 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<ChannelType, string> = {
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/login": {
|
||||
post: {
|
||||
@@ -320,4 +375,91 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user