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
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse, type NextRequest } from 'next/server'
/**
* Content-Security-Policy mit Nonce.
*
* Warum hier und nicht in next.config? Weil die Nonce pro Anfrage neu sein
* muss. Statische Header koennen das nicht.
*
* Die Richtlinie ist bewusst eng: keine fremden Quellen, keine Fonts von
* Google, kein CDN, keine Analytics. Was nicht aus dieser Anwendung kommt,
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
*
* 'strict-dynamic' erlaubt es dem Next.js-Loader, weitere eigene Skripte
* nachzuladen, ohne dass jede einzelne Datei aufgelistet werden muss.
*
* Bewusste Zugestaendnisse:
* - style-src erlaubt 'unsafe-inline'. Tailwind selbst braucht das nicht,
* aber Next.js und Radix setzen Inline-Styles fuer Positionierung. Das
* Risiko ist bei Styles deutlich kleiner als bei Skripten.
* - img-src erlaubt data: - QR-Codes und Belegvorschauen werden als
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
* - Im Entwicklungsmodus braucht der Hot-Reload 'unsafe-eval'.
*/
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */
const OEFFENTLICHE_PFADE = [
'/anmelden',
'/ersteinrichtung',
'/datenschutz',
'/impressum',
'/api/auth',
]
function nonceErzeugen(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return btoa(String.fromCharCode(...bytes))
}
export function middleware(anfrage: NextRequest) {
const nonce = nonceErzeugen()
const istEntwicklung = process.env.NODE_ENV === 'development'
const richtlinie = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${istEntwicklung ? " 'unsafe-eval'" : ''}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
// Uploads und Server Actions gehen an die eigene Herkunft. Im
// Entwicklungsmodus zusaetzlich der WebSocket des Hot-Reloads.
`connect-src 'self'${istEntwicklung ? ' ws: wss:' : ''}`,
`media-src 'self' blob:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`frame-src 'self' blob:`,
`worker-src 'self' blob:`,
`manifest-src 'self'`,
...(istEntwicklung ? [] : ['upgrade-insecure-requests']),
].join('; ')
const kopf = new Headers(anfrage.headers)
kopf.set('x-nonce', nonce)
const antwort = NextResponse.next({ request: { headers: kopf } })
antwort.headers.set('Content-Security-Policy', richtlinie)
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
// zwischengespeichert wird.
if (!OEFFENTLICHE_PFADE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) {
antwort.headers.set('Cache-Control', 'no-store, must-revalidate')
}
return antwort
}
export const config = {
matcher: [
/*
* Alles ausser statischen Dateien und dem Favicon. Der negative
* Lookahead haelt die Middleware von Auslieferungspfaden fern, wo sie
* nur Rechenzeit kosten wuerde.
*/
{
source: '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|webp|ico|woff2?)$).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}