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
+9
View File
@@ -0,0 +1,9 @@
/**
* Auth.js-Endpunkte (Anmelden, Abmelden, Sitzung, CSRF).
*
* Node.js-Laufzeit ist Pflicht: Argon2 ist eine native Bibliothek und
* laeuft nicht auf der Edge-Runtime.
*/
export const runtime = 'nodejs'
export { GET, POST } from '@/auth'
+255
View File
@@ -0,0 +1,255 @@
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import type { Rolle } from '@prisma/client'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
import {
anmeldungErlaubt,
aufMindestdauerWarten,
fehlversucheZuruecksetzen,
versuchFesthalten,
} from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit'
/**
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
*
* Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste
* zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig,
* warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein
* Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt.
*
* Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
*/
export class AnmeldeFehler extends Error {
constructor(
public readonly grund:
| 'daten-falsch'
| 'gesperrt'
| 'konto-inaktiv'
| 'zweiter-faktor-fehlt'
| 'code-falsch',
public readonly nochSekunden?: number,
) {
super(grund)
this.name = 'AnmeldeFehler'
}
}
function ipAusAnfrage(anfrage: Request | undefined): string | null {
if (!anfrage) return null
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
}
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
secret: process.env.AUTH_SECRET,
session: {
strategy: 'jwt',
// Leerlauf bis zur automatischen Abmeldung.
maxAge: env.sitzungMaxAlterSekunden,
// Bei jeder Anfrage verlaengern, solange gearbeitet wird.
updateAge: 60,
},
pages: {
signIn: '/anmelden',
error: '/anmelden',
},
providers: [
Credentials({
id: 'zugangsdaten',
name: 'E-Mail, Passwort und Code',
credentials: {
email: { label: 'E-Mail', type: 'email' },
passwort: { label: 'Passwort', type: 'password' },
code: { label: 'Sechsstelliger Code', type: 'text' },
},
async authorize(daten, anfrage) {
const begonnen = Date.now()
const email = String(daten?.email ?? '').trim().toLowerCase()
const passwort = String(daten?.passwort ?? '')
const code = String(daten?.code ?? '')
const ip = ipAusAnfrage(anfrage)
if (!email || !passwort) {
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('daten-falsch')
}
// 1. Sperre pruefen, bevor teuer gerechnet wird.
const sperre = await anmeldungErlaubt(email, ip)
if (sperre.gesperrt) {
await versuchFesthalten(email, ip, false, 'gesperrt')
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
}
const benutzer = await db.user.findUnique({ where: { email } })
// 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet,
// damit die Antwortzeit nichts verraet.
const hashZumPruefen =
benutzer?.passwortHash ??
'$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort)
if (!benutzer || !passwortStimmt) {
await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer?.id ?? null,
userEmail: email,
})
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('daten-falsch')
}
if (!benutzer.aktiv || benutzer.geloeschtAm) {
await versuchFesthalten(email, ip, false, 'konto-inaktiv')
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('konto-inaktiv')
}
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer
// zwar herein, das App-Layout schickt ihn aber sofort zur
// Einrichtung - er kann nichts anderes tun.
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
const codeOhneTrenner = code.replace(/[\s-]/g, '')
let zweiterFaktorOk = false
if (/^\d{6}$/.test(codeOhneTrenner)) {
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
} else if (codeOhneTrenner.length >= 12) {
// Wiederherstellungscode - wird nach Gebrauch entfernt.
const treffer = await wiederherstellungscodeFinden(
benutzer.wiederherstellungscodes,
code,
)
if (treffer >= 0) {
zweiterFaktorOk = true
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
await db.user.update({
where: { id: benutzer.id },
data: { wiederherstellungscodes: rest },
})
}
}
if (!zweiterFaktorOk) {
await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
entitaet: 'User',
entitaetId: benutzer.id,
userEmail: email,
userId: benutzer.id,
bezeichnung: 'Zweiter Faktor falsch',
})
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('code-falsch')
}
}
// 4. Erfolg.
if (hashVeraltet(benutzer.passwortHash)) {
// Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen.
await db.user.update({
where: { id: benutzer.id },
data: { passwortHash: await passwortHashen(passwort) },
})
}
await Promise.all([
versuchFesthalten(email, ip, true),
fehlversucheZuruecksetzen(email),
db.user.update({
where: { id: benutzer.id },
data: { letzteAnmeldung: new Date() },
}),
protokollieren({
aktion: 'ANMELDUNG',
entitaet: 'User',
entitaetId: benutzer.id,
userId: benutzer.id,
userEmail: benutzer.email,
userName: benutzer.name,
}),
])
await aufMindestdauerWarten(begonnen)
return {
id: benutzer.id,
email: benutzer.email,
name: benutzer.name,
rolle: benutzer.rolle,
sitzungsGeneration: benutzer.sitzungsGeneration,
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
}
},
}),
],
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.rolle = user.rolle
token.sitzungsGeneration = user.sitzungsGeneration
token.zweiterFaktorAktiv = user.zweiterFaktorAktiv
// Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die
// erneute Bestaetigung vor sensiblen Aktionen.
token.angemeldetSeit = Math.floor(Date.now() / 1000)
}
// Nach dem Einrichten des zweiten Faktors aktualisiert die Seite
// die Sitzung, ohne dass man sich neu anmelden muss.
if (trigger === 'update' && session) {
const aktualisierung = session as Partial<{
zweiterFaktorAktiv: boolean
sitzungsGeneration: number
}>
if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') {
token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv
}
if (typeof aktualisierung.sitzungsGeneration === 'number') {
token.sitzungsGeneration = aktualisierung.sitzungsGeneration
}
}
return token
},
async session({ session, token }) {
session.user.id = token.sub ?? ''
session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER'
session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0
session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv)
session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0
return session
},
},
events: {
async signOut(nachricht) {
const userId = 'token' in nachricht ? nachricht.token?.sub : undefined
if (userId) {
await protokollieren({
aktion: 'ABMELDUNG',
entitaet: 'User',
entitaetId: userId,
userId,
})
}
},
},
})
+167
View File
@@ -0,0 +1,167 @@
import 'server-only'
import { headers } from 'next/headers'
import type { AuditAktion, Prisma } from '@prisma/client'
import { db } from '@/lib/db'
import { verschluesseln } from '@/lib/crypto/feld'
import { ipHashen } from '@/lib/crypto/hash'
/**
* Audit-Log.
*
* Jede schreibende Aktion und jeder Dokumentzugriff wird protokolliert:
* wer, wann, was, alter Wert -> neuer Wert. Der Eintrag ist unveraenderlich
* (Datenbank-Trigger, siehe prisma/sql/audit-schutz.sql).
*
* Das ist nicht nur Datenschutz-Kuer. Als ehrenamtlicher Betreuer muss ich
* gegenueber dem Betreuungsgericht und moeglichen Miterben belegen koennen,
* was ich wann getan habe. Das Log ist dieser Beleg.
*
* Die geaenderten Werte werden verschluesselt abgelegt - dort stehen sonst
* Diagnosen und Kontostaende im Klartext.
*/
export type Aenderung = Record<string, { alt: unknown; neu: unknown }>
type ProtokollEingabe = {
aktion: AuditAktion
entitaet: string
entitaetId?: string | null
bezeichnung?: string | null
betreuteId?: string | null
userId?: string | null
userEmail?: string | null
userName?: string | null
aenderung?: Aenderung | null
/** Optional: laeuft die Aktion in einer Transaktion mit? */
tx?: Prisma.TransactionClient
}
/** Felder, die nie ins Log gehoeren - auch nicht verschluesselt. */
const NIEMALS_PROTOKOLLIEREN = new Set([
'passwortHash',
'passwort',
'passwortWiederholung',
'totpSecretVerschluesselt',
'wiederherstellungscodes',
])
/**
* Liest IP und User-Agent aus der laufenden Anfrage. Ausserhalb eines
* Request-Kontexts (z.B. im Seed oder in einem Cron-Job) gibt es beides
* nicht - dann bleibt das Feld leer, statt dass die Aktion scheitert.
*/
async function anfragekontext(): Promise<{ ipHash: string | null; userAgent: string | null }> {
try {
const kopf = await headers()
// Hinter dem Reverse Proxy steht die echte Adresse in X-Forwarded-For.
const weitergeleitet = kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
const ip = weitergeleitet || kopf.get('x-real-ip') || null
return {
ipHash: ipHashen(ip),
userAgent: kopf.get('user-agent')?.slice(0, 250) ?? null,
}
} catch {
return { ipHash: null, userAgent: null }
}
}
/**
* Schreibt einen Eintrag. Wirft bewusst nicht: ein Fehler im Protokoll
* darf die eigentliche Fachaktion nicht abbrechen - er wird geloggt und
* faellt beim naechsten Blick in die Serverlogs auf.
*
* Laeuft die Aktion in einer Transaktion (tx uebergeben), gilt das nicht:
* dann soll der Protokolleintrag zusammen mit der Aenderung stehen oder
* fallen.
*/
export async function protokollieren(eingabe: ProtokollEingabe): Promise<void> {
const { tx, aenderung, ...rest } = eingabe
const klient = tx ?? db
try {
const kontext = await anfragekontext()
const gefiltert = aenderung ? aenderungFiltern(aenderung) : null
await klient.auditLog.create({
data: {
aktion: rest.aktion,
entitaet: rest.entitaet,
entitaetId: rest.entitaetId ?? null,
bezeichnung: rest.bezeichnung ?? null,
betreuteId: rest.betreuteId ?? null,
userId: rest.userId ?? null,
userEmail: rest.userEmail ?? null,
userName: rest.userName ?? null,
aenderungVerschluesselt:
gefiltert && Object.keys(gefiltert).length > 0
? verschluesseln(JSON.stringify(gefiltert), 'aenderungVerschluesselt')
: null,
ipHash: kontext.ipHash,
userAgent: kontext.userAgent,
},
})
} catch (fehler) {
if (tx) throw fehler
console.error('[audit] Eintrag konnte nicht geschrieben werden:', fehler)
}
}
function aenderungFiltern(aenderung: Aenderung): Aenderung {
const ergebnis: Aenderung = {}
for (const [feld, werte] of Object.entries(aenderung)) {
if (NIEMALS_PROTOKOLLIEREN.has(feld)) {
ergebnis[feld] = { alt: '[entfernt]', neu: '[entfernt]' }
continue
}
ergebnis[feld] = werte
}
return ergebnis
}
/**
* Vergleicht zwei Datensaetze und liefert nur die tatsaechlich geaenderten
* Felder. So steht im Log nicht bei jedem Speichern der ganze Datensatz.
*
* Verschluesselte Felder werden am Suffix erkannt: verglichen wird der
* Klartext (den der Aufrufer mitgibt), protokolliert ebenfalls der
* Klartext - der Eintrag selbst ist ja verschluesselt.
*/
export function aenderungErmitteln(
vorher: Record<string, unknown>,
nachher: Record<string, unknown>,
): Aenderung {
const aenderung: Aenderung = {}
for (const feld of Object.keys(nachher)) {
if (feld.endsWith('Verschluesselt')) continue // Umschlaege aendern sich immer
if (['aktualisiertAm', 'erstelltAm', 'id'].includes(feld)) continue
const alt = vorher[feld]
const neu = nachher[feld]
if (!gleich(alt, neu)) {
aenderung[feld] = { alt: normalisieren(alt), neu: normalisieren(neu) }
}
}
return aenderung
}
function gleich(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
if (a instanceof Date || b instanceof Date) {
return normalisieren(a) === normalisieren(b)
}
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((wert, i) => gleich(wert, b[i]))
}
if (a === null || a === undefined) return b === null || b === undefined
return false
}
/** BigInt und Date sind nicht JSON-faehig - hier in Text wandeln. */
function normalisieren(wert: unknown): unknown {
if (wert instanceof Date) return wert.toISOString()
if (typeof wert === 'bigint') return wert.toString()
return wert ?? null
}
+107
View File
@@ -0,0 +1,107 @@
import 'server-only'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { ipHashen } from '@/lib/crypto/hash'
/**
* Rate-Limiting am Anmeldeformular.
*
* Bewusst in der Datenbank statt in Redis: die Anwendung laeuft auf einem
* einzelnen kleinen Server, eine zusaetzliche Komponente waere Aufwand ohne
* Gegenwert. Bei ein paar Benutzern und wenigen Anmeldungen pro Tag ist die
* Tabelle winzig; ein Aufraeumen nach 30 Tagen haelt sie klein.
*
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem Anschluss.
*/
export type SperrErgebnis =
| { gesperrt: false; verbleibendeVersuche: number }
| { gesperrt: true; nochSekunden: number }
function fensterStart(): Date {
return new Date(Date.now() - env.loginFensterSekunden * 1000)
}
/**
* Prueft, ob eine Anmeldung ueberhaupt versucht werden darf. Wird VOR dem
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
* Rechenzeit fuer Argon2 kosten.
*/
export async function anmeldungErlaubt(
email: string,
ip: string | null,
): Promise<SperrErgebnis> {
const seit = fensterStart()
const kennung = email.trim().toLowerCase()
const ipH = ipHashen(ip)
const fehlversuche = await db.loginVersuch.findMany({
where: {
erfolg: false,
zeitpunkt: { gte: seit },
OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
},
orderBy: { zeitpunkt: 'desc' },
select: { zeitpunkt: true },
take: env.loginMaxVersuche + 1,
})
if (fehlversuche.length >= env.loginMaxVersuche) {
const juengster = fehlversuche[0]!.zeitpunkt.getTime()
const frei = juengster + env.loginSperreSekunden * 1000
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000))
return { gesperrt: true, nochSekunden }
}
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length }
}
/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */
export async function versuchFesthalten(
email: string,
ip: string | null,
erfolg: boolean,
grund?: string,
): Promise<void> {
await db.loginVersuch.create({
data: {
email: email.trim().toLowerCase(),
ipHash: ipHashen(ip),
erfolg,
grund: grund ?? null,
},
})
}
/**
* Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
*/
export async function fehlversucheZuruecksetzen(email: string): Promise<void> {
await db.loginVersuch.deleteMany({
where: { email: email.trim().toLowerCase(), erfolg: false },
})
}
/** Aufraeumen; wird vom Wartungslauf aufgerufen. */
export async function alteVersucheEntfernen(): Promise<number> {
const grenze = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const { count } = await db.loginVersuch.deleteMany({
where: { zeitpunkt: { lt: grenze } },
})
return count
}
/**
* Gleicht die Antwortzeit an. Ohne das verraet ein schneller "Benutzer
* unbekannt" gegenueber einem langsamen "Passwort falsch", welche
* E-Mail-Adressen es gibt.
*/
export async function aufMindestdauerWarten(begonnenAm: number, mindestMs = 400): Promise<void> {
const vergangen = Date.now() - begonnenAm
if (vergangen < mindestMs) {
await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen))
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { Modul, Rolle } from '@prisma/client'
/**
* Rollen und Modulfreigaben - ohne "server-only", weil die Beschriftungen
* auch im Browser gebraucht werden.
*
* Wichtig: Diese Datei entscheidet nichts allein. Der eigentliche
* Zugriffsschutz sitzt in src/lib/auth/sitzung.ts und prueft serverseitig
* gegen die betreuteId. Hier stehen nur die Regeln, damit sie an einem Ort
* nachlesbar sind.
*/
export const ROLLEN_BESCHRIFTUNG: Record<Rolle, string> = {
BETREUER: 'Betreuer',
MITLESER: 'Mitleser',
ADMIN: 'Administrator',
}
export const ROLLEN_ERKLAERUNG: Record<Rolle, string> = {
BETREUER: 'Darf alles sehen und ändern, was zu den zugeordneten Betreuten gehört.',
MITLESER: 'Darf nur lesen, und nur die ausdrücklich freigegebenen Bereiche.',
ADMIN: 'Verwaltet Benutzer und Systemeinstellungen.',
}
export const MODUL_BESCHRIFTUNG: Record<Modul, string> = {
STAMMDATEN: 'Stammdaten',
VORGAENGE: 'Vorgänge',
FRISTEN: 'Fristen',
DOKUMENTE: 'Dokumente',
FINANZEN: 'Vermögen & Kassenbuch',
SOZIALHILFE: 'Sozialhilfe',
IMMOBILIE: 'Immobilie',
VERTRAEGE: 'Versicherungen & Verträge',
GESUNDHEIT: 'Heim & Pflege',
KONTAKTE: 'Kontakte',
BERICHTE: 'Berichte',
}
export const ALLE_MODULE: Modul[] = [
'STAMMDATEN',
'VORGAENGE',
'FRISTEN',
'DOKUMENTE',
'FINANZEN',
'SOZIALHILFE',
'IMMOBILIE',
'VERTRAEGE',
'GESUNDHEIT',
'KONTAKTE',
'BERICHTE',
]
/**
* Module mit besonders sensiblen Inhalten. Fuer sie ist eine Freigabe an
* Mitleser eine bewusste Entscheidung - die Oberflaeche weist darauf hin.
*/
export const BESONDERS_SENSIBEL: Modul[] = ['GESUNDHEIT', 'FINANZEN', 'SOZIALHILFE']
export type Zugriffsart = 'lesen' | 'schreiben'
export type Zugang = {
rolle: Rolle
freigegebeneModule: Modul[]
}
/**
* Die eine Regel, nach der entschieden wird:
*
* - BETREUER darf alles, lesen wie schreiben.
* - ADMIN darf alles (er verwaltet die Anwendung).
* - MITLESER darf ausschliesslich lesen, und nur freigegebene Module.
*/
export function darf(zugang: Zugang | null, modul: Modul, art: Zugriffsart = 'lesen'): boolean {
if (!zugang) return false
if (zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN') return true
if (zugang.rolle === 'MITLESER') {
if (art === 'schreiben') return false
return zugang.freigegebeneModule.includes(modul)
}
return false
}
/** Kurzform fuer die Oberflaeche. */
export function darfSchreiben(zugang: Zugang | null, modul: Modul): boolean {
return darf(zugang, modul, 'schreiben')
}
+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)
}
+59
View File
@@ -0,0 +1,59 @@
import 'server-only'
import { PrismaClient } from '@prisma/client'
/**
* Prisma-Client als Singleton.
*
* Im Entwicklungsmodus laedt Next.js Module bei jeder Aenderung neu. Ohne
* diesen Umweg ueber globalThis entstuende bei jedem Speichern eine neue
* Verbindung, bis Postgres keine mehr annimmt.
*/
const globalFuerPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalFuerPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
: [{ emit: 'stdout', level: 'error' }],
})
if (process.env.NODE_ENV !== 'production') {
globalFuerPrisma.prisma = db
}
/**
* Standardfilter: Der Papierkorb wird ueberall ausgeblendet. Wer die
* geloeschten Datensaetze sehen will, fragt sie ausdruecklich ab.
*/
export const nichtGeloescht = { geloeschtAm: null } as const
/**
* Stichtag, ab dem ein Datensatz im Papierkorb endgueltig entfernt wird.
* 30 Tage - lang genug fuer ein "oh, das brauchte ich doch noch".
*/
export const PAPIERKORB_TAGE = 30
export function papierkorbGrenze(jetzt = new Date()): Date {
const grenze = new Date(jetzt)
grenze.setDate(grenze.getDate() - PAPIERKORB_TAGE)
return grenze
}
/**
* BigInt laesst sich nicht als JSON serialisieren. Server Components
* reichen Daten an Client Components weiter - deshalb hier ein Helfer,
* der Cent-Betraege auf dem Weg nach draussen in Number wandelt.
* Cent-Betraege bis 90 Billionen Euro bleiben dabei exakt.
*/
export function centZuZahl(wert: bigint | null | undefined): number | null {
if (wert === null || wert === undefined) return null
if (wert > BigInt(Number.MAX_SAFE_INTEGER) || wert < BigInt(-Number.MAX_SAFE_INTEGER)) {
throw new Error(`Betrag ${wert} liegt ausserhalb des sicher darstellbaren Bereichs.`)
}
return Number(wert)
}
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse, type NextRequest } from 'next/server'
/**
* Content-Security-Policy mit Nonce.
*
* Warum hier und nicht in next.config? Weil die Nonce pro Anfrage neu sein
* muss. Statische Header koennen das nicht.
*
* Die Richtlinie ist bewusst eng: keine fremden Quellen, keine Fonts von
* Google, kein CDN, keine Analytics. Was nicht aus dieser Anwendung kommt,
* darf nicht geladen werden - hier liegen Gesundheits- und Finanzdaten.
*
* 'strict-dynamic' erlaubt es dem Next.js-Loader, weitere eigene Skripte
* nachzuladen, ohne dass jede einzelne Datei aufgelistet werden muss.
*
* Bewusste Zugestaendnisse:
* - style-src erlaubt 'unsafe-inline'. Tailwind selbst braucht das nicht,
* aber Next.js und Radix setzen Inline-Styles fuer Positionierung. Das
* Risiko ist bei Styles deutlich kleiner als bei Skripten.
* - img-src erlaubt data: - QR-Codes und Belegvorschauen werden als
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
* - Im Entwicklungsmodus braucht der Hot-Reload 'unsafe-eval'.
*/
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */
const OEFFENTLICHE_PFADE = [
'/anmelden',
'/ersteinrichtung',
'/datenschutz',
'/impressum',
'/api/auth',
]
function nonceErzeugen(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return btoa(String.fromCharCode(...bytes))
}
export function middleware(anfrage: NextRequest) {
const nonce = nonceErzeugen()
const istEntwicklung = process.env.NODE_ENV === 'development'
const richtlinie = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${istEntwicklung ? " 'unsafe-eval'" : ''}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
// Uploads und Server Actions gehen an die eigene Herkunft. Im
// Entwicklungsmodus zusaetzlich der WebSocket des Hot-Reloads.
`connect-src 'self'${istEntwicklung ? ' ws: wss:' : ''}`,
`media-src 'self' blob:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`frame-src 'self' blob:`,
`worker-src 'self' blob:`,
`manifest-src 'self'`,
...(istEntwicklung ? [] : ['upgrade-insecure-requests']),
].join('; ')
const kopf = new Headers(anfrage.headers)
kopf.set('x-nonce', nonce)
const antwort = NextResponse.next({ request: { headers: kopf } })
antwort.headers.set('Content-Security-Policy', richtlinie)
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
// zwischengespeichert wird.
if (!OEFFENTLICHE_PFADE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) {
antwort.headers.set('Cache-Control', 'no-store, must-revalidate')
}
return antwort
}
export const config = {
matcher: [
/*
* Alles ausser statischen Dateien und dem Favicon. Der negative
* Lookahead haelt die Middleware von Auslieferungspfaden fern, wo sie
* nur Rechenzeit kosten wuerde.
*/
{
source: '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|webp|ico|woff2?)$).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
+38
View File
@@ -0,0 +1,38 @@
import type { Rolle } from '@prisma/client'
import type { DefaultSession } from 'next-auth'
/**
* Erweiterung der Auth.js-Typen um die Felder, die diese Anwendung in der
* Sitzung mitfuehrt. Ohne diese Deklaration waeren session.user.rolle und
* Verwandte in TypeScript unbekannt.
*/
declare module 'next-auth' {
interface User {
rolle: Rolle
sitzungsGeneration: number
zweiterFaktorAktiv: boolean
}
interface Session {
user: {
id: string
rolle: Rolle
sitzungsGeneration: number
zweiterFaktorAktiv: boolean
/** Unix-Sekunden der letzten vollstaendigen Anmeldung. */
angemeldetSeit: number
} & DefaultSession['user']
}
}
declare module 'next-auth/jwt' {
interface JWT {
rolle?: Rolle
sitzungsGeneration?: number
zweiterFaktorAktiv?: boolean
angemeldetSeit?: number
}
}
export {}