"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
134 lines
5.3 KiB
TypeScript
134 lines
5.3 KiB
TypeScript
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')
|
|
})
|
|
})
|