Просмотр исходного кода

Log two-factor and passkey changes in the audit log (#540)

* Log two-factor and passkey changes in the audit log

* Check the two-factor audit entries in the account e2e flow

* Read the backup codes once the dialog shows them
Bernt Christian Egeland 1 день назад
Родитель
Сommit
7d8bd2f1fc

+ 47 - 2
e2e/specs/auth/account.spec.ts

@@ -1,5 +1,5 @@
 import { type Browser, type BrowserContext, expect, type Page, test } from '@playwright/test'
-import { storedTwoFactorSecret } from '../../support/db'
+import { accountSecurityEntries, storedTwoFactorSecret } from '../../support/db'
 import { fillSettled } from '../../support/hydration'
 import { currentTotpCode } from '../../support/totp'
 
@@ -78,8 +78,13 @@ test.describe('two-factor authentication', () => {
   // render off the cookie cache, but anything sensitive would be refused.
   let owner: BrowserContext
   let page: Page
+  // Each change below leaves a line in the audit log, counted from here. A
+  // second back: the row is stamped by the database's clock, not this one.
+  let since: Date
+  let backupCodes: string[] = []
 
   test.beforeAll(async ({ browser }) => {
+    since = new Date(Date.now() - 1_000)
     owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
     page = await owner.newPage()
   })
@@ -111,8 +116,18 @@ test.describe('two-factor authentication', () => {
     await dialog.locator('[id="2fa-verify-code"]').fill(await currentTotpCode(stored as string))
     await dialog.getByRole('button', { name: 'Verify', exact: true }).click()
 
-    await dialog.getByRole('button', { name: /saved my backup codes/i }).click()
+    // The codes the owner is asked to keep, for the backup-code sign-in below.
+    // Read once they are on screen: the step appears when the code is accepted.
+    const saved = dialog.getByRole('button', { name: /saved my backup codes/i })
+    await expect(saved).toBeVisible()
+    backupCodes = (await dialog.innerText()).match(/\b[A-Za-z0-9]{5}-[A-Za-z0-9]{5}\b/g) ?? []
+    expect(backupCodes.length, 'the dialog shows backup codes to keep').toBeGreaterThan(0)
+
+    await saved.click()
     await expect(page.getByText(/two-factor authentication (is )?enabled/i).first()).toBeVisible()
+    await expect
+      .poll(() => accountSecurityEntries(email, since))
+      .toEqual(['Turned on two-factor sign-in'])
   })
 
   test('signing in now asks for the code before opening anything', async ({ browser }) => {
@@ -132,6 +147,29 @@ test.describe('two-factor authentication', () => {
     await fresh.context().close()
   })
 
+  test('a backup code opens the account in place of the app, and is logged', async ({
+    browser,
+  }) => {
+    const fresh = await signedOut(browser)
+    await signIn(fresh, password)
+    await fresh.waitForURL(/\/auth\/verify-2fa/, { timeout: 30_000 })
+    await fresh.getByRole('button', { name: 'Use a backup code', exact: true }).click()
+
+    // A wrong code opens nothing and is not a sign-in to log.
+    await fresh.locator('#code').fill('wrong-codes')
+    await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
+    await expect(fresh.getByRole('button', { name: 'Verify', exact: true })).toBeEnabled()
+    await expect(fresh).toHaveURL(/\/auth\/verify-2fa/)
+
+    await fresh.locator('#code').fill(backupCodes[0])
+    await fresh.getByRole('button', { name: 'Verify', exact: true }).click()
+    await fresh.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+    await expect
+      .poll(() => accountSecurityEntries(email, since))
+      .toEqual(['Turned on two-factor sign-in', 'Signed in with a backup code'])
+    await fresh.context().close()
+  })
+
   test('is switched off again with the password', async () => {
     await page.goto('/settings/account')
     await fillSettled(page.locator('[id="2fa-disable-password"]'), password)
@@ -142,5 +180,12 @@ test.describe('two-factor authentication', () => {
       timeout: 15_000,
     })
     expect(await storedTwoFactorSecret(email)).toBeNull()
+    await expect
+      .poll(() => accountSecurityEntries(email, since))
+      .toEqual([
+        'Turned on two-factor sign-in',
+        'Signed in with a backup code',
+        'Turned off two-factor sign-in',
+      ])
   })
 })

+ 24 - 0
e2e/support/db.ts

@@ -1495,6 +1495,30 @@ export async function permissionDenialsFor(email: string, since: Date): Promise<
   })
 }
 
