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,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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user