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 { 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 { 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 { 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 { 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 { 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 { 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 { 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: ... * 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 { 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 { 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 { if (await reauthGueltig(benutzer.id)) return redirect(`/bestaetigen?weiter=${encodeURIComponent(weiter)}`) } export async function reauthVerwerfen(): Promise { const keks = await cookies() keks.delete(COOKIE_REAUTH) }