Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.' }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user