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:
@@ -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
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Vendored
+38
@@ -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 {}
|
||||||
Reference in New Issue
Block a user