Zweiten Faktor zur Grenze machen, nicht nur zur Anzeige
"Der zweite Faktor ist Pflicht" stand im Kopf von 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 an die Daten. Die Pruefung steckt jetzt in erfordereBenutzer() selbst und damit ueber erfordereZugang() automatisch in jeder Server Action. Massgeblich ist der Datenbankstand, nicht das Feld im Sitzungstoken - das laesst sich ueber session.update() vom Client beeinflussen. Fuer Route Handler gibt es benutzerFuerRoute(): 401 bzw. 403 statt einer Umleitung. Eine Anmeldeseite als Inhalt einer Word-Datei waere die falsche Antwort. Dabei aufgefallen: Der Dokumentenabruf unter /api/dokumente/datei fragte nur nach der Anmeldung. Auch geschlossen. Ausnahmen sind aufgezaehlt und werden von tests/mfa-grenze.test.ts festgehalten - der Test liest den Quelltext und schlaegt an, sobald eine weitere dazukommt. Refs #38
This commit is contained in:
@@ -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