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.
222 lines
6.8 KiB
TypeScript
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
|
|
}
|