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
168 lines
5.5 KiB
TypeScript
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
|
|
}
|