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
+59
View File
@@ -0,0 +1,59 @@
import 'server-only'
import { PrismaClient } from '@prisma/client'
/**
* Prisma-Client als Singleton.
*
* Im Entwicklungsmodus laedt Next.js Module bei jeder Aenderung neu. Ohne
* diesen Umweg ueber globalThis entstuende bei jedem Speichern eine neue
* Verbindung, bis Postgres keine mehr annimmt.
*/
const globalFuerPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalFuerPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
: [{ emit: 'stdout', level: 'error' }],
})
if (process.env.NODE_ENV !== 'production') {
globalFuerPrisma.prisma = db
}
/**
* Standardfilter: Der Papierkorb wird ueberall ausgeblendet. Wer die
* geloeschten Datensaetze sehen will, fragt sie ausdruecklich ab.
*/
export const nichtGeloescht = { geloeschtAm: null } as const
/**
* Stichtag, ab dem ein Datensatz im Papierkorb endgueltig entfernt wird.
* 30 Tage - lang genug fuer ein "oh, das brauchte ich doch noch".
*/
export const PAPIERKORB_TAGE = 30
export function papierkorbGrenze(jetzt = new Date()): Date {
const grenze = new Date(jetzt)
grenze.setDate(grenze.getDate() - PAPIERKORB_TAGE)
return grenze
}
/**
* BigInt laesst sich nicht als JSON serialisieren. Server Components
* reichen Daten an Client Components weiter - deshalb hier ein Helfer,
* der Cent-Betraege auf dem Weg nach draussen in Number wandelt.
* Cent-Betraege bis 90 Billionen Euro bleiben dabei exakt.
*/
export function centZuZahl(wert: bigint | null | undefined): number | null {
if (wert === null || wert === undefined) return null
if (wert > BigInt(Number.MAX_SAFE_INTEGER) || wert < BigInt(-Number.MAX_SAFE_INTEGER)) {
throw new Error(`Betrag ${wert} liegt ausserhalb des sicher darstellbaren Bereichs.`)
}
return Number(wert)
}