Files
betreuungs-cockpit/src/auth.ts
T
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00

359 lines
12 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 } from '@/lib/crypto/hash'
import {
anmeldungErlaubt,
aufMindestdauerWarten,
fehlversucheZuruecksetzen,
sperreVermerken,
versuchFesthalten,
} from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit'
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
import { anschlussAus } from '@/lib/auth/anschluss'
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
return anschlussAus(anfrage.headers)
}
export const { handlers, signIn, signOut, auth } = NextAuth({
// Siehe env.vertraueHost: Ist AUTH_URL gesetzt, wird dem Host-Kopf nicht
// geglaubt. Vorher stand hier fest "true".
trustHost: env.vertraueHost,
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 sperreVermerken(email, ip)
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. Ist er noch nicht eingerichtet, kommt der
// Benutzer nur bis zur Einrichtungsseite - erzwungen in
// erfordereBenutzer(), nicht erst im Layout.
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
if (!eingeloest.ok) {
await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer.id,
userEmail: email,
userId: benutzer.id,
bezeichnung:
eingeloest.grund === 'wiederverwendet'
? 'Zweiter Faktor bereits verbraucht'
: '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 sperreVermerken(benutzer.email, ip)
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,
})
}
},
},
})