Files
betreuungs-cockpit/src/lib/auth/sitzung.ts
T
Florian Wilde 2bed78915f 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
2026-08-24 02:12:50 +02:00

278 lines
9.5 KiB
TypeScript

import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { createHmac, timingSafeEqual } from 'node:crypto'
import type { Betreute, Modul, User } from '@prisma/client'
import { auth } from '@/auth'
import { db, nichtGeloescht } from '@/lib/db'
import { env } from '@/lib/env'
import { darf, type Zugang, type Zugriffsart } from '@/lib/auth/rollen'
/**
* Zugriffsschutz.
*
* Grundsatz: Es wird immer serverseitig gegen die betreuteId geprueft, nie
* nur im UI ausgeblendet. Jede Seite und jede Server Action, die Daten einer
* betreuten Person anfasst, geht durch erfordereZugang().
*/
export const COOKIE_AKTIVE_BETREUTE = 'bc_betreute'
export const COOKIE_REAUTH = 'bc_reauth'
export class ZugriffVerweigert extends Error {
constructor(nachricht = 'Für diesen Bereich fehlt dir die Berechtigung.') {
super(nachricht)
this.name = 'ZugriffVerweigert'
}
}
/**
* Der angemeldete Benutzer, frisch aus der Datenbank.
*
* Die Sitzung allein reicht nicht: Rolle, Aktiv-Status und die
* Sitzungsgeneration koennen sich geaendert haben, seit das Token
* ausgestellt wurde. Stimmt die Generation nicht mehr, ist die Sitzung
* ungueltig - so lassen sich alle Anmeldungen eines Benutzers auf einen
* Schlag beenden (Passwortwechsel, Verdacht auf Kompromittierung).
*/
export async function aktuellerBenutzer(): Promise<User | null> {
const sitzung = await auth()
if (!sitzung?.user?.id) return null
const benutzer = await db.user.findUnique({ where: { id: sitzung.user.id } })
if (!benutzer || !benutzer.aktiv || benutzer.geloeschtAm) return null
if (benutzer.sitzungsGeneration !== sitzung.user.sitzungsGeneration) return null
return benutzer
}
/** 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)}` : ''
redirect(`/anmelden${ziel}`)
}
return benutzer
}
/**
* 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 (!zweiterFaktorFertig(benutzer)) {
redirect('/zwei-faktor/einrichten')
}
}
// -----------------------------------------------------------------------------
// Zugriff auf eine betreute Person
// -----------------------------------------------------------------------------
export type ZugangsKontext = {
benutzer: User
betreute: Betreute
zugang: Zugang
/** Bequemer Test fuer die Oberflaeche. */
darfSchreiben: (modul: Modul) => boolean
}
/**
* Prueft, ob der Benutzer auf diese betreute Person und dieses Modul
* zugreifen darf. Wirft ZugriffVerweigert, wenn nicht.
*/
export async function erfordereZugang(
betreuteId: string,
modul: Modul,
art: Zugriffsart = 'lesen',
): Promise<ZugangsKontext> {
const benutzer = await erfordereBenutzer()
const betreute = await db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
})
if (!betreute) throw new ZugriffVerweigert('Diese betreute Person gibt es nicht.')
const eintrag = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId: benutzer.id, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
})
// Administratoren haben Zugriff, auch ohne eigenen Eintrag - sie
// verwalten die Anwendung. Jeder Zugriff steht im Audit-Log.
const zugang: Zugang | null = eintrag
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN', freigegebeneModule: [] }
: null
if (!darf(zugang, modul, art)) {
throw new ZugriffVerweigert()
}
return {
benutzer,
betreute,
zugang: zugang!,
darfSchreiben: (m: Modul) => darf(zugang, m, 'schreiben'),
}
}
/** Alle betreuten Personen, auf die der Benutzer Zugriff hat. */
export async function meineBetreuten(benutzer: User): Promise<Betreute[]> {
if (benutzer.rolle === 'ADMIN') {
return db.betreute.findMany({
where: nichtGeloescht,
orderBy: [{ nachname: 'asc' }, { vorname: 'asc' }],
})
}
const zugaenge = await db.betreuungsZugang.findMany({
where: { userId: benutzer.id, betreute: nichtGeloescht },
include: { betreute: true },
orderBy: { betreute: { nachname: 'asc' } },
})
return zugaenge.map((z) => z.betreute)
}
/**
* Die gerade ausgewaehlte betreute Person. Merkt sich die Auswahl in einem
* Cookie; bei nur einer betreuten Person - der Regelfall - wird sie
* automatisch gewaehlt.
*/
export async function aktiveBetreute(benutzer: User): Promise<Betreute | null> {
const alle = await meineBetreuten(benutzer)
if (alle.length === 0) return null
const keks = await cookies()
const gemerkt = keks.get(COOKIE_AKTIVE_BETREUTE)?.value
const treffer = gemerkt ? alle.find((b) => b.id === gemerkt) : undefined
return treffer ?? alle[0]!
}
// -----------------------------------------------------------------------------
// Erneute Bestaetigung vor sensiblen Aktionen
// -----------------------------------------------------------------------------
/**
* Vor Export, endgueltigem Loeschen und Benutzerverwaltung muss die
* Anmeldung frisch sein. Statt eines serverseitigen Zustands wird ein
* signiertes, kurzlebiges Cookie gesetzt: <userId>.<ablauf>.<signatur>.
* Ohne den AUTH_SECRET laesst es sich nicht faelschen.
*/
function reauthSignieren(userId: string, ablauf: number): string {
const geheim = process.env.AUTH_SECRET ?? ''
return createHmac('sha256', geheim).update(`${userId}.${ablauf}`).digest('base64url')
}
export async function reauthBestaetigen(userId: string): Promise<void> {
const ablauf = Math.floor(Date.now() / 1000) + env.reauthMaxAlterSekunden
const keks = await cookies()
keks.set(COOKIE_REAUTH, `${userId}.${ablauf}.${reauthSignieren(userId, ablauf)}`, {
httpOnly: true,
sameSite: 'strict',
secure: env.istProduktion,
path: '/',
maxAge: env.reauthMaxAlterSekunden,
})
}
export async function reauthGueltig(userId: string): Promise<boolean> {
const keks = await cookies()
const wert = keks.get(COOKIE_REAUTH)?.value
if (!wert) return false
const [id, ablaufText, signatur] = wert.split('.')
if (!id || !ablaufText || !signatur) return false
if (id !== userId) return false
const ablauf = Number.parseInt(ablaufText, 10)
if (Number.isNaN(ablauf) || ablauf < Math.floor(Date.now() / 1000)) return false
const erwartet = Buffer.from(reauthSignieren(id, ablauf))
const vorhanden = Buffer.from(signatur)
if (erwartet.length !== vorhanden.length) return false
return timingSafeEqual(erwartet, vorhanden)
}
/**
* Leitet zur Bestaetigungsseite um, wenn die Anmeldung zu lange her ist.
* Der Rueckweg steht im Parameter "weiter".
*/
export async function erfordereFrischeAnmeldung(benutzer: User, weiter: string): Promise<void> {
if (await reauthGueltig(benutzer.id)) return
redirect(`/bestaetigen?weiter=${encodeURIComponent(weiter)}`)
}
export async function reauthVerwerfen(): Promise<void> {
const keks = await cookies()
keks.delete(COOKIE_REAUTH)
}