"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
278 lines
9.5 KiB
TypeScript
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)
|
|
}
|