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:
@@ -0,0 +1,167 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user