+/**
+ * What the audit log says about one person's sign-in security since a moment:
+ * two-factor sign-in turned on or off, a backup code used, a passkey added or
+ * removed. Read as the English line the log shows.
+ *
+ * The writes are fire-and-forget, so poll this rather than read it once.
+ */
+export async function accountSecurityEntries(email: string, since: Date): Promise<string[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{ message: string }>(
+      `select coalesce(a.message, a.action) as message
+         from audit_logs a
+         join users u on u.id = a."userId"
+        where lower(u.email) = lower($1)
+          and a.action in ('auth.twoFactorEnabled', 'auth.twoFactorDisabled', 'auth.backupCodeUsed',
+                           'auth.passkeyAdded', 'auth.passkeyRemoved')
+          and a.timestamp >= $2
+        order by a.timestamp`,
+      [email, since]
+    )
+    return result.rows.map((row) => row.message)
+  })
+}
+
 /**
  * Sets one of a workshop's settings directly, returning what was there before
  * (null when the key had never been saved), so a spec can put it back.

+ 10 - 0
messages/de/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Arbeitsplatz gelöscht",
     "auth_appSetupRedeemed": "Mit Einrichtungscode angemeldet",
     "auth_technicianCodeSignIn": "Mit Einmalcode angemeldet",
+    "auth_twoFactorEnabled": "Zwei-Faktor-Anmeldung aktiviert",
+    "auth_twoFactorDisabled": "Zwei-Faktor-Anmeldung deaktiviert",
+    "auth_passkeyAdded": "Passkey hinzugefügt",
+    "auth_passkeyRemoved": "Passkey entfernt",
+    "auth_backupCodeUsed": "Mit Backup-Code angemeldet",
     "team_createAppSetupCode": "App-Einrichtungscode ausgestellt",
     "team_createTechnicianAccount": "Technikerkonto angelegt",
     "team_removeTechnicianAccess": "Techniker entfernt und Zugriff entzogen",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Sicherung wiederhergestellt"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Zwei-Faktor-Anmeldung aktiviert",
+    "auth_twoFactorDisabled": "Zwei-Faktor-Anmeldung deaktiviert",
+    "auth_passkeyAdded": "Passkey „{name}“ hinzugefügt",
+    "auth_passkeyRemoved": "Passkey „{name}“ entfernt",
+    "auth_backupCodeUsed": "Mit einem Backup-Code angemeldet",
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
     "customField_delete": "Benutzerdefiniertes Feld „{name}“ gelöscht",
     "customField_update": "Benutzerdefiniertes Feld „{name}“ aktualisiert",

+ 10 - 0
messages/en/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Deleted Work Bay",
     "auth_appSetupRedeemed": "Signed in with a setup code",
     "auth_technicianCodeSignIn": "Signed in with a one-time code",
+    "auth_twoFactorEnabled": "Two-Factor Sign-In Turned On",
+    "auth_twoFactorDisabled": "Two-Factor Sign-In Turned Off",
+    "auth_passkeyAdded": "Passkey Added",
+    "auth_passkeyRemoved": "Passkey Removed",
+    "auth_backupCodeUsed": "Signed in with a backup code",
     "team_createAppSetupCode": "Issued an app setup code",
     "team_createTechnicianAccount": "Created a technician account",
     "team_removeTechnicianAccess": "Removed a technician and revoked their access",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Restored Backup"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Turned on two-factor sign-in",
+    "auth_twoFactorDisabled": "Turned off two-factor sign-in",
+    "auth_passkeyAdded": "Added the passkey \"{name}\"",
+    "auth_passkeyRemoved": "Removed the passkey \"{name}\"",
+    "auth_backupCodeUsed": "Signed in with a backup code",
     "customField_create": "Created custom field \"{name}\"",
     "customField_delete": "Deleted custom field \"{name}\"",
     "customField_update": "Updated custom field \"{name}\"",

+ 10 - 0
messages/es/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Puesto de trabajo eliminado",
     "auth_appSetupRedeemed": "Sesión iniciada con código de configuración",
     "auth_technicianCodeSignIn": "Sesión iniciada con código de un solo uso",
+    "auth_twoFactorEnabled": "Inicio de sesión en dos pasos activado",
+    "auth_twoFactorDisabled": "Inicio de sesión en dos pasos desactivado",
+    "auth_passkeyAdded": "Passkey añadida",
+    "auth_passkeyRemoved": "Passkey eliminada",
+    "auth_backupCodeUsed": "Sesión iniciada con código de respaldo",
     "team_createAppSetupCode": "Código de configuración de la aplicación emitido",
     "team_createTechnicianAccount": "Cuenta de técnico creada",
     "team_removeTechnicianAccess": "Técnico eliminado y acceso revocado",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Copia de seguridad restaurada"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Activó el inicio de sesión en dos pasos",
+    "auth_twoFactorDisabled": "Desactivó el inicio de sesión en dos pasos",
+    "auth_passkeyAdded": "Añadió la passkey «{name}»",
+    "auth_passkeyRemoved": "Eliminó la passkey «{name}»",
+    "auth_backupCodeUsed": "Inició sesión con un código de respaldo",
     "customField_create": "Campo personalizado «{name}» creado",
     "customField_delete": "Campo personalizado «{name}» eliminado",
     "customField_update": "Campo personalizado «{name}» actualizado",

+ 10 - 0
messages/fr/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Poste de travail supprimé",
     "auth_appSetupRedeemed": "Connexion avec un code de configuration",
     "auth_technicianCodeSignIn": "Connexion avec un code à usage unique",
+    "auth_twoFactorEnabled": "Connexion à deux facteurs activée",
+    "auth_twoFactorDisabled": "Connexion à deux facteurs désactivée",
+    "auth_passkeyAdded": "Passkey ajoutée",
+    "auth_passkeyRemoved": "Passkey supprimée",
+    "auth_backupCodeUsed": "Connexion avec un code de secours",
     "team_createAppSetupCode": "Code de configuration de l'application émis",
     "team_createTechnicianAccount": "Compte technicien créé",
     "team_removeTechnicianAccess": "Technicien retiré et accès révoqué",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Sauvegarde restaurée"
   },
   "summary": {
+    "auth_twoFactorEnabled": "A activé la connexion à deux facteurs",
+    "auth_twoFactorDisabled": "A désactivé la connexion à deux facteurs",
+    "auth_passkeyAdded": "A ajouté la passkey « {name} »",
+    "auth_passkeyRemoved": "A supprimé la passkey « {name} »",
+    "auth_backupCodeUsed": "Connexion avec un code de secours",
     "customField_create": "Champ personnalisé « {name} » créé",
     "customField_delete": "Champ personnalisé « {name} » supprimé",
     "customField_update": "Champ personnalisé « {name} » mis à jour",

+ 10 - 0
messages/it/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Postazione eliminata",
     "auth_appSetupRedeemed": "Accesso con codice di configurazione",
     "auth_technicianCodeSignIn": "Accesso con codice monouso",
+    "auth_twoFactorEnabled": "Accesso a due fattori attivato",
+    "auth_twoFactorDisabled": "Accesso a due fattori disattivato",
+    "auth_passkeyAdded": "Passkey aggiunta",
+    "auth_passkeyRemoved": "Passkey rimossa",
+    "auth_backupCodeUsed": "Accesso con codice di backup",
     "team_createAppSetupCode": "Codice di configurazione dell'app emesso",
     "team_createTechnicianAccount": "Account tecnico creato",
     "team_removeTechnicianAccess": "Tecnico rimosso e accesso revocato",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Backup ripristinato"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Ha attivato l'accesso a due fattori",
+    "auth_twoFactorDisabled": "Ha disattivato l'accesso a due fattori",
+    "auth_passkeyAdded": "Ha aggiunto la passkey \"{name}\"",
+    "auth_passkeyRemoved": "Ha rimosso la passkey \"{name}\"",
+    "auth_backupCodeUsed": "Accesso con un codice di backup",
     "customField_create": "Campo personalizzato «{name}» creato",
     "customField_delete": "Campo personalizzato «{name}» eliminato",
     "customField_update": "Campo personalizzato «{name}» aggiornato",

+ 10 - 0
messages/lt/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Ištrinta darbo vieta",
     "auth_appSetupRedeemed": "Prisijungta su nustatymo kodu",
     "auth_technicianCodeSignIn": "Prisijungta su vienkartiniu kodu",
+    "auth_twoFactorEnabled": "Įjungtas dviejų veiksnių prisijungimas",
+    "auth_twoFactorDisabled": "Išjungtas dviejų veiksnių prisijungimas",
+    "auth_passkeyAdded": "Pridėtas prieigos raktas",
+    "auth_passkeyRemoved": "Pašalintas prieigos raktas",
+    "auth_backupCodeUsed": "Prisijungta su atsarginiu kodu",
     "team_createAppSetupCode": "Išduotas programėlės nustatymo kodas",
     "team_createTechnicianAccount": "Sukurta techniko paskyra",
     "team_removeTechnicianAccess": "Technikas pašalintas, prieiga atšaukta",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Atkurta atsarginė kopija"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Įjungtas dviejų veiksnių prisijungimas",
+    "auth_twoFactorDisabled": "Išjungtas dviejų veiksnių prisijungimas",
+    "auth_passkeyAdded": "Pridėtas prieigos raktas „{name}“",
+    "auth_passkeyRemoved": "Pašalintas prieigos raktas „{name}“",
+    "auth_backupCodeUsed": "Prisijungta su atsarginiu kodu",
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
     "customField_delete": "Ištrintas pasirinktinis laukas „{name}“",
     "customField_update": "Atnaujintas pasirinktinis laukas „{name}“",

+ 10 - 0
messages/nb/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Slettet arbeidsplass",
     "auth_appSetupRedeemed": "Logget inn med oppsettskode",
     "auth_technicianCodeSignIn": "Logget inn med engangskode",
+    "auth_twoFactorEnabled": "Tofaktorinnlogging slått på",
+    "auth_twoFactorDisabled": "Tofaktorinnlogging slått av",
+    "auth_passkeyAdded": "Passkey lagt til",
+    "auth_passkeyRemoved": "Passkey fjernet",
+    "auth_backupCodeUsed": "Logget inn med sikkerhetskode",
     "team_createAppSetupCode": "Utstedte en oppsettskode for appen",
     "team_createTechnicianAccount": "Opprettet en teknikerkonto",
     "team_removeTechnicianAccess": "Fjernet en tekniker og trakk tilbake tilgangen",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Gjenopprettet sikkerhetskopi"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Slo på tofaktorinnlogging",
+    "auth_twoFactorDisabled": "Slo av tofaktorinnlogging",
+    "auth_passkeyAdded": "La til passkeyen «{name}»",
+    "auth_passkeyRemoved": "Fjernet passkeyen «{name}»",
+    "auth_backupCodeUsed": "Logget inn med en sikkerhetskode",
     "customField_create": "Opprettet egendefinert felt «{name}»",
     "customField_delete": "Slettet egendefinert felt «{name}»",
     "customField_update": "Oppdaterte egendefinert felt «{name}»",

+ 10 - 0
messages/nl/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Werkplek verwijderd",
     "auth_appSetupRedeemed": "Ingelogd met installatiecode",
     "auth_technicianCodeSignIn": "Ingelogd met eenmalige code",
+    "auth_twoFactorEnabled": "Inloggen in twee stappen ingeschakeld",
+    "auth_twoFactorDisabled": "Inloggen in twee stappen uitgeschakeld",
+    "auth_passkeyAdded": "Passkey toegevoegd",
+    "auth_passkeyRemoved": "Passkey verwijderd",
+    "auth_backupCodeUsed": "Ingelogd met een back-upcode",
     "team_createAppSetupCode": "Installatiecode voor de app uitgegeven",
     "team_createTechnicianAccount": "Monteursaccount aangemaakt",
     "team_removeTechnicianAccess": "Monteur verwijderd en toegang ingetrokken",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Back-up teruggezet"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Inloggen in twee stappen ingeschakeld",
+    "auth_twoFactorDisabled": "Inloggen in twee stappen uitgeschakeld",
+    "auth_passkeyAdded": "Passkey \"{name}\" toegevoegd",
+    "auth_passkeyRemoved": "Passkey \"{name}\" verwijderd",
+    "auth_backupCodeUsed": "Ingelogd met een back-upcode",
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
     "customField_delete": "Aangepast veld \"{name}\" verwijderd",
     "customField_update": "Aangepast veld \"{name}\" bijgewerkt",

+ 10 - 0
messages/pl/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Usunięto stanowisko",
     "auth_appSetupRedeemed": "Zalogowano kodem konfiguracji",
     "auth_technicianCodeSignIn": "Zalogowano kodem jednorazowym",
+    "auth_twoFactorEnabled": "Włączono logowanie dwuskładnikowe",
+    "auth_twoFactorDisabled": "Wyłączono logowanie dwuskładnikowe",
+    "auth_passkeyAdded": "Dodano passkey",
+    "auth_passkeyRemoved": "Usunięto passkey",
+    "auth_backupCodeUsed": "Zalogowano kodem zapasowym",
     "team_createAppSetupCode": "Wydano kod konfiguracji aplikacji",
     "team_createTechnicianAccount": "Utworzono konto technika",
     "team_removeTechnicianAccess": "Usunięto technika i cofnięto dostęp",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Przywrócono kopię zapasową"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Włączono logowanie dwuskładnikowe",
+    "auth_twoFactorDisabled": "Wyłączono logowanie dwuskładnikowe",
+    "auth_passkeyAdded": "Dodano passkey „{name}”",
+    "auth_passkeyRemoved": "Usunięto passkey „{name}”",
+    "auth_backupCodeUsed": "Zalogowano kodem zapasowym",
     "customField_create": "Utworzono pole niestandardowe „{name}”",
     "customField_delete": "Usunięto pole niestandardowe „{name}”",
     "customField_update": "Zaktualizowano pole niestandardowe „{name}”",

+ 10 - 0
messages/pt-BR/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Baia excluída",
     "auth_appSetupRedeemed": "Entrou com código de configuração",
     "auth_technicianCodeSignIn": "Entrou com código de uso único",
+    "auth_twoFactorEnabled": "Login em duas etapas ativado",
+    "auth_twoFactorDisabled": "Login em duas etapas desativado",
+    "auth_passkeyAdded": "Passkey adicionada",
+    "auth_passkeyRemoved": "Passkey removida",
+    "auth_backupCodeUsed": "Entrou com código de backup",
     "team_createAppSetupCode": "Código de configuração do aplicativo emitido",
     "team_createTechnicianAccount": "Conta de técnico criada",
     "team_removeTechnicianAccess": "Técnico removido e acesso revogado",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Backup restaurado"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Ativou o login em duas etapas",
+    "auth_twoFactorDisabled": "Desativou o login em duas etapas",
+    "auth_passkeyAdded": "Adicionou a passkey \"{name}\"",
+    "auth_passkeyRemoved": "Removeu a passkey \"{name}\"",
+    "auth_backupCodeUsed": "Entrou com um código de backup",
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_delete": "Campo personalizado \"{name}\" excluído",
     "customField_update": "Campo personalizado \"{name}\" atualizado",

+ 10 - 0
messages/pt-PT/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Posto de trabalho eliminado",
     "auth_appSetupRedeemed": "Iniciou sessão com um código de configuração",
     "auth_technicianCodeSignIn": "Iniciou sessão com um código de utilização única",
+    "auth_twoFactorEnabled": "Início de sessão em dois passos ativado",
+    "auth_twoFactorDisabled": "Início de sessão em dois passos desativado",
+    "auth_passkeyAdded": "Passkey adicionada",
+    "auth_passkeyRemoved": "Passkey removida",
+    "auth_backupCodeUsed": "Iniciou sessão com um código de recuperação",
     "team_createAppSetupCode": "Código de configuração da aplicação emitido",
     "team_createTechnicianAccount": "Conta de técnico criada",
     "team_removeTechnicianAccess": "Técnico removido e acesso revogado",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Cópia de segurança reposta"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Ativou o início de sessão em dois passos",
+    "auth_twoFactorDisabled": "Desativou o início de sessão em dois passos",
+    "auth_passkeyAdded": "Adicionou a passkey \"{name}\"",
+    "auth_passkeyRemoved": "Removeu a passkey \"{name}\"",
+    "auth_backupCodeUsed": "Iniciou sessão com um código de recuperação",
     "customField_create": "Campo personalizado \"{name}\" criado",
     "customField_delete": "Campo personalizado \"{name}\" eliminado",
     "customField_update": "Campo personalizado \"{name}\" atualizado",

+ 10 - 0
messages/ru/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Удалён пост",
     "auth_appSetupRedeemed": "Вход по коду настройки",
     "auth_technicianCodeSignIn": "Вход по одноразовому коду",
+    "auth_twoFactorEnabled": "Двухфакторный вход включён",
+    "auth_twoFactorDisabled": "Двухфакторный вход отключён",
+    "auth_passkeyAdded": "Добавлен ключ доступа",
+    "auth_passkeyRemoved": "Удалён ключ доступа",
+    "auth_backupCodeUsed": "Вход по резервному коду",
     "team_createAppSetupCode": "Выдан код настройки приложения",
     "team_createTechnicianAccount": "Создана учётная запись механика",
     "team_removeTechnicianAccess": "Механик удалён, доступ отозван",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Восстановлена резервная копия"
   },
   "summary": {
+    "auth_twoFactorEnabled": "Включён двухфакторный вход",
+    "auth_twoFactorDisabled": "Отключён двухфакторный вход",
+    "auth_passkeyAdded": "Добавлен ключ доступа «{name}»",
+    "auth_passkeyRemoved": "Удалён ключ доступа «{name}»",
+    "auth_backupCodeUsed": "Вход по резервному коду",
     "customField_create": "Создано настраиваемое поле «{name}»",
     "customField_delete": "Удалено настраиваемое поле «{name}»",
     "customField_update": "Обновлено настраиваемое поле «{name}»",

+ 10 - 0
messages/tr/audit.json

@@ -147,6 +147,11 @@
     "work_bay_delete": "Çalışma alanı silindi",
     "auth_appSetupRedeemed": "Kurulum koduyla giriş",
     "auth_technicianCodeSignIn": "Tek kullanımlık kodla giriş",
+    "auth_twoFactorEnabled": "İki adımlı giriş açıldı",
+    "auth_twoFactorDisabled": "İki adımlı giriş kapatıldı",
+    "auth_passkeyAdded": "Passkey eklendi",
+    "auth_passkeyRemoved": "Passkey kaldırıldı",
+    "auth_backupCodeUsed": "Yedek kodla giriş",
     "team_createAppSetupCode": "Uygulama kurulum kodu verildi",
     "team_createTechnicianAccount": "Teknisyen hesabı oluşturuldu",
     "team_removeTechnicianAccess": "Teknisyen kaldırıldı ve erişimi iptal edildi",
@@ -202,6 +207,11 @@
     "integration_backup_restore": "Yedek geri yüklendi"
   },
   "summary": {
+    "auth_twoFactorEnabled": "İki adımlı giriş açıldı",
+    "auth_twoFactorDisabled": "İki adımlı giriş kapatıldı",
+    "auth_passkeyAdded": "\"{name}\" passkey'i eklendi",
+    "auth_passkeyRemoved": "\"{name}\" passkey'i kaldırıldı",
+    "auth_backupCodeUsed": "Yedek kodla giriş yapıldı",
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
     "customField_delete": "\"{name}\" özel alanı silindi",
     "customField_update": "\"{name}\" özel alanı güncellendi",

+ 173 - 0
src/__tests__/lib/account-security-audit.test.ts

@@ -0,0 +1,173 @@
+/**
+ * Two-factor sign-in and passkeys reach the audit log. They change through
+ * better-auth's own routes, so for a long time none of it was written down,
+ * although the security docs say every one of these events is there.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { APIError } from 'better-auth/api'
+
+// The audit line is rendered with next-intl's own translator, which the global mock leaves out.
+vi.unmock('next-intl')
+
+vi.mock('@/features/webhooks/Lib/dispatcher', () => ({ dispatchWebhookEvent: vi.fn() }))
+vi.mock('@/features/integrations/Lib/events', () => ({ notifyIntegrations: vi.fn() }))
+vi.mock('@/lib/db', () => ({
+  db: {
+    auditLog: { create: vi.fn().mockResolvedValue({}) },
+    organizationMember: { findFirst: vi.fn() },
+    passkey: { findFirst: vi.fn() },
+  },
+}))
+
+import { db } from '@/lib/db'
+import {
+  auditAccountSecurityRoute,
+  auditTwoFactorChange,
+  rememberPasskeyBeforeRemoval,
+} from '@/lib/account-security-audit'
+
+const CHROME_ON_LINUX =
+  'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
+
+function request(path: string) {
+  return new Request(`http://localhost:3000/api/public/auth${path}`, {
+    method: 'POST',
+    headers: { 'user-agent': CHROME_ON_LINUX, 'x-real-ip': '203.0.113.7' },
+  })
+}
+
+/** What the audit log holds, oldest first. */
+function written() {
+  return vi.mocked(db.auditLog.create).mock.calls.map(([args]) => args.data)
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+    organizationId: 'org-a',
+  } as never)
+})
+
+describe('two-factor sign-in', () => {
+  it('logs turning it on, with the address and browser', async () => {
+    await auditTwoFactorChange(
+      { id: 'alex', twoFactorEnabled: true },
+      { path: '/two-factor/verify-totp', request: request('/two-factor/verify-totp') }
+    )
+    expect(written()).toEqual([
+      expect.objectContaining({
+        userId: 'alex',
+        organizationId: 'org-a',
+        action: 'auth.twoFactorEnabled',
+        message: 'Turned on two-factor sign-in',
+        ip: '203.0.113.7',
+        userAgent: CHROME_ON_LINUX,
+      }),
+    ])
+  })
+
+  it('logs turning it off', async () => {
+    await auditTwoFactorChange(
+      { id: 'alex', twoFactorEnabled: false },
+      { path: '/two-factor/disable', request: request('/two-factor/disable') }
+    )
+    expect(written().map((row) => row.message)).toEqual(['Turned off two-factor sign-in'])
+  })
+
+  it('says nothing about other changes to the account', async () => {
+    await auditTwoFactorChange(
+      { id: 'alex', twoFactorEnabled: true },
+      { path: '/update-user', request: request('/update-user') }
+    )
+    await auditTwoFactorChange({ id: 'alex', twoFactorEnabled: true }, null)
+    expect(written()).toEqual([])
+  })
+
+  it('logs a sign-in with a backup code', async () => {
+    await auditAccountSecurityRoute({
+      path: '/two-factor/verify-backup-code',
+      request: request('/two-factor/verify-backup-code'),
+      context: { returned: { token: 't' }, newSession: { user: { id: 'alex' } } },
+    })
+    expect(written().map((row) => row.message)).toEqual(['Signed in with a backup code'])
+  })
+
+  it('says nothing when the backup code was wrong', async () => {
+    await auditAccountSecurityRoute({
+      path: '/two-factor/verify-backup-code',
+      request: request('/two-factor/verify-backup-code'),
+      context: { returned: new APIError('UNAUTHORIZED'), newSession: null },
+    })
+    expect(written()).toEqual([])
+  })
+})
+
+describe('passkeys', () => {
+  it('logs a passkey added, by its name', async () => {
+    await auditAccountSecurityRoute({
+      path: '/passkey/verify-registration',
+      request: request('/passkey/verify-registration'),
+      context: { returned: { id: 'pk-1', userId: 'alex', name: 'Safari on iPhone' } },
+    })
+    expect(written()).toEqual([
+      expect.objectContaining({
+        userId: 'alex',
+        action: 'auth.passkeyAdded',
+        message: 'Added the passkey "Safari on iPhone"',
+      }),
+    ])
+  })
+
+  it('names an unnamed passkey after the device it was made on', async () => {
+    await auditAccountSecurityRoute({
+      path: '/passkey/verify-registration',
+      request: request('/passkey/verify-registration'),
+      context: { returned: { id: 'pk-1', userId: 'alex', name: null } },
+    })
+    expect(written().map((row) => row.message)).toEqual(['Added the passkey "Chrome on Linux"'])
+  })
+
+  it('logs a passkey removed, by the name it had', async () => {
+    vi.mocked(db.passkey.findFirst).mockResolvedValue({
+      userId: 'alex',
+      name: 'Chrome on Linux',
+    } as never)
+    const req = request('/passkey/delete-passkey')
+    await rememberPasskeyBeforeRemoval({
+      path: '/passkey/delete-passkey',
+      body: { id: 'pk-1' },
+      request: req,
+    })
+    await auditAccountSecurityRoute({
+      path: '/passkey/delete-passkey',
+      request: req,
+      context: { returned: { status: true } },
+    })
+    expect(written()).toEqual([
+      expect.objectContaining({
+        userId: 'alex',
+        action: 'auth.passkeyRemoved',
+        message: 'Removed the passkey "Chrome on Linux"',
+      }),
+    ])
+  })
+
+  it('says nothing when removing someone else’s passkey was refused', async () => {
+    vi.mocked(db.passkey.findFirst).mockResolvedValue({
+      userId: 'sam',
+      name: 'Sam’s phone',
+    } as never)
+    const req = request('/passkey/delete-passkey')
+    await rememberPasskeyBeforeRemoval({
+      path: '/passkey/delete-passkey',
+      body: { id: 'pk-sam' },
+      request: req,
+    })
+    await auditAccountSecurityRoute({
+      path: '/passkey/delete-passkey',
+      request: req,
+      context: { returned: new APIError('UNAUTHORIZED') },
+    })
+    expect(written()).toEqual([])
+  })
+})

