Files
betreuungs-cockpit/src/lib/wartung.ts
T
Florian Wilde c38c988c3c feat: Anmelden mit Passkey
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".

Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.

TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.

Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
  nicht in der Server Action davor. Ein "war schon geprueft" ueber die
  Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
  lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
  verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
  zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
  Anmeldung nennt den verwendeten Passkey namentlich.

Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
  prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
  entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
  Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
  Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
  geloeschte.

Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.

Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
2026-08-22 18:44:56 +02:00

222 lines
6.8 KiB
TypeScript

import 'server-only'
import { db, papierkorbGrenze } from '@/lib/db'
import { env } from '@/lib/env'
import { fristErinnerung, mailSenden } from '@/lib/mail'
import { alteVersucheEntfernen } from '@/lib/auth/ratelimit'
import { abgelaufeneAnfragenEntfernen } from '@/lib/auth/passkey'
/**
* Taeglicher Wartungslauf.
*
* Wird ueber die Route /api/wartung angestossen (durch cron, systemd-timer
* oder den Docker-Sicherungsdienst). Vier Aufgaben:
*
* 1. Fristerinnerungen verschicken, wenn eine Vorwarnstufe erreicht ist
* 2. Papierkorb leeren (30 Tage)
* 3. Alte Anmeldeversuche entfernen (30 Tage)
* 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion)
* 5. Liegengebliebene Passkey-Challenges entfernen (5 Minuten)
*
* Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll
* trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein
* SMTP-Server nicht antwortet.
*/
export type Wartungsergebnis = {
erinnerungen: number
papierkorbGeleert: number
loginversucheEntfernt: number
auditEintraegeEntfernt: number
passkeyAnfragenEntfernt: number
fehler: string[]
}
/** Tabellen mit Papierkorb - Reihenfolge egal, Fremdschluessel raeumen mit. */
const PAPIERKORB_TABELLEN = [
'frist',
'vorgang',
'genehmigung',
'dokument',
'buchung',
'konto',
'vermoegensposten',
'immobilie',
'vertrag',
'kontakt',
'kontaktprotokoll',
'gesundheitsereignis',
'sozialhilfeAntrag',
'nachforderung',
'bescheid',
'leistung',
'bericht',
'betreute',
] as const
export async function wartungslauf(): Promise<Wartungsergebnis> {
const ergebnis: Wartungsergebnis = {
erinnerungen: 0,
papierkorbGeleert: 0,
loginversucheEntfernt: 0,
auditEintraegeEntfernt: 0,
passkeyAnfragenEntfernt: 0,
fehler: [],
}
// --- 1. Fristerinnerungen ---------------------------------------------
try {
ergebnis.erinnerungen = await erinnerungenVerschicken()
} catch (fehler) {
ergebnis.fehler.push(`Erinnerungen: ${beschreiben(fehler)}`)
}
// --- 2. Papierkorb leeren ----------------------------------------------
try {
ergebnis.papierkorbGeleert = await papierkorbLeeren()
} catch (fehler) {
ergebnis.fehler.push(`Papierkorb: ${beschreiben(fehler)}`)
}
// --- 3. Anmeldeversuche -------------------------------------------------
try {
ergebnis.loginversucheEntfernt = await alteVersucheEntfernen()
} catch (fehler) {
ergebnis.fehler.push(`Anmeldeversuche: ${beschreiben(fehler)}`)
}
// --- 4. Audit-Log --------------------------------------------------------
try {
// Die Tabelle ist gegen DELETE gesperrt; nur diese Funktion darf
// aufraeumen. Zehn Jahre entsprechen der Aufbewahrung der
// Rechnungslegungsunterlagen.
const zeilen = await db.$queryRaw<
{ audit_log_aufraeumen: bigint }[]
>`SELECT audit_log_aufraeumen('10 years'::interval)`
ergebnis.auditEintraegeEntfernt = Number(zeilen[0]?.audit_log_aufraeumen ?? 0)
} catch (fehler) {
ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`)
}
// --- 5. Passkey-Challenges ----------------------------------------------
try {
// Normalerweise raeumt jede Anmeldung selbst auf. Bleibt der letzte
// Versuch des Tages liegen, faengt es dieser Lauf ab.
ergebnis.passkeyAnfragenEntfernt = await abgelaufeneAnfragenEntfernen()
} catch (fehler) {
ergebnis.fehler.push(`Passkey-Anfragen: ${beschreiben(fehler)}`)
}
return ergebnis
}
function beschreiben(fehler: unknown): string {
return fehler instanceof Error ? fehler.message : String(fehler)
}
/**
* Verschickt je Frist hoechstens eine Mail pro Vorwarnstufe.
*
* letzteErinnerungStufe merkt sich die zuletzt gemeldete Stufe. Erst wenn
* eine engere Stufe erreicht ist, geht wieder eine Mail raus - sonst kaeme
* sie taeglich, und man wuerde sie nach einer Woche ignorieren. Genau das
* soll nicht passieren.
*/
async function erinnerungenVerschicken(): Promise<number> {
if (!env.smtpKonfiguriert) return 0
const heute = new Date()
heute.setHours(0, 0, 0, 0)
const fristen = await db.frist.findMany({
where: { status: 'OFFEN', geloeschtAm: null },
select: {
id: true,
titel: true,
datum: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorwarnungTage: true,
letzteErinnerungStufe: true,
betreuteId: true,
},
})
let verschickt = 0
for (const frist of fristen) {
const tageBis = Math.round((frist.datum.getTime() - heute.getTime()) / 86_400_000)
// Erreichte Stufen: alle Vorwarnungen, die schon greifen. Ueberfaellige
// Fristen bekommen die Stufe 0.
const erreicht = frist.vorwarnungTage.filter((tage) => tageBis <= tage)
const stufe = tageBis < 0 ? 0 : erreicht.length > 0 ? Math.min(...erreicht) : null
if (stufe === null) continue
// Schon fuer diese oder eine engere Stufe gemeldet?
if (frist.letzteErinnerungStufe !== null && frist.letzteErinnerungStufe <= stufe) continue
const empfaenger = await empfaengerFuer(frist.betreuteId)
if (empfaenger.length === 0) continue
const vorlage = fristErinnerung({
titel: frist.titel,
datum: frist.datum,
tageBis,
ausschlussfrist: frist.ausschlussfrist,
rechtsgrundlage: frist.rechtsgrundlage,
})
let mindestensEine = false
for (const an of empfaenger) {
const { gesendet } = await mailSenden({ ...vorlage, an })
if (gesendet) mindestensEine = true
}
if (mindestensEine) {
await db.frist.update({
where: { id: frist.id },
data: { letzteErinnerungStufe: stufe },
})
verschickt += 1
}
}
return verschickt
}
/** Nur Betreuer bekommen Erinnerungen, keine Mitleser. */
async function empfaengerFuer(betreuteId: string): Promise<string[]> {
const zugaenge = await db.betreuungsZugang.findMany({
where: { betreuteId, rolle: 'BETREUER', user: { aktiv: true, geloeschtAm: null } },
select: { user: { select: { email: true } } },
})
return zugaenge.map((z) => z.user.email)
}
/**
* Entfernt endgueltig, was laenger als 30 Tage im Papierkorb liegt.
*
* Das Audit-Log bleibt davon unberuehrt - dort steht weiterhin, dass und
* wann etwas geloescht wurde. Genau darum geht es bei einem Nachweis.
*/
async function papierkorbLeeren(): Promise<number> {
const grenze = papierkorbGrenze()
let entfernt = 0
for (const tabelle of PAPIERKORB_TABELLEN) {
// Prisma hat keinen dynamischen Zugriff mit Typsicherheit - deshalb
// hier ein bewusster, eng begrenzter Umweg ueber den Modellnamen.
const modell = (db as unknown as Record<string, { deleteMany: (a: unknown) => Promise<{ count: number }> }>)[
tabelle
]
if (!modell) continue
const { count } = await modell.deleteMany({
where: { geloeschtAm: { lt: grenze } },
})
entfernt += count
}
return entfernt
}