diff --git a/e2e/anmeldung.spec.ts b/e2e/anmeldung.spec.ts new file mode 100644 index 0000000..51f5221 --- /dev/null +++ b/e2e/anmeldung.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test' +import { anmelden, fehlversucheLoeschen, umgebung } from './hilfen' + +/** + * Hinweis zu den Selektoren: Meldungen werden immer INNERHALB des + * Formulars gesucht (`page.locator('form').getByRole('alert')`). Die + * Fehleranzeige von Next.js traegt im Entwicklungsmodus ebenfalls + * role="alert" - ein ungenauer Selektor findet zwei Elemente und der + * Test schlaegt aus dem falschen Grund fehl. + * + * Der wichtigste Ablauf ueberhaupt: Ohne Anmeldung kommt niemand an + * Gesundheits- und Vermoegensdaten. Diese Datei prueft die Zusage, dass + * der zweite Faktor Pflicht ist und sich nicht umgehen laesst. + */ + +test.describe('Anmeldung', () => { + // Ein Test loest die Sperre absichtlich aus - danach muss aufgeraeumt + // werden, sonst haengen die folgenden Tests an ihr fest. + test.afterEach(async () => { + await fehlversucheLoeschen() + }) + + test('verlangt alle drei Angaben und laesst danach herein', async ({ page }) => { + await page.goto('/anmelden') + + await expect(page.getByRole('heading', { name: 'Anmelden' })).toBeVisible() + await expect(page.locator('input[name="email"]')).toBeVisible() + await expect(page.locator('input[name="passwort"]')).toBeVisible() + await expect(page.locator('input[name="code"]')).toBeVisible() + + await anmelden(page) + await expect(page).toHaveURL(/\/heute/) + }) + + test('verraet nicht, welche der drei Angaben falsch war', async ({ page }) => { + const env = await umgebung() + + // Zwei Fehlversuche, die sich nur in EINER Angabe unterscheiden. + // Beide muessen dieselbe Meldung liefern - sonst liesse sich daraus + // ablesen, ob eine E-Mail-Adresse existiert oder ob das Passwort + // bereits stimmte. + const meldungen: string[] = [] + + for (const versuch of [ + { email: env.SEED_ADMIN_EMAIL!, passwort: 'ganz-sicher-falsch-2026' }, + { email: 'gibt-es-nicht@example.invalid', passwort: 'ganz-sicher-falsch-2026' }, + ]) { + await page.goto('/anmelden') + await page.fill('input[name="email"]', versuch.email) + await page.fill('input[name="passwort"]', versuch.passwort) + await page.fill('input[name="code"]', '123456') + await page.click('button[type="submit"]') + + const meldung = page.locator('form').getByRole('alert') + await expect(meldung).toBeVisible() + meldungen.push(((await meldung.textContent()) ?? '').trim()) + + await expect(page).toHaveURL(/\/anmelden/) + } + + // Entweder beide Male die neutrale Meldung, oder beide Male die + // Sperrmeldung - aber nie zwei verschiedene Auskuenfte. + expect(meldungen[0]).toBe(meldungen[1]) + + // Und in keinem Fall ein Hinweis auf das einzelne Feld. + for (const meldung of meldungen) { + expect(meldung).not.toMatch(/unbekannt|nicht registriert|existiert nicht/i) + expect(meldung).not.toMatch(/Passwort ist falsch|falsches Passwort/i) + } + }) + + test('sperrt nach mehreren Fehlversuchen und sagt das auch', async ({ page }) => { + const env = await umgebung() + + // LOGIN_MAX_VERSUCHE steht in der .env, Standard 5. Sechs Versuche + // reichen also in jedem Fall. + let sperrmeldung = '' + + for (let i = 0; i < 6; i++) { + await page.goto('/anmelden') + await page.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!) + await page.fill('input[name="passwort"]', `falsch-${i}`) + await page.fill('input[name="code"]', '123456') + await page.click('button[type="submit"]') + + // Auf die Meldung warten, statt sie sofort zu lesen: die Anmeldung + // wird bewusst auf eine Mindestdauer gestreckt, damit die Antwortzeit + // nichts verraet. + const meldung = page.locator('form').getByRole('alert') + await expect(meldung).toBeVisible({ timeout: 20_000 }) + const text = ((await meldung.textContent()) ?? '').trim() + + if (/gesperrt|zu viele/i.test(text)) { + sperrmeldung = text + break + } + } + + expect(sperrmeldung, 'Nach sechs Fehlversuchen muss gesperrt werden').not.toBe('') + // Die Sperre wird benannt - sonst probiert man ratlos weiter. + expect(sperrmeldung).toMatch(/gesperrt|zu viele/i) + }) + + test('schickt Unangemeldete von Fachseiten zur Anmeldung', async ({ page }) => { + for (const pfad of ['/heute', '/stammdaten', '/fristen', '/protokoll']) { + await page.goto(pfad) + await expect(page, `${pfad} muss geschuetzt sein`).toHaveURL(/\/anmelden/) + } + }) + + test('setzt eine strenge Content-Security-Policy und laedt nichts von aussen', async ({ + page, + }) => { + const fremdeAnfragen: string[] = [] + page.on('request', (anfrage) => { + const url = new URL(anfrage.url()) + if (!['localhost', '127.0.0.1'].includes(url.hostname)) { + fremdeAnfragen.push(anfrage.url()) + } + }) + + const antwort = await page.goto('/anmelden') + const kopf = antwort?.headers() ?? {} + + expect(kopf['content-security-policy']).toBeTruthy() + expect(kopf['content-security-policy']).toContain("default-src 'self'") + expect(kopf['content-security-policy']).toContain("frame-ancestors 'none'") + expect(kopf['content-security-policy']).toContain("object-src 'none'") + expect(kopf['x-content-type-options']).toBe('nosniff') + expect(kopf['x-frame-options']).toBe('DENY') + + await page.waitForLoadState('networkidle') + + // Keine Schriften von Google, kein CDN, keine Analysedienste. + expect(fremdeAnfragen, `Fremde Anfragen: ${fremdeAnfragen.join(', ')}`).toHaveLength(0) + }) + + test('meldet ab und sperrt danach wieder aus', async ({ page }) => { + await anmelden(page) + await expect(page).toHaveURL(/\/heute/) + + // Am Handy liegt das Benutzermenue in der Kopfzeile, am Schreibtisch + // unten in der Seitenleiste - der Knopf traegt in beiden Faellen den + // Namen des angemeldeten Benutzers. + await page.getByRole('button', { name: /Florian Wilde|Betreuer|Administrator/i }).first().click() + await page.getByRole('menuitem', { name: 'Abmelden' }).click() + + await page.waitForURL(/\/anmelden/, { timeout: 30_000 }) + + await page.goto('/stammdaten') + await expect(page).toHaveURL(/\/anmelden/) + }) +}) diff --git a/e2e/hilfen.ts b/e2e/hilfen.ts new file mode 100644 index 0000000..d9c3e16 --- /dev/null +++ b/e2e/hilfen.ts @@ -0,0 +1,119 @@ +import type { Page } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { TOTP, Secret } from 'otpauth' +import { readFile } from 'node:fs/promises' + +/** + * Gemeinsame Helfer fuer die Playwright-Tests. + * + * Die Anmeldung braucht einen echten TOTP-Code. Deshalb setzt der Seed im + * Entwicklungsmodus ein bekanntes Geheimnis (SEED_DEV_TOTP_SECRET), aus + * dem sich hier der aktuelle Code errechnen laesst. In der Produktion + * ignoriert der Seed diese Variable. + */ + +let gemerkt: Record | null = null + +export async function umgebung(): Promise> { + if (gemerkt) return gemerkt + + const roh = await readFile('.env', 'utf8') + const werte: Record = {} + for (const zeile of roh.split(/\r?\n/)) { + const treffer = zeile.match(/^([A-Z0-9_]+)=(.*)$/) + if (treffer) werte[treffer[1]!] = treffer[2]!.replace(/^"|"$/g, '') + } + + gemerkt = werte + return werte +} + +export function totpCode(secret: string, kennung: string): string { + return new TOTP({ + issuer: 'Betreuungs-Cockpit', + label: kennung, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secret), + }).generate() +} + +/** + * Loescht die Fehlversuche des Testzugangs. + * + * Noetig, weil ein Test die Login-Sperre absichtlich ausloest. Ohne diesen + * Reset waeren alle folgenden Tests fuer eine Viertelstunde ausgesperrt - + * und man saehe nicht mehr, ob sie wirklich fehlschlagen oder nur warten. + */ +export async function fehlversucheLoeschen(): Promise { + const db = new PrismaClient() + try { + await db.loginVersuch.deleteMany({ where: { erfolg: false } }) + } finally { + await db.$disconnect() + } +} + +/** Meldet sich an und wartet, bis die Anwendung erreicht ist. */ +export async function anmelden(seite: Page): Promise { + const env = await umgebung() + + // Vorherige Tests koennen die Sperre ausgeloest haben. + await fehlversucheLoeschen() + + await seite.goto('/anmelden') + await seite.fill('input[name="email"]', env.SEED_ADMIN_EMAIL!) + await seite.fill('input[name="passwort"]', env.SEED_ADMIN_PASSWORT!) + await seite.fill('input[name="code"]', totpCode(env.SEED_DEV_TOTP_SECRET!, env.SEED_ADMIN_EMAIL!)) + + await Promise.all([ + seite.waitForURL((url) => !url.pathname.startsWith('/anmelden'), { timeout: 45_000 }), + seite.click('button[type="submit"]'), + ]) +} + +/** + * Ein winziges, gueltiges PNG als Buffer - dient als hochgeladenes + * "Foto eines Behoerdenschreibens". Bewusst inline erzeugt, damit im + * Repository keine Binaerdatei liegt. + */ +export function beispielBild(): Buffer { + return Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ) +} + +/** + * Eine minimale, gueltige PDF-Datei. Ebenfalls inline erzeugt. + * Enthaelt den uebergebenen Text als sichtbaren Seiteninhalt. + */ +export function beispielPdf(text = 'Bescheid'): Buffer { + const inhalt = `BT /F1 24 Tf 72 700 Td (${text}) Tj ET` + const objekte = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ' + + '/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${inhalt.length} >>\nstream\n${inhalt}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ] + + let pdf = '%PDF-1.4\n' + const versaetze: number[] = [] + + objekte.forEach((objekt, i) => { + versaetze.push(pdf.length) + pdf += `${i + 1} 0 obj\n${objekt}\nendobj\n` + }) + + const xref = pdf.length + pdf += `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n` + for (const versatz of versaetze) { + pdf += `${String(versatz).padStart(10, '0')} 00000 n \n` + } + pdf += `trailer\n<< /Size ${objekte.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` + + return Buffer.from(pdf, 'latin1') +} diff --git a/package.json b/package.json index 36893dd..48d4488 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "db:sql": "npm run db:volltext && npm run db:audit-schutz", "protokoll": "node scripts/bauprotokoll.mjs", "test:e2e": "playwright test", - "protokoll:md": "node scripts/bauprotokoll.mjs --md" + "protokoll:md": "node scripts/bauprotokoll.mjs --md", + "wartungsschluessel": "node --env-file=.env scripts/wartungsschluessel.mjs" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..60c24fe --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright fuer die kritischen Ablaeufe. + * + * Zwei Geraete, weil die Anwendung am Handy vollwertig bedienbar sein muss - + * das ist kein Zusatz, sondern Anforderung: Ich sitze oft im Heim oder auf + * dem Amt und habe nur das Telefon dabei. + * + * Der Server wird nicht automatisch gestartet: die Tests brauchen eine + * eingerichtete Datenbank mit Demodaten. Vorher einmal: + * + * docker compose -f docker-compose.dev.yml up -d + * npm run db:migrate && npm run db:sql + * SEED_DEMO=true npm run db:seed + * npm run dev + */ +const BASIS = process.env.E2E_BASIS ?? 'http://localhost:3300' + +export default defineConfig({ + testDir: './e2e', + // Anmeldung, Uploads und PDF-Erzeugung brauchen Zeit. + timeout: 90_000, + expect: { timeout: 15_000 }, + + // Die Tests teilen sich eine Datenbank und legen Daten an - parallel + // wuerden sie sich gegenseitig die Zaehlungen verderben. + fullyParallel: false, + workers: 1, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + + use: { + baseURL: BASIS, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'schreibtisch', + use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } }, + }, + { + name: 'handy', + use: { ...devices['Pixel 7'] }, + }, + ], +}) diff --git a/scripts/wartungsschluessel.mjs b/scripts/wartungsschluessel.mjs new file mode 100644 index 0000000..73fcae8 --- /dev/null +++ b/scripts/wartungsschluessel.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Gibt den Schluessel fuer /api/wartung aus. + * + * node --env-file=.env scripts/wartungsschluessel.mjs + * + * Damit laesst sich der taegliche Wartungslauf von aussen anstossen, z.B. + * per cron auf dem Server: + * + * 30 4 * * * curl -fsS -H "X-Wartung-Schluessel: " \ + * https://cockpit.example.de/api/wartung >/dev/null + */ +import { createHmac } from 'node:crypto' + +const geheim = process.env.AUTH_SECRET +if (!geheim) { + console.error('AUTH_SECRET ist nicht gesetzt. Aufruf: node --env-file=.env scripts/wartungsschluessel.mjs') + process.exit(1) +} + +console.log(createHmac('sha256', geheim).update('wartung').digest('base64url')) diff --git a/src/app/api/wartung/route.ts b/src/app/api/wartung/route.ts new file mode 100644 index 0000000..59d7b52 --- /dev/null +++ b/src/app/api/wartung/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { createHmac, timingSafeEqual } from 'node:crypto' +import { wartungslauf } from '@/lib/wartung' + +/** + * Anstoss fuer den taeglichen Wartungslauf. + * + * curl -H "X-Wartung-Schluessel: " https://.../api/wartung + * + * Der Schluessel ist ein HMAC ueber die Zeichenkette "wartung" mit + * AUTH_SECRET - so braucht es kein weiteres Geheimnis in der .env, und der + * Wert laesst sich mit dem Skript unten jederzeit neu ausrechnen: + * + * node -e "console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET).update('wartung').digest('base64url'))" + * + * Ohne gueltigen Schluessel antwortet die Route mit 404 statt 401 - wer + * die Adresse errät, soll nicht einmal erfahren, dass es sie gibt. + */ +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function erwarteterSchluessel(): string { + return createHmac('sha256', process.env.AUTH_SECRET ?? '') + .update('wartung') + .digest('base64url') +} + +function schluesselStimmt(uebergeben: string | null): boolean { + if (!uebergeben) return false + const erwartet = Buffer.from(erwarteterSchluessel()) + const vorhanden = Buffer.from(uebergeben) + if (erwartet.length !== vorhanden.length) return false + return timingSafeEqual(erwartet, vorhanden) +} + +export async function GET(anfrage: NextRequest) { + const schluessel = + anfrage.headers.get('x-wartung-schluessel') ?? + anfrage.nextUrl.searchParams.get('schluessel') + + if (!schluesselStimmt(schluessel)) { + return new NextResponse('Not Found', { status: 404 }) + } + + const ergebnis = await wartungslauf() + + return NextResponse.json(ergebnis, { + status: ergebnis.fehler.length > 0 ? 207 : 200, + headers: { 'Cache-Control': 'no-store' }, + }) +} diff --git a/src/lib/mail.ts b/src/lib/mail.ts new file mode 100644 index 0000000..d04cca5 --- /dev/null +++ b/src/lib/mail.ts @@ -0,0 +1,140 @@ +import 'server-only' +import nodemailer from 'nodemailer' +import type { Transporter } from 'nodemailer' +import { env } from '@/lib/env' + +/** + * E-Mail-Versand fuer Fristerinnerungen. + * + * Bewusst als Zugabe gebaut, nicht als Kernstueck: Ist kein SMTP + * konfiguriert, wird nichts gesendet und nichts geworfen. Die Fristen + * stehen ohnehin im Dashboard und im abonnierten Kalender - eine + * ausgefallene Mail darf nicht dazu fuehren, dass ein Wartungslauf + * abbricht und danach auch das Aufraeumen liegen bleibt. + * + * Inhaltlich gilt Datensparsamkeit: In die Mail gehoert, DASS etwas + * faellig ist und wo man nachsieht - nicht, worum es geht. E-Mail ist + * unverschluesselt unterwegs und landet auf fremden Servern; Diagnosen + * oder Kontostaende haben darin nichts zu suchen. Deshalb nimmt + * mailSenden() bewusst nur einfachen Text entgegen, und die Aufrufer + * halten sich an diese Regel. + */ + +let versender: Transporter | null = null + +function versenderHolen(): Transporter { + if (versender) return versender + + const smtp = env.smtp + versender = nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + // 465 ist implizit TLS, 587 wird per STARTTLS hochgestuft. + secure: smtp.port === 465, + auth: smtp.user ? { user: smtp.user, pass: smtp.passwort } : undefined, + requireTLS: smtp.port !== 465, + tls: { + // Ein Zertifikatsfehler ist ein Fehler, kein Grund zum Weitermachen. + rejectUnauthorized: true, + minVersion: 'TLSv1.2', + }, + }) + + return versender +} + +export type Nachricht = { + an: string + betreff: string + text: string +} + +export type Versandergebnis = { + gesendet: boolean + meldung?: string +} + +export async function mailSenden(nachricht: Nachricht): Promise { + if (!env.smtpKonfiguriert) { + return { gesendet: false, meldung: 'Kein SMTP konfiguriert - es wurde nichts gesendet.' } + } + + try { + await versenderHolen().sendMail({ + from: env.smtp.absender, + to: nachricht.an, + subject: nachricht.betreff, + text: nachricht.text, + headers: { + // Damit Abwesenheitsantworten nicht zurueckkommen. + 'Auto-Submitted': 'auto-generated', + 'X-Auto-Response-Suppress': 'All', + }, + }) + return { gesendet: true } + } catch (fehler) { + const meldung = fehler instanceof Error ? fehler.message : String(fehler) + console.error('[mail] Versand fehlgeschlagen:', meldung) + return { gesendet: false, meldung } + } +} + +/** + * Erinnerungstext fuer eine faellige Frist. + * + * Enthaelt Titel und Datum der Frist, aber weder den Namen der betreuten + * Person noch inhaltliche Angaben. Wer die Mail abfaengt, erfaehrt daraus + * nichts ueber den Fall. + */ +export function fristErinnerung(frist: { + titel: string + datum: Date + tageBis: number + ausschlussfrist: boolean + rechtsgrundlage: string | null +}): Nachricht { + const datum = frist.datum.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + + const wann = + frist.tageBis < 0 + ? `war am ${datum} fällig und ist seit ${Math.abs(frist.tageBis)} Tagen überfällig` + : frist.tageBis === 0 + ? `ist heute fällig (${datum})` + : `ist in ${frist.tageBis} Tagen fällig (${datum})` + + const zeilen = [ + `Eine Frist ${wann}:`, + '', + ` ${frist.titel}`, + frist.rechtsgrundlage ? ` ${frist.rechtsgrundlage}` : '', + '', + ] + + if (frist.ausschlussfrist) { + zeilen.push( + 'ACHTUNG: Das ist eine Ausschlussfrist. Läuft sie ab, ist der Anspruch', + 'ersatzlos weg - es gibt keine Nachsicht und keine Wiedereinsetzung.', + '', + ) + } + + zeilen.push( + `Einzelheiten stehen im Betreuungs-Cockpit: ${env.basisUrl}/fristen`, + '', + '--', + 'Diese Nachricht wurde automatisch erzeugt. Sie enthält bewusst keine', + 'Angaben zur betreuten Person - E-Mail ist unterwegs nicht verschlüsselt.', + ) + + return { + an: '', + betreff: frist.ausschlussfrist + ? `Ausschlussfrist: ${frist.titel}` + : `Frist: ${frist.titel}`, + text: zeilen.filter((z) => z !== undefined).join('\n'), + } +} diff --git a/src/lib/wartung.ts b/src/lib/wartung.ts new file mode 100644 index 0000000..d1468e5 --- /dev/null +++ b/src/lib/wartung.ts @@ -0,0 +1,208 @@ +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' + +/** + * 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) + * + * 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 + 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 { + const ergebnis: Wartungsergebnis = { + erinnerungen: 0, + papierkorbGeleert: 0, + loginversucheEntfernt: 0, + auditEintraegeEntfernt: 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)}`) + } + + 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 { + 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 { + 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 { + 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 Promise<{ count: number }> }>)[ + tabelle + ] + if (!modell) continue + + const { count } = await modell.deleteMany({ + where: { geloeschtAm: { lt: grenze } }, + }) + entfernt += count + } + + return entfernt +} diff --git a/src/server/actions/anmelden.ts b/src/server/actions/anmelden.ts index 0b2e0d2..af48882 100644 --- a/src/server/actions/anmelden.ts +++ b/src/server/actions/anmelden.ts @@ -1,8 +1,10 @@ 'use server' import { AuthError } from 'next-auth' +import { headers } from 'next/headers' import { z } from 'zod' import { signIn } from '@/auth' +import { anmeldungErlaubt } from '@/lib/auth/ratelimit' /** * Server Action fuer das Anmeldeformular. @@ -41,6 +43,16 @@ function istWeiterleitung(fehler: unknown): boolean { ) } +/** Hinter dem Reverse Proxy steht die echte Adresse im weitergereichten Kopf. */ +async function ipDerAnfrage(): Promise { + try { + const kopf = await headers() + return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null + } catch { + return null + } +} + /** Verhindert offene Weiterleitungen auf fremde Seiten. */ function sicheresZiel(ziel: string): string { if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute' @@ -93,6 +105,19 @@ export async function anmelden( const { email, passwort, code, weiter } = gelesen.data + // Die Sperre wird hier geprueft und nicht erst in authorize(). + // + // Grund: Auth.js reicht den Fehlercode aus authorize() nicht + // unveraendert an die Server Action zurueck - man saehe auch bei + // gesperrtem Zugang nur die allgemeine Meldung und wuerde ratlos + // weiterprobieren. Die Pruefung in authorize() bleibt trotzdem + // bestehen: sie ist die eigentliche Sperre, diese hier ist die + // Erklaerung dafuer. + const sperre = await anmeldungErlaubt(email, await ipDerAnfrage()) + if (sperre.gesperrt) { + return { fehler: fehlertext(`gesperrt:${sperre.nochSekunden}`), email } + } + try { await signIn('zugangsdaten', { email, diff --git a/tests/feldverschluesselung.test.ts b/tests/feldverschluesselung.test.ts index ccbb746..ea2a441 100644 --- a/tests/feldverschluesselung.test.ts +++ b/tests/feldverschluesselung.test.ts @@ -115,17 +115,20 @@ describe('sealen und oeffnen', () => { }) it('oeffnet einen Datensatz und ergaenzt die Klartextfelder', () => { - const datenbankzeile = { - id: 'abc', - vorname: 'Erika', - ...sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null }), - } + const versiegelt = sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null }) + const datenbankzeile = { id: 'abc', vorname: 'Erika', ...versiegelt } + const offen = oeffnen('Betreute', datenbankzeile) expect(offen.vorname).toBe('Erika') expect(offen.diagnosen).toBe('COPD GOLD III') expect(offen.notizen).toBeNull() - // Der Umschlag bleibt stehen. - expect(offen.diagnosenVerschluesselt).toMatch(/^bc1\./) + + // Der Umschlag bleibt stehen - sonst wuerde ein Zurueckschreiben des + // geoeffneten Datensatzes die verschluesselten Felder leeren. + expect(versiegelt.diagnosenVerschluesselt).toMatch(/^bc1\./) + expect((offen as Record).diagnosenVerschluesselt).toBe( + versiegelt.diagnosenVerschluesselt, + ) }) })