diff --git a/src/app/(app)/berichte/export/datei/route.ts b/src/app/(app)/berichte/export/datei/route.ts index 9ad88f7..64e03e0 100644 --- a/src/app/(app)/berichte/export/datei/route.ts +++ b/src/app/(app)/berichte/export/datei/route.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import type { NextRequest } from 'next/server' -import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung' +import { aktiveBetreute, benutzerFuerRoute, reauthGueltig } from '@/lib/auth/sitzung' import { darf } from '@/lib/auth/rollen' import { db, nichtGeloescht } from '@/lib/db' import { protokollieren } from '@/lib/audit' @@ -33,7 +33,11 @@ function abgelehnt(nachricht: string, status: number): Response { } export async function GET(anfrage: NextRequest): Promise { - const benutzer = await erfordereBenutzer('/berichte/export') + // Eine Route antwortet mit einem Statuscode, nicht mit einer + // Anmeldeseite als Dateiinhalt. + const angemeldet = await benutzerFuerRoute() + if (!angemeldet.ok) return angemeldet.antwort + const benutzer = angemeldet.benutzer const gewuenscht = anfrage.nextUrl.searchParams.get('betreute') const aktive = await aktiveBetreute(benutzer) diff --git a/src/app/api/dokumente/datei/[signatur]/route.ts b/src/app/api/dokumente/datei/[signatur]/route.ts index a4b5db4..cb3e3f3 100644 --- a/src/app/api/dokumente/datei/[signatur]/route.ts +++ b/src/app/api/dokumente/datei/[signatur]/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import type { Modul } from '@prisma/client' import { protokollieren } from '@/lib/audit' import { darf } from '@/lib/auth/rollen' -import { aktuellerBenutzer } from '@/lib/auth/sitzung' +import { benutzerFuerRoute } from '@/lib/auth/sitzung' import { db, nichtGeloescht } from '@/lib/db' import { typErkennen } from '@/lib/speicher/dateityp' import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher' @@ -55,7 +55,10 @@ function nurText(status: number, meldung: string): NextResponse { export async function GET( _anfrage: Request, { params }: { params: Promise<{ signatur: string }> }, -): Promise { + // Response statt NextResponse: Die Abweisung ohne zweiten Faktor kommt + // aus benutzerFuerRoute() und ist eine schlichte Response. NextResponse + // erbt davon, die uebrigen Rueckgaben passen also weiterhin. +): Promise { const { signatur } = await params // 1. Signatur und Ablauf @@ -70,9 +73,14 @@ export async function GET( } const schluessel = geprueft.schluessel - // 2. Angemeldet? - const benutzer = await aktuellerBenutzer() - if (!benutzer) return nurText(401, 'Bitte melde dich an.') + // 2. Angemeldet - und mit abgeschlossenem zweitem Faktor. + // + // Frueher stand hier nur die Frage nach der Anmeldung. Damit kam jemand, + // der die TOTP-Einrichtung nie abgeschlossen hatte, an jedes Dokument - + // die MFA-Pflicht galt nur fuer Seiten, nicht fuer diesen Abruf. + const angemeldet = await benutzerFuerRoute() + if (!angemeldet.ok) return angemeldet.antwort + const benutzer = angemeldet.benutzer // 3. Zugriff auf genau diese betreute Person const betreuteId = betreuteIdAusSchluessel(schluessel) diff --git a/src/lib/auth/sitzung.ts b/src/lib/auth/sitzung.ts index ad0f624..c4f0c58 100644 --- a/src/lib/auth/sitzung.ts +++ b/src/lib/auth/sitzung.ts @@ -46,8 +46,46 @@ export async function aktuellerBenutzer(): Promise { return benutzer } -/** Wie aktuellerBenutzer, leitet aber zum Anmeldeformular um. */ +/** Hat dieser Benutzer den zweiten Faktor abgeschlossen? */ +export function zweiterFaktorFertig(benutzer: User): boolean { + return Boolean(benutzer.totpAktiviertAm && benutzer.totpSecretVerschluesselt) +} + +/** + * Der angemeldete Benutzer - mit abgeschlossenem zweitem Faktor. + * + * Die MFA-Pflicht steckt bewusst HIER und nicht im App-Layout. Ein Layout + * schuetzt nur, was durch das Layout gerendert wird; Server Actions und + * Route Handler laufen daran vorbei. Wer eine Passwort-Sitzung hat, aber + * die TOTP-Einrichtung nie abgeschlossen hat, konnte sie frueher direkt + * aufrufen - "MFA ist Pflicht" war damit eine Aussage ueber die + * Oberflaeche, keine Sicherheitsgrenze. + * + * Massgeblich ist der Datenbankstand aus aktuellerBenutzer(), nicht das + * Feld im Sitzungstoken: Das laesst sich ueber session.update() vom + * Client beeinflussen. + * + * Die Ausnahmen sind klein und aufgezaehlt - Einrichtung des zweiten + * Faktors und Abmelden. Wer eine weitere braucht, nimmt bewusst + * erfordereBenutzerOhneZweitenFaktor() und begruendet es dort. + */ export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise { + const benutzer = await erfordereBenutzerOhneZweitenFaktor(zielNachAnmeldung) + if (!zweiterFaktorFertig(benutzer)) { + redirect('/zwei-faktor/einrichten') + } + return benutzer +} + +/** + * Nur fuer die Handvoll Stellen, die VOR abgeschlossener MFA erreichbar + * sein muessen: die Einrichtung selbst und das Abmelden. Jede weitere + * Verwendung ist ein Loch in der MFA-Pflicht und braucht eine + * Begruendung im Code. + */ +export async function erfordereBenutzerOhneZweitenFaktor( + zielNachAnmeldung?: string, +): Promise { const benutzer = await aktuellerBenutzer() if (!benutzer) { const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : '' @@ -57,11 +95,37 @@ export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise { + const benutzer = await aktuellerBenutzer() + if (!benutzer) { + return { ok: false, antwort: new Response('Nicht angemeldet.', { status: 401 }) } + } + if (!zweiterFaktorFertig(benutzer)) { + return { + ok: false, + antwort: new Response('Zweiter Faktor ist nicht eingerichtet.', { status: 403 }), + } + } + return { ok: true, benutzer } +} + +/** + * Erzwingt den eingerichteten zweiten Faktor. + * + * Bleibt als eigener Aufruf im App-Layout bestehen, obwohl + * erfordereBenutzer() das inzwischen selbst tut: Zwei Sperren an + * derselben Stelle kosten nichts und die aeussere ist die, die man beim + * Lesen des Layouts sieht. */ export async function erfordereZweitenFaktor(benutzer: User): Promise { - if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) { + if (!zweiterFaktorFertig(benutzer)) { redirect('/zwei-faktor/einrichten') } } diff --git a/src/server/actions/zwei-faktor.ts b/src/server/actions/zwei-faktor.ts index 7a5fae3..1b4854b 100644 --- a/src/server/actions/zwei-faktor.ts +++ b/src/server/actions/zwei-faktor.ts @@ -3,7 +3,11 @@ import { revalidatePath } from 'next/cache' import { z } from 'zod' import { db } from '@/lib/db' -import { erfordereBenutzer, erfordereFrischeAnmeldung } from '@/lib/auth/sitzung' +import { + erfordereBenutzer, + erfordereBenutzerOhneZweitenFaktor, + erfordereFrischeAnmeldung, +} from '@/lib/auth/sitzung' import { protokollieren } from '@/lib/audit' import { totpPruefen, @@ -40,7 +44,9 @@ export type EinrichtungsDaten = { } export async function zweitenFaktorVorbereiten(): Promise { - const benutzer = await erfordereBenutzer() + // Ausnahme von der MFA-Pflicht, und zwar die offensichtliche: Wer den + // zweiten Faktor gerade einrichtet, hat ihn noch nicht. + const benutzer = await erfordereBenutzerOhneZweitenFaktor() if (benutzer.totpAktiviertAm && benutzer.totpSecretVerschluesselt) { // Schon eingerichtet - kein neues Geheimnis, sonst waere die alte App @@ -78,7 +84,8 @@ export async function zweitenFaktorBestaetigen( _vorher: BestaetigungsZustand, formular: FormData, ): Promise { - const benutzer = await erfordereBenutzer() + // Zweite und letzte Ausnahme: der Abschluss der Einrichtung selbst. + const benutzer = await erfordereBenutzerOhneZweitenFaktor() if (benutzer.totpAktiviertAm) { return { fehler: 'Der zweite Faktor ist bereits eingerichtet.' } diff --git a/tests/mfa-grenze.test.ts b/tests/mfa-grenze.test.ts new file mode 100644 index 0000000..1756356 --- /dev/null +++ b/tests/mfa-grenze.test.ts @@ -0,0 +1,133 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Die MFA-Pflicht als Grenze, nicht als Behauptung. + * + * "Der zweite Faktor ist Pflicht" stand lange im Kopf von src/auth.ts, + * durchgesetzt wurde es aber nur im App-Layout. Ein Layout schuetzt, was + * durch das Layout gerendert wird - Server Actions und Route Handler laufen + * daran vorbei. Wer eine Passwort-Sitzung hatte, die TOTP-Einrichtung aber + * nie abgeschlossen hat, kam trotzdem an die Daten. + * + * Seitdem steckt die Pruefung in erfordereBenutzer() selbst. Damit sie dort + * bleibt, prueft dieser Test die Bauform statt des Verhaltens: Er liest den + * Quelltext und besteht darauf, dass die Umgehungen aufgezaehlt bleiben. + * + * Ein Integrationstest waere schoener, braeuchte aber eine Sitzung mit + * halbfertigem zweitem Faktor - also genau den Zustand, den die Anwendung + * nicht mehr herstellen laesst. Diese Pruefung greift dafuer bei jedem + * neuen Endpunkt, auch bei dem, an den beim Testeschreiben niemand denkt. + */ + +const WURZEL = join(__dirname, '..', 'src') + +/** + * Wer an aktuellerBenutzer() oder erfordereBenutzerOhneZweitenFaktor() + * vorbei darf - und warum. + * + * Gemeinsam ist allen: Sie geben keine Daten einer betreuten Person heraus. + * Wer hier etwas ergaenzt, hebelt die MFA-Pflicht fuer diesen Pfad aus und + * schuldet eine Begruendung. + */ +const ERLAUBT = new Map([ + ['lib/auth/sitzung.ts', 'definiert die Funktionen'], + ['app/(auth)/anmelden/page.tsx', 'leitet bereits Angemeldete weiter, zeigt nichts an'], + ['app/(auth)/ersteinrichtung/page.tsx', 'laeuft, bevor es einen zweiten Faktor geben kann'], + ['app/(auth)/zwei-faktor/einrichten/page.tsx', 'ist die Einrichtung selbst'], + ['server/actions/zwei-faktor.ts', 'Vorbereiten und Bestaetigen der Einrichtung'], +]) + +/** Route Handler, die absichtlich ohne Anmeldung erreichbar sind. */ +const OEFFENTLICHE_ROUTEN = new Map([ + ['app/api/auth/[...nextauth]/route.ts', 'die Anmeldung selbst'], + ['app/api/gesundheit/route.ts', 'Statusabfrage fuer die Ueberwachung, ohne Daten'], + ['app/api/kalender/[signatur]/route.ts', 'signierter Abo-Link, eigene Pruefung'], + ['app/api/wartung/route.ts', 'Aufruf per Geheimnis aus dem Systemdienst'], +]) + +/** Alles, was einen angemeldeten Benutzer mit fertigem zweitem Faktor sichert. */ +const WAECHTER = [ + 'benutzerFuerRoute', + 'erfordereZugang', + 'erfordereBenutzer(', + 'finanzKontext', + 'berichtsKontext', +] + +function dateienUnter(verzeichnis: string, endungen: string[]): string[] { + const gefunden: string[] = [] + + for (const eintrag of readdirSync(verzeichnis)) { + if (eintrag === 'node_modules' || eintrag === '.next') continue + const pfad = join(verzeichnis, eintrag) + + if (statSync(pfad).isDirectory()) { + gefunden.push(...dateienUnter(pfad, endungen)) + } else if (endungen.some((endung) => eintrag.endsWith(endung))) { + gefunden.push(pfad) + } + } + + return gefunden +} + +/** Pfad relativ zu src/, immer mit Schraegstrich - auch unter Windows. */ +function kennung(pfad: string): string { + return relative(WURZEL, pfad).split(sep).join('/') +} + +describe('MFA-Grenze', () => { + it('umgeht die Pflicht nur an den aufgezaehlten Stellen', () => { + const umgehungen = dateienUnter(WURZEL, ['.ts', '.tsx']) + .filter((pfad) => { + const quelltext = readFileSync(pfad, 'utf-8') + return ( + quelltext.includes('aktuellerBenutzer') || + quelltext.includes('erfordereBenutzerOhneZweitenFaktor') + ) + }) + .map(kennung) + .sort() + + expect(umgehungen).toEqual([...ERLAUBT.keys()].sort()) + }) + + it('sichert jeden Route Handler oder erklaert, warum nicht', () => { + const ungesichert = dateienUnter(join(WURZEL, 'app'), ['route.ts']) + .map(kennung) + .filter((datei) => !OEFFENTLICHE_ROUTEN.has(datei)) + .filter((datei) => { + const quelltext = readFileSync(join(WURZEL, datei), 'utf-8') + return !WAECHTER.some((waechter) => quelltext.includes(waechter)) + }) + + expect(ungesichert).toEqual([]) + }) + + it('weist einen Abruf ohne zweiten Faktor ab, statt ihn umzuleiten', () => { + // Ein Redirect auf die Anmeldeseite waere bei einer Datei die falsche + // Antwort: Der Aufrufer bekaeme HTML, wo er ein Dokument erwartet, und + // ein Skript wertete das womoeglich als Erfolg. + const quelltext = readFileSync(join(WURZEL, 'lib', 'auth', 'sitzung.ts'), 'utf-8') + const abschnitt = quelltext.slice(quelltext.indexOf('export async function benutzerFuerRoute')) + + expect(abschnitt).toContain('status: 401') + expect(abschnitt).toContain('status: 403') + }) + + it('prueft den zweiten Faktor am Datenbankstand, nicht am Sitzungstoken', () => { + const quelltext = readFileSync(join(WURZEL, 'lib', 'auth', 'sitzung.ts'), 'utf-8') + const funktion = quelltext.slice( + quelltext.indexOf('export function zweiterFaktorFertig'), + quelltext.indexOf('export async function erfordereBenutzer'), + ) + + // Die Felder kommen aus dem User-Datensatz. Stuende hier etwas aus der + // Sitzung, liesse es sich ueber session.update() vom Client setzen. + expect(funktion).toContain('benutzer.totpAktiviertAm') + expect(funktion).toContain('benutzer.totpSecretVerschluesselt') + expect(funktion).not.toContain('sitzung') + }) +})