+ 149 - 0
src/lib/account-security-audit.ts

@@ -0,0 +1,149 @@
+import { auditDetails, logAudit, type AuditEvent } from '@/lib/audit'
+import { db } from '@/lib/db'
+import { describeUserAgent } from '@/lib/user-agent-label'
+
+/**
+ * Two-factor sign-in and passkeys change through better-auth's own routes,
+ * which no server action wraps, so none of it reached the audit log although
+ * the security docs say every one of these events is there. lib/auth.ts calls
+ * these from its hooks.
+ */
+
+/** The headers the auth config reads a caller's address from, in order. See lib/auth.ts. */
+export const AUTH_IP_HEADERS =
+  process.env.TRUST_CF_CONNECTING_IP === 'true' ? ['cf-connecting-ip', 'x-real-ip'] : ['x-real-ip']
+
+/** The parts of a better-auth hook context these read. */
+export type AuthHookContext = {
+  path?: string
+  body?: unknown
+  request?: Request
+  headers?: Headers
+  context?: {
+    returned?: unknown
+    newSession?: { user: { id: string } } | null
+  }
+}
+
+function headersOf(ctx: AuthHookContext | null | undefined) {
+  return ctx?.request?.headers ?? ctx?.headers ?? null
+}
+
+function clientIp(headers: Headers | null) {
+  for (const name of AUTH_IP_HEADERS) {
+    const value = headers?.get(name)
+    if (value) return value
+  }
+  return null
+}
+
+/**
+ * Written to the workshop the account works in, the same one its sign-ins
+ * go to, with the address and browser the change came from.
+ */
+async function logAccountEvent(
+  userId: string,
+  event: Pick<AuditEvent, 'action' | 'details' | 'metadata'>,
+  ctx: AuthHookContext | null | undefined
+) {
+  const headers = headersOf(ctx)
+  const membership = await db.organizationMember.findFirst({
+    where: { userId },
+    select: { organizationId: true },
+  })
+  await logAudit(
+    { userId, organizationId: membership?.organizationId ?? '' },
+    {
+      ...event,
+      entity: 'User',
+      entityId: userId,
+      ip: clientIp(headers),
+      userAgent: headers?.get('user-agent') ?? null,
+    }
+  )
+}
+
+/**
+ * From the user update hook. The two-factor routes switch `twoFactorEnabled`
+ * and change nothing else on the user, so an update made from one of them is
+ * the switch being turned on or off.
+ */
+export async function auditTwoFactorChange(
+  user: { id: string; twoFactorEnabled?: boolean | null },
+  ctx: AuthHookContext | null | undefined
+) {
+  if (!ctx?.path?.startsWith('/two-factor/')) return
+  await logAccountEvent(
+    user.id,
+    user.twoFactorEnabled
+      ? { action: 'auth.twoFactorEnabled', details: auditDetails('auth_twoFactorEnabled') }
+      : { action: 'auth.twoFactorDisabled', details: auditDetails('auth_twoFactorDisabled') },
+    ctx
+  )
+}
+
+/** A passkey's row is gone by the time the delete route answers, so its name is read first. */
+const passkeysBeingRemoved = new WeakMap<Request, { userId: string; name: string }>()
+
+/** From the before hook. */
+export async function rememberPasskeyBeforeRemoval(ctx: AuthHookContext) {
+  if (ctx.path !== '/passkey/delete-passkey' || !ctx.request) return
+  const id = (ctx.body as { id?: unknown } | undefined)?.id
+  if (typeof id !== 'string') return
+  const passkey = await db.passkey.findFirst({
+    where: { id },
+    select: { userId: true, name: true },
+  })
+  if (passkey) {
+    passkeysBeingRemoved.set(ctx.request, {
+      userId: passkey.userId,
+      name: passkey.name || 'Passkey',
+    })
+  }
+}
+
+/** From the after hook: what the passkey and backup-code routes did, once they succeeded. */
+export async function auditAccountSecurityRoute(ctx: AuthHookContext) {
+  const returned = ctx.context?.returned
+  // A refused request comes back as an APIError; nothing changed.
+  if (!returned || returned instanceof Error) return
+
+  if (ctx.path === '/passkey/verify-registration') {
+    const passkey = returned as { userId?: string; name?: string | null }
+    if (!passkey.userId) return
+    // The app names a passkey after the device it is made on; one added
+    // without a name is called the same thing here.
+    const name = passkey.name || describeUserAgent(headersOf(ctx)?.get('user-agent'))
+    await logAccountEvent(
+      passkey.userId,
+      { action: 'auth.passkeyAdded', details: auditDetails('auth_passkeyAdded', { name }) },
+      ctx
+    )
+    return
+  }
+
+  if (ctx.path === '/passkey/delete-passkey') {
+    const removed = ctx.request ? passkeysBeingRemoved.get(ctx.request) : undefined
+    if (!removed) return
+    passkeysBeingRemoved.delete(ctx.request as Request)
+    await logAccountEvent(
+      removed.userId,
+      {
+        action: 'auth.passkeyRemoved',
+        details: auditDetails('auth_passkeyRemoved', { name: removed.name }),
+      },
+      ctx
+    )
+    return
+  }
+
+  if (ctx.path === '/two-factor/verify-backup-code') {
+    const userId = ctx.context?.newSession?.user.id
+    if (!userId) return
+    await logAccountEvent(
+      userId,
+      { action: 'auth.backupCodeUsed', details: auditDetails('auth_backupCodeUsed') },
+      ctx
+    )
+  }
+}

