Files
betreuungs-cockpit/src/auth.ts
T
Florian Wilde c38c988c3c feat: Anmelden mit Passkey
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".

Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.

TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.

Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
  nicht in der Server Action davor. Ein "war schon geprueft" ueber die
  Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
  lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
  verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
  zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
  Anmeldung nennt den verwendeten Passkey namentlich.

Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
  prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
  entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
  Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
  Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
  geloeschte.

Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.

Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
2026-08-22 18:44:56 +02:00

373 lines
13 KiB
TypeScript

import NextAuth, { CredentialsSignin } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import type { Rolle } from '@prisma/client'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
import {
anmeldungErlaubt,
aufMindestdauerWarten,
fehlversucheZuruecksetzen,
versuchFesthalten,
} from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit'
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
/**
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
*
* Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste
* zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig,
* warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein
* Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt.
*
* Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
*/
export type AnmeldeGrund =
| 'daten-falsch'
| 'gesperrt'
| 'konto-inaktiv'
| 'code-falsch'
/** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */
| 'passkey'
/**
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
* Deshalb erbt der Fehler von CredentialsSignin und kodiert eine etwaige
* Restsperrzeit in den Code hinein: "gesperrt:120".
*/
export class AnmeldeFehler extends CredentialsSignin {
code: string
constructor(grund: AnmeldeGrund, nochSekunden?: number) {
super(grund)
this.code = nochSekunden ? `${grund}:${nochSekunden}` : grund
}
}
/**
* Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:<grund>"
* kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann.
*/
export class PasskeyAnmeldeFehler extends CredentialsSignin {
code: string
constructor(grund: string) {
super(grund)
this.code = `passkey:${grund}`
}
}
function ipAusAnfrage(anfrage: Request | undefined): string | null {
if (!anfrage) return null
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
}
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
secret: process.env.AUTH_SECRET,
session: {
strategy: 'jwt',
// Leerlauf bis zur automatischen Abmeldung.
maxAge: env.sitzungMaxAlterSekunden,
// Bei jeder Anfrage verlaengern, solange gearbeitet wird.
updateAge: 60,
},
pages: {
signIn: '/anmelden',
error: '/anmelden',
},
providers: [
Credentials({
id: 'zugangsdaten',
name: 'E-Mail, Passwort und Code',
credentials: {
email: { label: 'E-Mail', type: 'email' },
passwort: { label: 'Passwort', type: 'password' },
code: { label: 'Sechsstelliger Code', type: 'text' },
},
async authorize(daten, anfrage) {
const begonnen = Date.now()
const email = String(daten?.email ?? '').trim().toLowerCase()
const passwort = String(daten?.passwort ?? '')
const code = String(daten?.code ?? '')
const ip = ipAusAnfrage(anfrage)
if (!email || !passwort) {
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('daten-falsch')
}
// 1. Sperre pruefen, bevor teuer gerechnet wird.
const sperre = await anmeldungErlaubt(email, ip)
if (sperre.gesperrt) {
await versuchFesthalten(email, ip, false, 'gesperrt')
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
}
const benutzer = await db.user.findUnique({ where: { email } })
// 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet,
// damit die Antwortzeit nichts verraet.
const hashZumPruefen =
benutzer?.passwortHash ??
'$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort)
if (!benutzer || !passwortStimmt) {
await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer?.id ?? null,
userEmail: email,
})
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('daten-falsch')
}
if (!benutzer.aktiv || benutzer.geloeschtAm) {
await versuchFesthalten(email, ip, false, 'konto-inaktiv')
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('konto-inaktiv')
}
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer
// zwar herein, das App-Layout schickt ihn aber sofort zur
// Einrichtung - er kann nichts anderes tun.
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
const codeOhneTrenner = code.replace(/[\s-]/g, '')
let zweiterFaktorOk = false
if (/^\d{6}$/.test(codeOhneTrenner)) {
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
} else if (codeOhneTrenner.length >= 12) {
// Wiederherstellungscode - wird nach Gebrauch entfernt.
const treffer = await wiederherstellungscodeFinden(
benutzer.wiederherstellungscodes,
code,
)
if (treffer >= 0) {
zweiterFaktorOk = true
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
await db.user.update({
where: { id: benutzer.id },
data: { wiederherstellungscodes: rest },
})
}
}
if (!zweiterFaktorOk) {
await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer.id,
userEmail: email,
userId: benutzer.id,
bezeichnung: 'Zweiter Faktor falsch',
})
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('code-falsch')
}
}
// 4. Erfolg.
if (hashVeraltet(benutzer.passwortHash)) {
// Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen.
await db.user.update({
where: { id: benutzer.id },
data: { passwortHash: await passwortHashen(passwort) },
})
}
await Promise.all([
versuchFesthalten(email, ip, true),
fehlversucheZuruecksetzen(email),
db.user.update({
where: { id: benutzer.id },
data: { letzteAnmeldung: new Date() },
}),
protokollieren({
aktion: 'ANMELDUNG',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
}),
])
await aufMindestdauerWarten(begonnen)
return {
id: benutzer.id,
email: benutzer.email,
name: benutzer.name,
rolle: benutzer.rolle,
sitzungsGeneration: benutzer.sitzungsGeneration,
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
}
},
}),
/**
* Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort.
*
* Die vollstaendige Pruefung laeuft hier drin und nicht in der Server
* Action davor. Das ist Absicht: Wuerde die Action pruefen und das
* Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen
* Zwischenzustand, dem authorize() glauben muesste. Genau solche
* "war schon geprueft"-Uebergaben sind die Stelle, an der sich
* Anmeldungen faelschen lassen. Hier gibt es sie nicht.
*/
Credentials({
id: 'passkey',
name: 'Passkey',
credentials: {
antwort: { label: 'Antwort des Authenticators', type: 'text' },
},
async authorize(daten, anfrage) {
const ip = ipAusAnfrage(anfrage)
let antwort: AuthenticationResponseJSON
try {
antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON
} catch {
throw new PasskeyAnmeldeFehler('ungueltig')
}
let geprueft
try {
geprueft = await anmeldungPruefen(antwort)
} catch (fehler) {
// Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist -
// sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei.
await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
userEmail: '(passkey)',
bezeichnung:
fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen',
})
throw new PasskeyAnmeldeFehler(
fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig',
)
}
const { benutzer, passkey } = geprueft
// Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie
// wird erst hier geprueft, weil vorher niemand weiss, wer sich
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
const sperre = await anmeldungErlaubt(benutzer.email, ip)
if (sperre.gesperrt) {
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt')
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
}
if (!benutzer.aktiv || benutzer.geloeschtAm) {
await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv')
throw new AnmeldeFehler('konto-inaktiv')
}
await Promise.all([
versuchFesthalten(benutzer.email, ip, true, 'passkey'),
fehlversucheZuruecksetzen(benutzer.email),
db.user.update({
where: { id: benutzer.id },
data: { letzteAnmeldung: new Date() },
}),
protokollieren({
aktion: 'ANMELDUNG',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
bezeichnung: `Mit Passkey „${passkey.name}“`,
}),
])
return {
id: benutzer.id,
email: benutzer.email,
name: benutzer.name,
rolle: benutzer.rolle,
sitzungsGeneration: benutzer.sitzungsGeneration,
// Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne
// eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das
// Flag ist hier also immer wahr - es wird trotzdem aus dem
// Benutzer gelesen und nicht fest gesetzt.
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
}
},
}),
],
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.rolle = user.rolle
token.sitzungsGeneration = user.sitzungsGeneration
token.zweiterFaktorAktiv = user.zweiterFaktorAktiv
// Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die
// erneute Bestaetigung vor sensiblen Aktionen.
token.angemeldetSeit = Math.floor(Date.now() / 1000)
}
// Nach dem Einrichten des zweiten Faktors aktualisiert die Seite
// die Sitzung, ohne dass man sich neu anmelden muss.
if (trigger === 'update' && session) {
const aktualisierung = session as Partial<{
zweiterFaktorAktiv: boolean
sitzungsGeneration: number
}>
if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') {
token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv
}
if (typeof aktualisierung.sitzungsGeneration === 'number') {
token.sitzungsGeneration = aktualisierung.sitzungsGeneration
}
}
return token
},
async session({ session, token }) {
session.user.id = token.sub ?? ''
session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER'
session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0
session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv)
session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0
return session
},
},
events: {
async signOut(nachricht) {
const userId = 'token' in nachricht ? nachricht.token?.sub : undefined
if (userId) {
await protokollieren({
aktion: 'ABMELDUNG',
entitaet: 'User',
entitaetId: userId,
userId,
})
}
},
},
})