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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user