feat: Anmeldung mit Pflicht-2FA, Rollen und Audit-Log

Auth.js mit Zugangsdaten-Provider: E-Mail, Passwort und TOTP-Code werden
in einem Formular abgefragt. Ein zweistufiger Ablauf muesste zwischen den
Schritten einen Zwischenzustand halten - genau das ist die uebliche
Schwachstelle, und am Handy ist ein Formular ohnehin schneller.

Anmeldung:
- Sperre wird VOR dem Passwortvergleich geprueft, damit ein gesperrter
  Zugang nicht einmal Argon2-Rechenzeit kostet.
- Bei unbekannter Adresse wird gegen einen Dummy-Hash gerechnet und die
  Antwort auf eine Mindestdauer gestreckt. Sonst verraet die Antwortzeit,
  welche Adressen existieren.
- Wiederherstellungscodes werden nach Gebrauch entfernt.
- Veraltete Argon2-Parameter werden beim erfolgreichen Login nachgezogen.

Rate-Limiting in der Datenbank statt Redis - ein Server, wenige Benutzer,
kein Grund fuer eine weitere Komponente. Gezaehlt wird nach E-Mail und
nach Anschluss, damit weder viele Anschluesse noch viele Adressen helfen.

Zugriffsschutz (sitzung.ts):
- Die Sitzung allein entscheidet nichts. Bei jedem Aufruf wird der
  Benutzer frisch geladen und die Sitzungsgeneration verglichen - so
  lassen sich alle Anmeldungen auf einen Schlag beenden.
- erfordereZugang() prueft immer gegen die betreuteId, nie nur im UI.
- Erneute Bestaetigung vor sensiblen Aktionen ueber ein signiertes,
  kurzlebiges Cookie statt ueber Serverzustand.

Audit-Log: aenderungErmitteln() protokolliert nur die tatsaechlich
geaenderten Felder; Passwoerter und TOTP-Geheimnisse werden ersetzt, nie
gespeichert. Ein Fehler im Protokoll bricht die Fachaktion nicht ab -
ausser die Aktion laeuft in einer Transaktion, dann faellt beides.

CSP mit Nonce pro Anfrage in der Middleware: keine Fremdquellen, kein
CDN, keine Fonts von aussen. Fachseiten werden nicht zwischengespeichert.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:46:20 +02:00
parent c49810d092
commit bd1c7b801f
9 changed files with 1031 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
import NextAuth 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'
/**
* 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 class AnmeldeFehler extends Error {
constructor(
public readonly grund:
| 'daten-falsch'
| 'gesperrt'
| 'konto-inaktiv'
| 'zweiter-faktor-fehlt'
| 'code-falsch',
public readonly nochSekunden?: number,
) {
super(grund)
this.name = 'AnmeldeFehler'
}
}
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),
}
},
}),
],
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,
})
}
},
},
})