+ 25 - 4
src/lib/auth.ts

@@ -12,6 +12,12 @@ import { sendAccountMail } from '@/lib/account-mail'
 import { isDemoMode } from './demo'
 import { googleSignInConfig } from './auth-providers'
 import { scopedSessionMayCall, sessionTokensInHeaders } from './session-scope'
+import {
+  AUTH_IP_HEADERS,
+  auditAccountSecurityRoute,
+  auditTwoFactorChange,
+  rememberPasskeyBeforeRemoval,
+} from './account-security-audit'
 
 const baseURL = process.env.NEXT_PUBLIC_APP_URL
 const google = googleSignInConfig()
@@ -203,6 +209,9 @@ export const auth = betterAuth({
     // its own session and a sign-out. The rest of it is the account's: the
     // other sessions and their tokens, passkeys, the password, the name.
     before: createAuthMiddleware(async (ctx) => {
+      await rememberPasskeyBeforeRemoval(ctx).catch((error) =>
+        console.error('[auth] could not read the passkey being removed:', error)
+      )
       if (scopedSessionMayCall(ctx.path)) return
       const tokens = [
         ...sessionTokensInHeaders(ctx.request?.headers, ctx.context.authCookies.sessionToken.name),
@@ -219,6 +228,13 @@ export const auth = betterAuth({
         })
       }
     }),
+    // Passkeys added and removed, and sign-ins with a backup code, for the
+    // audit log. Best effort: the change has happened either way.
+    after: createAuthMiddleware(async (ctx) => {
+      await auditAccountSecurityRoute(ctx).catch((error) =>
+        console.error('[audit] account security event failed:', error)
+      )
+    }),
   },
   advanced: {
     useSecureCookies: isProduction,
@@ -229,10 +245,7 @@ export const auth = betterAuth({
       // it does not while production runs grey-clouded. Trusting it let a
       // caller change one digit and become a different person to every
       // per-address limit in the product. See lib/rate-limit.ts.
-      ipAddressHeaders:
-        process.env.TRUST_CF_CONNECTING_IP === 'true'
-          ? ['cf-connecting-ip', 'x-real-ip']
-          : ['x-real-ip'],
+      ipAddressHeaders: AUTH_IP_HEADERS,
     },
   },
   databaseHooks: {
@@ -304,6 +317,14 @@ export const auth = betterAuth({
       },
     },
     user: {
+      update: {
+        // Two-factor sign-in turned on or off, for the audit log.
+        after: async (user, ctx) => {
+          await auditTwoFactorChange(user, ctx).catch((error) =>
+            console.error('[audit] two-factor change failed:', error)
+          )
+        },
+      },
       create: {
         before: async (user) => {
           // Block registration if disabled via system settings (always disabled in demo mode)