diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9dc6f40 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,9 @@ +/** + * Auth.js-Endpunkte (Anmelden, Abmelden, Sitzung, CSRF). + * + * Node.js-Laufzeit ist Pflicht: Argon2 ist eine native Bibliothek und + * laeuft nicht auf der Edge-Runtime. + */ +export const runtime = 'nodejs' + +export { GET, POST } from '@/auth' diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..fb5db15 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,255 @@ +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' +import type { Rolle } from '@prisma/client' +import { db } from '@/lib/db' +import { env } from '@/lib/env' +import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash' +import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp' +import { + anmeldungErlaubt, + aufMindestdauerWarten, + fehlversucheZuruecksetzen, + versuchFesthalten, +} from '@/lib/auth/ratelimit' +import { protokollieren } from '@/lib/audit' + +/** + * Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt. + * + * Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste + * zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig, + * warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein + * Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt. + * + * Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der + * Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout. + */ + +export class AnmeldeFehler extends Error { + constructor( + public readonly grund: + | 'daten-falsch' + | 'gesperrt' + | 'konto-inaktiv' + | 'zweiter-faktor-fehlt' + | 'code-falsch', + public readonly nochSekunden?: number, + ) { + super(grund) + this.name = 'AnmeldeFehler' + } +} + +function ipAusAnfrage(anfrage: Request | undefined): string | null { + if (!anfrage) return null + const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + return weitergeleitet || anfrage.headers.get('x-real-ip') || null +} + +export const { handlers, signIn, signOut, auth } = NextAuth({ + trustHost: true, + secret: process.env.AUTH_SECRET, + + session: { + strategy: 'jwt', + // Leerlauf bis zur automatischen Abmeldung. + maxAge: env.sitzungMaxAlterSekunden, + // Bei jeder Anfrage verlaengern, solange gearbeitet wird. + updateAge: 60, + }, + + pages: { + signIn: '/anmelden', + error: '/anmelden', + }, + + providers: [ + Credentials({ + id: 'zugangsdaten', + name: 'E-Mail, Passwort und Code', + credentials: { + email: { label: 'E-Mail', type: 'email' }, + passwort: { label: 'Passwort', type: 'password' }, + code: { label: 'Sechsstelliger Code', type: 'text' }, + }, + + async authorize(daten, anfrage) { + const begonnen = Date.now() + const email = String(daten?.email ?? '').trim().toLowerCase() + const passwort = String(daten?.passwort ?? '') + const code = String(daten?.code ?? '') + const ip = ipAusAnfrage(anfrage) + + if (!email || !passwort) { + await aufMindestdauerWarten(begonnen) + throw new AnmeldeFehler('daten-falsch') + } + + // 1. Sperre pruefen, bevor teuer gerechnet wird. + const sperre = await anmeldungErlaubt(email, ip) + if (sperre.gesperrt) { + await versuchFesthalten(email, ip, false, 'gesperrt') + throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) + } + + const benutzer = await db.user.findUnique({ where: { email } }) + + // 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet, + // damit die Antwortzeit nichts verraet. + const hashZumPruefen = + benutzer?.passwortHash ?? + '$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort) + + if (!benutzer || !passwortStimmt) { + await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt') + await protokollieren({ + aktion: 'ANMELDUNG_FEHLGESCHLAGEN', + entitaet: 'User', + entitaetId: benutzer?.id ?? null, + userEmail: email, + }) + await aufMindestdauerWarten(begonnen) + throw new AnmeldeFehler('daten-falsch') + } + + if (!benutzer.aktiv || benutzer.geloeschtAm) { + await versuchFesthalten(email, ip, false, 'konto-inaktiv') + await aufMindestdauerWarten(begonnen) + throw new AnmeldeFehler('konto-inaktiv') + } + + // 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer + // zwar herein, das App-Layout schickt ihn aber sofort zur + // Einrichtung - er kann nichts anderes tun. + if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) { + const codeOhneTrenner = code.replace(/[\s-]/g, '') + let zweiterFaktorOk = false + + if (/^\d{6}$/.test(codeOhneTrenner)) { + const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt) + zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig + } else if (codeOhneTrenner.length >= 12) { + // Wiederherstellungscode - wird nach Gebrauch entfernt. + const treffer = await wiederherstellungscodeFinden( + benutzer.wiederherstellungscodes, + code, + ) + if (treffer >= 0) { + zweiterFaktorOk = true + const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer) + await db.user.update({ + where: { id: benutzer.id }, + data: { wiederherstellungscodes: rest }, + }) + } + } + + if (!zweiterFaktorOk) { + await versuchFesthalten(email, ip, false, 'code-falsch') + await protokollieren({ + aktion: 'ANMELDUNG_FEHLGESCHLAGEN', + entitaet: 'User', + entitaetId: benutzer.id, + userEmail: email, + userId: benutzer.id, + bezeichnung: 'Zweiter Faktor falsch', + }) + await aufMindestdauerWarten(begonnen) + throw new AnmeldeFehler('code-falsch') + } + } + + // 4. Erfolg. + if (hashVeraltet(benutzer.passwortHash)) { + // Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen. + await db.user.update({ + where: { id: benutzer.id }, + data: { passwortHash: await passwortHashen(passwort) }, + }) + } + + await Promise.all([ + versuchFesthalten(email, ip, true), + fehlversucheZuruecksetzen(email), + db.user.update({ + where: { id: benutzer.id }, + data: { letzteAnmeldung: new Date() }, + }), + protokollieren({ + aktion: 'ANMELDUNG', + entitaet: 'User', + entitaetId: benutzer.id, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }), + ]) + + await aufMindestdauerWarten(begonnen) + + return { + id: benutzer.id, + email: benutzer.email, + name: benutzer.name, + rolle: benutzer.rolle, + sitzungsGeneration: benutzer.sitzungsGeneration, + zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm), + } + }, + }), + ], + + callbacks: { + async jwt({ token, user, trigger, session }) { + if (user) { + token.rolle = user.rolle + token.sitzungsGeneration = user.sitzungsGeneration + token.zweiterFaktorAktiv = user.zweiterFaktorAktiv + // Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die + // erneute Bestaetigung vor sensiblen Aktionen. + token.angemeldetSeit = Math.floor(Date.now() / 1000) + } + + // Nach dem Einrichten des zweiten Faktors aktualisiert die Seite + // die Sitzung, ohne dass man sich neu anmelden muss. + if (trigger === 'update' && session) { + const aktualisierung = session as Partial<{ + zweiterFaktorAktiv: boolean + sitzungsGeneration: number + }> + if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') { + token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv + } + if (typeof aktualisierung.sitzungsGeneration === 'number') { + token.sitzungsGeneration = aktualisierung.sitzungsGeneration + } + } + + return token + }, + + async session({ session, token }) { + session.user.id = token.sub ?? '' + session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER' + session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0 + session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv) + session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0 + return session + }, + }, + + events: { + async signOut(nachricht) { + const userId = 'token' in nachricht ? nachricht.token?.sub : undefined + if (userId) { + await protokollieren({ + aktion: 'ABMELDUNG', + entitaet: 'User', + entitaetId: userId, + userId, + }) + } + }, + }, +}) diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..bb80e3e --- /dev/null +++ b/src/lib/audit.ts @@ -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 + +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 { + 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, + nachher: Record, +): 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 +} diff --git a/src/lib/auth/ratelimit.ts b/src/lib/auth/ratelimit.ts new file mode 100644 index 0000000..a3723ce --- /dev/null +++ b/src/lib/auth/ratelimit.ts @@ -0,0 +1,107 @@ +import 'server-only' +import { db } from '@/lib/db' +import { env } from '@/lib/env' +import { ipHashen } from '@/lib/crypto/hash' + +/** + * Rate-Limiting am Anmeldeformular. + * + * Bewusst in der Datenbank statt in Redis: die Anwendung laeuft auf einem + * einzelnen kleinen Server, eine zusaetzliche Komponente waere Aufwand ohne + * Gegenwert. Bei ein paar Benutzern und wenigen Anmeldungen pro Tag ist die + * Tabelle winzig; ein Aufraeumen nach 30 Tagen haelt sie klein. + * + * Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es + * weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele + * Adressen von einem Anschluss. + */ + +export type SperrErgebnis = + | { gesperrt: false; verbleibendeVersuche: number } + | { gesperrt: true; nochSekunden: number } + +function fensterStart(): Date { + return new Date(Date.now() - env.loginFensterSekunden * 1000) +} + +/** + * Prueft, ob eine Anmeldung ueberhaupt versucht werden darf. Wird VOR dem + * Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst + * Rechenzeit fuer Argon2 kosten. + */ +export async function anmeldungErlaubt( + email: string, + ip: string | null, +): Promise { + const seit = fensterStart() + const kennung = email.trim().toLowerCase() + const ipH = ipHashen(ip) + + const fehlversuche = await db.loginVersuch.findMany({ + where: { + erfolg: false, + zeitpunkt: { gte: seit }, + OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])], + }, + orderBy: { zeitpunkt: 'desc' }, + select: { zeitpunkt: true }, + take: env.loginMaxVersuche + 1, + }) + + if (fehlversuche.length >= env.loginMaxVersuche) { + const juengster = fehlversuche[0]!.zeitpunkt.getTime() + const frei = juengster + env.loginSperreSekunden * 1000 + const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000)) + return { gesperrt: true, nochSekunden } + } + + return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length } +} + +/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */ +export async function versuchFesthalten( + email: string, + ip: string | null, + erfolg: boolean, + grund?: string, +): Promise { + await db.loginVersuch.create({ + data: { + email: email.trim().toLowerCase(), + ipHash: ipHashen(ip), + erfolg, + grund: grund ?? null, + }, + }) +} + +/** + * Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser + * Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen. + */ +export async function fehlversucheZuruecksetzen(email: string): Promise { + await db.loginVersuch.deleteMany({ + where: { email: email.trim().toLowerCase(), erfolg: false }, + }) +} + +/** Aufraeumen; wird vom Wartungslauf aufgerufen. */ +export async function alteVersucheEntfernen(): Promise { + const grenze = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const { count } = await db.loginVersuch.deleteMany({ + where: { zeitpunkt: { lt: grenze } }, + }) + return count +} + +/** + * Gleicht die Antwortzeit an. Ohne das verraet ein schneller "Benutzer + * unbekannt" gegenueber einem langsamen "Passwort falsch", welche + * E-Mail-Adressen es gibt. + */ +export async function aufMindestdauerWarten(begonnenAm: number, mindestMs = 400): Promise { + const vergangen = Date.now() - begonnenAm + if (vergangen < mindestMs) { + await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen)) + } +} diff --git a/src/lib/auth/rollen.ts b/src/lib/auth/rollen.ts new file mode 100644 index 0000000..04329a3 --- /dev/null +++ b/src/lib/auth/rollen.ts @@ -0,0 +1,89 @@ +import type { Modul, Rolle } from '@prisma/client' + +/** + * Rollen und Modulfreigaben - ohne "server-only", weil die Beschriftungen + * auch im Browser gebraucht werden. + * + * Wichtig: Diese Datei entscheidet nichts allein. Der eigentliche + * Zugriffsschutz sitzt in src/lib/auth/sitzung.ts und prueft serverseitig + * gegen die betreuteId. Hier stehen nur die Regeln, damit sie an einem Ort + * nachlesbar sind. + */ + +export const ROLLEN_BESCHRIFTUNG: Record = { + BETREUER: 'Betreuer', + MITLESER: 'Mitleser', + ADMIN: 'Administrator', +} + +export const ROLLEN_ERKLAERUNG: Record = { + BETREUER: 'Darf alles sehen und ändern, was zu den zugeordneten Betreuten gehört.', + MITLESER: 'Darf nur lesen, und nur die ausdrücklich freigegebenen Bereiche.', + ADMIN: 'Verwaltet Benutzer und Systemeinstellungen.', +} + +export const MODUL_BESCHRIFTUNG: Record = { + STAMMDATEN: 'Stammdaten', + VORGAENGE: 'Vorgänge', + FRISTEN: 'Fristen', + DOKUMENTE: 'Dokumente', + FINANZEN: 'Vermögen & Kassenbuch', + SOZIALHILFE: 'Sozialhilfe', + IMMOBILIE: 'Immobilie', + VERTRAEGE: 'Versicherungen & Verträge', + GESUNDHEIT: 'Heim & Pflege', + KONTAKTE: 'Kontakte', + BERICHTE: 'Berichte', +} + +export const ALLE_MODULE: Modul[] = [ + 'STAMMDATEN', + 'VORGAENGE', + 'FRISTEN', + 'DOKUMENTE', + 'FINANZEN', + 'SOZIALHILFE', + 'IMMOBILIE', + 'VERTRAEGE', + 'GESUNDHEIT', + 'KONTAKTE', + 'BERICHTE', +] + +/** + * Module mit besonders sensiblen Inhalten. Fuer sie ist eine Freigabe an + * Mitleser eine bewusste Entscheidung - die Oberflaeche weist darauf hin. + */ +export const BESONDERS_SENSIBEL: Modul[] = ['GESUNDHEIT', 'FINANZEN', 'SOZIALHILFE'] + +export type Zugriffsart = 'lesen' | 'schreiben' + +export type Zugang = { + rolle: Rolle + freigegebeneModule: Modul[] +} + +/** + * Die eine Regel, nach der entschieden wird: + * + * - BETREUER darf alles, lesen wie schreiben. + * - ADMIN darf alles (er verwaltet die Anwendung). + * - MITLESER darf ausschliesslich lesen, und nur freigegebene Module. + */ +export function darf(zugang: Zugang | null, modul: Modul, art: Zugriffsart = 'lesen'): boolean { + if (!zugang) return false + + if (zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN') return true + + if (zugang.rolle === 'MITLESER') { + if (art === 'schreiben') return false + return zugang.freigegebeneModule.includes(modul) + } + + return false +} + +/** Kurzform fuer die Oberflaeche. */ +export function darfSchreiben(zugang: Zugang | null, modul: Modul): boolean { + return darf(zugang, modul, 'schreiben') +} diff --git a/src/lib/auth/sitzung.ts b/src/lib/auth/sitzung.ts new file mode 100644 index 0000000..ad0f624 --- /dev/null +++ b/src/lib/auth/sitzung.ts @@ -0,0 +1,213 @@ +import 'server-only' +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import { createHmac, timingSafeEqual } from 'node:crypto' +import type { Betreute, Modul, User } from '@prisma/client' +import { auth } from '@/auth' +import { db, nichtGeloescht } from '@/lib/db' +import { env } from '@/lib/env' +import { darf, type Zugang, type Zugriffsart } from '@/lib/auth/rollen' + +/** + * Zugriffsschutz. + * + * Grundsatz: Es wird immer serverseitig gegen die betreuteId geprueft, nie + * nur im UI ausgeblendet. Jede Seite und jede Server Action, die Daten einer + * betreuten Person anfasst, geht durch erfordereZugang(). + */ + +export const COOKIE_AKTIVE_BETREUTE = 'bc_betreute' +export const COOKIE_REAUTH = 'bc_reauth' + +export class ZugriffVerweigert extends Error { + constructor(nachricht = 'Für diesen Bereich fehlt dir die Berechtigung.') { + super(nachricht) + this.name = 'ZugriffVerweigert' + } +} + +/** + * Der angemeldete Benutzer, frisch aus der Datenbank. + * + * Die Sitzung allein reicht nicht: Rolle, Aktiv-Status und die + * Sitzungsgeneration koennen sich geaendert haben, seit das Token + * ausgestellt wurde. Stimmt die Generation nicht mehr, ist die Sitzung + * ungueltig - so lassen sich alle Anmeldungen eines Benutzers auf einen + * Schlag beenden (Passwortwechsel, Verdacht auf Kompromittierung). + */ +export async function aktuellerBenutzer(): Promise { + const sitzung = await auth() + if (!sitzung?.user?.id) return null + + const benutzer = await db.user.findUnique({ where: { id: sitzung.user.id } }) + if (!benutzer || !benutzer.aktiv || benutzer.geloeschtAm) return null + if (benutzer.sitzungsGeneration !== sitzung.user.sitzungsGeneration) return null + + return benutzer +} + +/** Wie aktuellerBenutzer, leitet aber zum Anmeldeformular um. */ +export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise { + const benutzer = await aktuellerBenutzer() + if (!benutzer) { + const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : '' + redirect(`/anmelden${ziel}`) + } + return benutzer +} + +/** + * Erzwingt den eingerichteten zweiten Faktor. Wird im App-Layout + * aufgerufen: ohne TOTP kommt niemand an Fachdaten. + */ +export async function erfordereZweitenFaktor(benutzer: User): Promise { + if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) { + redirect('/zwei-faktor/einrichten') + } +} + +// ----------------------------------------------------------------------------- +// Zugriff auf eine betreute Person +// ----------------------------------------------------------------------------- + +export type ZugangsKontext = { + benutzer: User + betreute: Betreute + zugang: Zugang + /** Bequemer Test fuer die Oberflaeche. */ + darfSchreiben: (modul: Modul) => boolean +} + +/** + * Prueft, ob der Benutzer auf diese betreute Person und dieses Modul + * zugreifen darf. Wirft ZugriffVerweigert, wenn nicht. + */ +export async function erfordereZugang( + betreuteId: string, + modul: Modul, + art: Zugriffsart = 'lesen', +): Promise { + const benutzer = await erfordereBenutzer() + + const betreute = await db.betreute.findFirst({ + where: { id: betreuteId, ...nichtGeloescht }, + }) + if (!betreute) throw new ZugriffVerweigert('Diese betreute Person gibt es nicht.') + + const eintrag = await db.betreuungsZugang.findUnique({ + where: { userId_betreuteId: { userId: benutzer.id, betreuteId } }, + select: { rolle: true, freigegebeneModule: true }, + }) + + // Administratoren haben Zugriff, auch ohne eigenen Eintrag - sie + // verwalten die Anwendung. Jeder Zugriff steht im Audit-Log. + const zugang: Zugang | null = eintrag + ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule } + : benutzer.rolle === 'ADMIN' + ? { rolle: 'ADMIN', freigegebeneModule: [] } + : null + + if (!darf(zugang, modul, art)) { + throw new ZugriffVerweigert() + } + + return { + benutzer, + betreute, + zugang: zugang!, + darfSchreiben: (m: Modul) => darf(zugang, m, 'schreiben'), + } +} + +/** Alle betreuten Personen, auf die der Benutzer Zugriff hat. */ +export async function meineBetreuten(benutzer: User): Promise { + if (benutzer.rolle === 'ADMIN') { + return db.betreute.findMany({ + where: nichtGeloescht, + orderBy: [{ nachname: 'asc' }, { vorname: 'asc' }], + }) + } + + const zugaenge = await db.betreuungsZugang.findMany({ + where: { userId: benutzer.id, betreute: nichtGeloescht }, + include: { betreute: true }, + orderBy: { betreute: { nachname: 'asc' } }, + }) + + return zugaenge.map((z) => z.betreute) +} + +/** + * Die gerade ausgewaehlte betreute Person. Merkt sich die Auswahl in einem + * Cookie; bei nur einer betreuten Person - der Regelfall - wird sie + * automatisch gewaehlt. + */ +export async function aktiveBetreute(benutzer: User): Promise { + const alle = await meineBetreuten(benutzer) + if (alle.length === 0) return null + + const keks = await cookies() + const gemerkt = keks.get(COOKIE_AKTIVE_BETREUTE)?.value + const treffer = gemerkt ? alle.find((b) => b.id === gemerkt) : undefined + + return treffer ?? alle[0]! +} + +// ----------------------------------------------------------------------------- +// Erneute Bestaetigung vor sensiblen Aktionen +// ----------------------------------------------------------------------------- + +/** + * Vor Export, endgueltigem Loeschen und Benutzerverwaltung muss die + * Anmeldung frisch sein. Statt eines serverseitigen Zustands wird ein + * signiertes, kurzlebiges Cookie gesetzt: ... + * Ohne den AUTH_SECRET laesst es sich nicht faelschen. + */ +function reauthSignieren(userId: string, ablauf: number): string { + const geheim = process.env.AUTH_SECRET ?? '' + return createHmac('sha256', geheim).update(`${userId}.${ablauf}`).digest('base64url') +} + +export async function reauthBestaetigen(userId: string): Promise { + const ablauf = Math.floor(Date.now() / 1000) + env.reauthMaxAlterSekunden + const keks = await cookies() + keks.set(COOKIE_REAUTH, `${userId}.${ablauf}.${reauthSignieren(userId, ablauf)}`, { + httpOnly: true, + sameSite: 'strict', + secure: env.istProduktion, + path: '/', + maxAge: env.reauthMaxAlterSekunden, + }) +} + +export async function reauthGueltig(userId: string): Promise { + const keks = await cookies() + const wert = keks.get(COOKIE_REAUTH)?.value + if (!wert) return false + + const [id, ablaufText, signatur] = wert.split('.') + if (!id || !ablaufText || !signatur) return false + if (id !== userId) return false + + const ablauf = Number.parseInt(ablaufText, 10) + if (Number.isNaN(ablauf) || ablauf < Math.floor(Date.now() / 1000)) return false + + const erwartet = Buffer.from(reauthSignieren(id, ablauf)) + const vorhanden = Buffer.from(signatur) + if (erwartet.length !== vorhanden.length) return false + return timingSafeEqual(erwartet, vorhanden) +} + +/** + * Leitet zur Bestaetigungsseite um, wenn die Anmeldung zu lange her ist. + * Der Rueckweg steht im Parameter "weiter". + */ +export async function erfordereFrischeAnmeldung(benutzer: User, weiter: string): Promise { + if (await reauthGueltig(benutzer.id)) return + redirect(`/bestaetigen?weiter=${encodeURIComponent(weiter)}`) +} + +export async function reauthVerwerfen(): Promise { + const keks = await cookies() + keks.delete(COOKIE_REAUTH) +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..8bd01d1 --- /dev/null +++ b/src/lib/db.ts @@ -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) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..9570614 --- /dev/null +++ b/src/middleware.ts @@ -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' }, + ], + }, + ], +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..ec00c10 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,38 @@ +import type { Rolle } from '@prisma/client' +import type { DefaultSession } from 'next-auth' + +/** + * Erweiterung der Auth.js-Typen um die Felder, die diese Anwendung in der + * Sitzung mitfuehrt. Ohne diese Deklaration waeren session.user.rolle und + * Verwandte in TypeScript unbekannt. + */ + +declare module 'next-auth' { + interface User { + rolle: Rolle + sitzungsGeneration: number + zweiterFaktorAktiv: boolean + } + + interface Session { + user: { + id: string + rolle: Rolle + sitzungsGeneration: number + zweiterFaktorAktiv: boolean + /** Unix-Sekunden der letzten vollstaendigen Anmeldung. */ + angemeldetSeit: number + } & DefaultSession['user'] + } +} + +declare module 'next-auth/jwt' { + interface JWT { + rolle?: Rolle + sitzungsGeneration?: number + zweiterFaktorAktiv?: boolean + angemeldetSeit?: number + } +} + +export {}