Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
5 changed files with 230 additions and 14 deletions
Showing only changes of commit 2bed78915f - Show all commits
+6 -2
View File
@@ -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<Response> {
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)
@@ -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<NextResponse> {
// 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<Response> {
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)
+68 -4
View File
@@ -46,8 +46,46 @@ export async function aktuellerBenutzer(): Promise<User | null> {
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<User> {
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<User> {
const benutzer = await aktuellerBenutzer()
if (!benutzer) {
const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : ''
@@ -57,11 +95,37 @@ export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<Use
}
/**
* Erzwingt den eingerichteten zweiten Faktor. Wird im App-Layout
* aufgerufen: ohne TOTP kommt niemand an Fachdaten.
* Fuer Route Handler: Statt umzuleiten wird abgewiesen.
*
* Ein Redirect ist bei einer Datei- oder Kalenderantwort die falsche
* Antwort - der Aufrufer will Daten, nicht eine Anmeldeseite als Inhalt.
*/
export async function benutzerFuerRoute(): Promise<
{ ok: true; benutzer: User } | { ok: false; antwort: Response }
> {
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<void> {
if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) {
if (!zweiterFaktorFertig(benutzer)) {
redirect('/zwei-faktor/einrichten')
}
}
+10 -3
View File
@@ -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<EinrichtungsDaten> {
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<BestaetigungsZustand> {
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.' }
+133
View File
@@ -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<string, string>([
['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<string, string>([
['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')
})
})