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