Files
betreuungs-cockpit/src/lib/audit.ts
T
Florian Wilde bd1c7b801f 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
2026-08-22 01:46:20 +02:00

168 lines
5.5 KiB
TypeScript

import 'server-only'
import { headers } from 'next/headers'
import type { AuditAktion, Prisma } from '@prisma/client'
import { db } from '@/lib/db'
import { verschluesseln } from '@/lib/crypto/feld'
import { ipHashen } from '@/lib/crypto/hash'
/**
* Audit-Log.
*
* Jede schreibende Aktion und jeder Dokumentzugriff wird protokolliert:
* wer, wann, was, alter Wert -> neuer Wert. Der Eintrag ist unveraenderlich
* (Datenbank-Trigger, siehe prisma/sql/audit-schutz.sql).
*
* Das ist nicht nur Datenschutz-Kuer. Als ehrenamtlicher Betreuer muss ich
* gegenueber dem Betreuungsgericht und moeglichen Miterben belegen koennen,
* was ich wann getan habe. Das Log ist dieser Beleg.
*
* Die geaenderten Werte werden verschluesselt abgelegt - dort stehen sonst
* Diagnosen und Kontostaende im Klartext.
*/
export type Aenderung = Record<string, { alt: unknown; neu: unknown }>
type ProtokollEingabe = {
aktion: AuditAktion
entitaet: string
entitaetId?: string | null
bezeichnung?: string | null
betreuteId?: string | null
userId?: string | null
userEmail?: string | null
userName?: string | null
aenderung?: Aenderung | null
/** Optional: laeuft die Aktion in einer Transaktion mit? */
tx?: Prisma.TransactionClient
}
/** Felder, die nie ins Log gehoeren - auch nicht verschluesselt. */
const NIEMALS_PROTOKOLLIEREN = new Set([
'passwortHash',
'passwort',
'passwortWiederholung',
'totpSecretVerschluesselt',
'wiederherstellungscodes',
])
/**
* Liest IP und User-Agent aus der laufenden Anfrage. Ausserhalb eines
* Request-Kontexts (z.B. im Seed oder in einem Cron-Job) gibt es beides
* nicht - dann bleibt das Feld leer, statt dass die Aktion scheitert.
*/
async function anfragekontext(): Promise<{ ipHash: string | null; userAgent: string | null }> {
try {
const kopf = await headers()
// Hinter dem Reverse Proxy steht die echte Adresse in X-Forwarded-For.
const weitergeleitet = kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
const ip = weitergeleitet || kopf.get('x-real-ip') || null
return {
ipHash: ipHashen(ip),
userAgent: kopf.get('user-agent')?.slice(0, 250) ?? null,
}
} catch {
return { ipHash: null, userAgent: null }
}
}
/**
* Schreibt einen Eintrag. Wirft bewusst nicht: ein Fehler im Protokoll
* darf die eigentliche Fachaktion nicht abbrechen - er wird geloggt und
* faellt beim naechsten Blick in die Serverlogs auf.
*
* Laeuft die Aktion in einer Transaktion (tx uebergeben), gilt das nicht:
* dann soll der Protokolleintrag zusammen mit der Aenderung stehen oder
* fallen.
*/
export async function protokollieren(eingabe: ProtokollEingabe): Promise<void> {
const { tx, aenderung, ...rest } = eingabe
const klient = tx ?? db
try {
const kontext = await anfragekontext()
const gefiltert = aenderung ? aenderungFiltern(aenderung) : null
await klient.auditLog.create({
data: {
aktion: rest.aktion,
entitaet: rest.entitaet,
entitaetId: rest.entitaetId ?? null,
bezeichnung: rest.bezeichnung ?? null,
betreuteId: rest.betreuteId ?? null,
userId: rest.userId ?? null,
userEmail: rest.userEmail ?? null,
userName: rest.userName ?? null,
aenderungVerschluesselt:
gefiltert && Object.keys(gefiltert).length > 0
? verschluesseln(JSON.stringify(gefiltert), 'aenderungVerschluesselt')
: null,
ipHash: kontext.ipHash,
userAgent: kontext.userAgent,
},
})
} catch (fehler) {
if (tx) throw fehler
console.error('[audit] Eintrag konnte nicht geschrieben werden:', fehler)
}
}
function aenderungFiltern(aenderung: Aenderung): Aenderung {
const ergebnis: Aenderung = {}
for (const [feld, werte] of Object.entries(aenderung)) {
if (NIEMALS_PROTOKOLLIEREN.has(feld)) {
ergebnis[feld] = { alt: '[entfernt]', neu: '[entfernt]' }
continue
}
ergebnis[feld] = werte
}
return ergebnis
}
/**
* Vergleicht zwei Datensaetze und liefert nur die tatsaechlich geaenderten
* Felder. So steht im Log nicht bei jedem Speichern der ganze Datensatz.
*
* Verschluesselte Felder werden am Suffix erkannt: verglichen wird der
* Klartext (den der Aufrufer mitgibt), protokolliert ebenfalls der
* Klartext - der Eintrag selbst ist ja verschluesselt.
*/
export function aenderungErmitteln(
vorher: Record<string, unknown>,
nachher: Record<string, unknown>,
): Aenderung {
const aenderung: Aenderung = {}
for (const feld of Object.keys(nachher)) {
if (feld.endsWith('Verschluesselt')) continue // Umschlaege aendern sich immer
if (['aktualisiertAm', 'erstelltAm', 'id'].includes(feld)) continue
const alt = vorher[feld]
const neu = nachher[feld]
if (!gleich(alt, neu)) {
aenderung[feld] = { alt: normalisieren(alt), neu: normalisieren(neu) }
}
}
return aenderung
}
function gleich(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
if (a instanceof Date || b instanceof Date) {
return normalisieren(a) === normalisieren(b)
}
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((wert, i) => gleich(wert, b[i]))
}
if (a === null || a === undefined) return b === null || b === undefined
return false
}
/** BigInt und Date sind nicht JSON-faehig - hier in Text wandeln. */
function normalisieren(wert: unknown): unknown {
if (wert instanceof Date) return wert.toISOString()
if (typeof wert === 'bigint') return wert.toString()
return wert ?? null
}