feat: Anmeldung mit Pflicht-2FA, Rollen und Audit-Log

Auth.js mit Zugangsdaten-Provider: E-Mail, Passwort und TOTP-Code werden
in einem Formular abgefragt. Ein zweistufiger Ablauf muesste zwischen den
Schritten einen Zwischenzustand halten - genau das ist die uebliche
Schwachstelle, und am Handy ist ein Formular ohnehin schneller.

Anmeldung:
- Sperre wird VOR dem Passwortvergleich geprueft, damit ein gesperrter
  Zugang nicht einmal Argon2-Rechenzeit kostet.
- Bei unbekannter Adresse wird gegen einen Dummy-Hash gerechnet und die
  Antwort auf eine Mindestdauer gestreckt. Sonst verraet die Antwortzeit,
  welche Adressen existieren.
- Wiederherstellungscodes werden nach Gebrauch entfernt.
- Veraltete Argon2-Parameter werden beim erfolgreichen Login nachgezogen.

Rate-Limiting in der Datenbank statt Redis - ein Server, wenige Benutzer,
kein Grund fuer eine weitere Komponente. Gezaehlt wird nach E-Mail und
nach Anschluss, damit weder viele Anschluesse noch viele Adressen helfen.

Zugriffsschutz (sitzung.ts):
- Die Sitzung allein entscheidet nichts. Bei jedem Aufruf wird der
  Benutzer frisch geladen und die Sitzungsgeneration verglichen - so
  lassen sich alle Anmeldungen auf einen Schlag beenden.
- erfordereZugang() prueft immer gegen die betreuteId, nie nur im UI.
- Erneute Bestaetigung vor sensiblen Aktionen ueber ein signiertes,
  kurzlebiges Cookie statt ueber Serverzustand.

Audit-Log: aenderungErmitteln() protokolliert nur die tatsaechlich
geaenderten Felder; Passwoerter und TOTP-Geheimnisse werden ersetzt, nie
gespeichert. Ein Fehler im Protokoll bricht die Fachaktion nicht ab -
ausser die Aktion laeuft in einer Transaktion, dann faellt beides.

CSP mit Nonce pro Anfrage in der Middleware: keine Fremdquellen, kein
CDN, keine Fonts von aussen. Fachseiten werden nicht zwischengespeichert.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:46:20 +02:00
parent c49810d092
commit bd1c7b801f
9 changed files with 1031 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
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
}
/** Wie aktuellerBenutzer, leitet aber zum Anmeldeformular um. */
export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<User> {
const benutzer = await aktuellerBenutzer()
if (!benutzer) {
const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : ''
redirect(`/anmelden${ziel}`)
}
return benutzer
}
/**
* Erzwingt den eingerichteten zweiten Faktor. Wird im App-Layout
* aufgerufen: ohne TOTP kommt niemand an Fachdaten.
*/
export async function erfordereZweitenFaktor(benutzer: User): Promise<void> {
if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) {
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)
}