diff --git a/CHANGELOG.md b/CHANGELOG.md index 243c309..a6f1f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,51 @@ Wiederherstellung*. _Noch nichts._ +## [1.0.2] – 2026-08-22 + +### Behoben + +- **Die Einrichtung des zweiten Faktors brach mit einem Serverfehler ab** + („Application error: a server-side exception has occurred"). Im + Serverlog stand `Umgebungsvariable ENC_KEY_V2 fehlt` — obwohl niemand + einen zweiten Schlüssel wollte. + + Ursache: `docker-compose.yml` reicht `ENC_KEY_V2` immer an den Behälter + durch, mit **leerem Wert**, wenn in der `.env` keiner steht. Die + Anwendung erkannte Schlüssel aber am Variablennamen statt am Inhalt, + fand also ein leeres `ENC_KEY_V2` und brach ab. Leere + Schlüsselvariablen werden jetzt übersprungen. + + Betroffen war jede Stelle, die verschlüsselte Felder schreibt oder + liest — also praktisch alles außer der reinen Anmeldung. Die Anmeldung + ging durch, weil sie ohne Verschlüsselung auskommt; der Fehler trat + deshalb erst auf der nächsten Seite auf. + +- **Die Meldung beim Schlüsselwechsel** nennt jetzt, welche Versionen + tatsächlich vorhanden sind, und die richtige Reihenfolge: erst den + neuen Schlüssel eintragen, dann `ENC_KEY_AKTIV` umstellen. + +### Hinzugefügt + +- **`scripts/anmeldung-pruefen.mjs`** — beantwortet die Frage, warum eine + Anmeldung nicht klappt. Das Formular sagt bewusst nicht, woran es lag, + sonst ließe sich daran ablesen, welche Adressen es gibt. Auf dem Server + darf man es wissen: Jeder Versuch wird mit Grund protokolliert, und das + Skript liest ihn vor — dazu den Zustand des Zugangs, den Stand der + Login-Sperre und die eingestellte `AUTH_URL`. + + ```bash + docker compose exec app node scripts/anmeldung-pruefen.mjs deine@adresse.de + ``` + + Es ändert nichts, es liest nur. + +### Geprüft + +- Sieben neue Tests (`tests/umgebung.test.ts`) decken die + Schlüsselauswertung ab, darunter genau die Konstellation aus dem + Fehlerbericht: gesetztes `ENC_KEY_V1`, leeres `ENC_KEY_V2`. + ## [1.0.1] – 2026-08-22 ### Behoben @@ -219,6 +264,7 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md). --- -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.1...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...HEAD +[1.0.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.1...v1.0.2 [1.0.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.0...v1.0.1 [1.0.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0 diff --git a/docker-compose.yml b/docker-compose.yml index 02be03e..bbc61be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,8 @@ services: AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env} AUTH_TRUST_HOST: 'true' ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar} + # Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt + # leere Schluesselvariablen - siehe src/lib/env.ts. ENC_KEY_V2: ${ENC_KEY_V2:-} ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1} INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env} diff --git a/package.json b/package.json index c70d50f..03d3189 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/scripts/anmeldung-pruefen.mjs b/scripts/anmeldung-pruefen.mjs new file mode 100644 index 0000000..b2afc84 --- /dev/null +++ b/scripts/anmeldung-pruefen.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Warum klappt die Anmeldung nicht? + * + * node --env-file=.env scripts/anmeldung-pruefen.mjs + * docker compose exec app node scripts/anmeldung-pruefen.mjs + * + * Die Anwendung sagt am Anmeldeformular bewusst nicht, woran es lag - sonst + * liesse sich daran ablesen, welche Adressen es gibt. Auf dem Server darf + * man es wissen: Jeder Versuch wird mit Grund protokolliert, und dieses + * Skript liest ihn vor. + * + * Es aendert nichts. Es liest nur. + */ +import { PrismaClient } from '@prisma/client' + +const email = (process.argv[2] ?? '').trim().toLowerCase() + +if (!email) { + console.error('Aufruf: node scripts/anmeldung-pruefen.mjs ') + process.exit(1) +} + +const db = new PrismaClient() + +function zeit(d) { + return d ? new Date(d).toLocaleString('de-DE') : '—' +} + +/** Klartext zu den Gruenden, die src/auth.ts protokolliert. */ +const GRUND = { + unbekannt: 'Diese E-Mail-Adresse gibt es nicht (Tippfehler? andere Adresse?)', + 'passwort-falsch': 'Das Passwort stimmt nicht', + 'code-falsch': 'Der Code aus der Authenticator-App stimmt nicht', + gesperrt: 'Abgewiesen, weil zu diesem Zeitpunkt bereits gesperrt war', + 'konto-inaktiv': 'Der Zugang ist deaktiviert oder im Papierkorb', +} + +try { + const benutzer = await db.user.findUnique({ where: { email } }) + + console.log('\n══ Zugang ' + '═'.repeat(50)) + + if (!benutzer) { + console.log(` Es gibt KEINEN Zugang mit der Adresse ${email}.`) + const alle = await db.user.findMany({ + select: { email: true, aktiv: true, geloeschtAm: true }, + orderBy: { erstelltAm: 'asc' }, + }) + console.log( + alle.length + ? '\n Vorhandene Adressen:\n' + + alle.map((u) => ` ${u.email}${u.geloeschtAm ? ' (im Papierkorb)' : u.aktiv ? '' : ' (deaktiviert)'}`).join('\n') + : '\n Es gibt ueberhaupt keinen Zugang. Leg ihn im Browser unter /ersteinrichtung an.', + ) + } else { + const zweiterFaktor = benutzer.totpAktiviertAm + ? `eingerichtet am ${zeit(benutzer.totpAktiviertAm)}` + : benutzer.totpSecretVerschluesselt + ? 'vorbereitet, aber NICHT bestaetigt - beim Anmelden wird noch kein Code verlangt' + : 'noch nicht eingerichtet - beim Anmelden wird noch kein Code verlangt' + + console.log(` Name: ${benutzer.name}`) + console.log(` Rolle: ${benutzer.rolle}`) + console.log(` Aktiv: ${benutzer.aktiv ? 'ja' : 'NEIN - Anmeldung nicht moeglich'}`) + console.log(` Papierkorb: ${benutzer.geloeschtAm ? 'JA seit ' + zeit(benutzer.geloeschtAm) : 'nein'}`) + console.log(` Zweiter Faktor: ${zweiterFaktor}`) + console.log(` Wiederherstellung: ${benutzer.wiederherstellungscodes.length} Codes uebrig`) + console.log(` Passwort gesetzt: ${zeit(benutzer.passwortGeaendertAm)}`) + console.log(` Letzte Anmeldung: ${zeit(benutzer.letzteAnmeldung)}`) + + const zugaenge = await db.betreuungsZugang.count({ where: { userId: benutzer.id } }) + const betreute = await db.betreute.count({ where: { geloeschtAm: null } }) + console.log(` Betreute Personen: ${zugaenge} zugeordnet, ${betreute} insgesamt angelegt`) + + if (zugaenge === 0 && betreute === 0) { + console.log( + '\n Hinweis: Nach dem Anmelden landest du zuerst bei der\n' + + ' Ersteinrichtung - erst zweiter Faktor, dann betreute Person.', + ) + } + } + + // --- Versuche der letzten Tage ------------------------------------------- + const versuche = await db.loginVersuch.findMany({ + where: { email }, + orderBy: { zeitpunkt: 'desc' }, + take: 15, + }) + + console.log('\n══ Letzte Anmeldeversuche ' + '═'.repeat(35)) + + if (versuche.length === 0) { + console.log( + ' Keine. Es ist also NIE ein Versuch beim Server angekommen.\n\n' + + ' Das heisst: Das Formular hat gar nicht abgeschickt, oder es lief\n' + + ' auf eine andere Adresse. Pruef AUTH_URL - siehe unten.', + ) + } else { + for (const v of versuche) { + const kennzeichen = v.erfolg ? 'OK ' : 'FEHLER' + const grund = v.erfolg ? 'angemeldet' : (GRUND[v.grund] ?? v.grund ?? 'unbekannter Grund') + console.log(` ${zeit(v.zeitpunkt).padEnd(20)} ${kennzeichen} ${grund}`) + } + } + + // --- Sperre --------------------------------------------------------------- + const fenster = Number.parseInt(process.env.LOGIN_FENSTER_SEKUNDEN ?? '900', 10) + const grenze = Number.parseInt(process.env.LOGIN_MAX_VERSUCHE ?? '5', 10) + const seit = new Date(Date.now() - fenster * 1000) + + const offen = await db.loginVersuch.count({ + where: { email, erfolg: false, zeitpunkt: { gte: seit } }, + }) + + console.log('\n══ Sperre ' + '═'.repeat(51)) + console.log(` ${offen} von ${grenze} Fehlversuchen im laufenden Fenster (${fenster / 60} Minuten)`) + + if (offen >= grenze) { + console.log( + '\n GESPERRT. Warte das Fenster ab, oder setz die Zaehler zurueck:\n' + + ' docker compose exec db psql -U cockpit betreuungs_cockpit \\\n' + + ' -c "DELETE FROM login_versuch WHERE erfolg = false;"', + ) + } + + // --- Umgebung ------------------------------------------------------------- + console.log('\n══ Umgebung ' + '═'.repeat(49)) + console.log(` AUTH_URL: ${process.env.AUTH_URL ?? 'NICHT GESETZT'}`) + console.log( + '\n Diese Adresse muss EXAKT die sein, die du im Browser aufrufst -\n' + + ' gleiches Schema (https), gleicher Hostname, kein Schraegstrich am\n' + + ' Ende. Weicht sie ab, wird das Sitzungs-Cookie fuer die falsche\n' + + ' Herkunft gesetzt: Die Anmeldung landet ohne Fehlermeldung wieder\n' + + ' auf dem Formular, und oben steht kein einziger Fehlversuch.\n', + ) +} catch (fehler) { + // Ein Prisma-Stacktrace hilft hier niemandem weiter. + const meldung = fehler instanceof Error ? fehler.message : String(fehler) + + if (/Can't reach database server|ECONNREFUSED/i.test(meldung)) { + // Das Passwort in der Verbindungszeichenkette gehoert nicht auf den Schirm. + const url = (process.env.DATABASE_URL ?? 'NICHT GESETZT').replace(/:[^:@/]+@/, ':***@') + console.error( + [ + '', + 'Die Datenbank ist nicht erreichbar.', + '', + ` DATABASE_URL: ${url}`, + '', + ' Im Docker-Betrieb gehoert dieses Skript IN den Behaelter:', + ' docker compose exec app node scripts/anmeldung-pruefen.mjs ', + '', + ' Laeuft die Datenbank ueberhaupt?', + ' docker compose ps', + '', + ].join('\n'), + ) + } else { + console.error(`\nFehler: ${meldung}\n`) + } + process.exitCode = 1 +} finally { + await db.$disconnect() +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 2f7308e..52f685b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -58,14 +58,25 @@ function einmal(fn: () => T): () => T { const holeVerschluesselungsschluessel = einmal((): Map => { const schluessel = new Map() + // ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln // stehen, verschluesselt wird immer mit der aktiven Version. - for (const [name] of Object.entries(process.env)) { + for (const [name, wert] of Object.entries(process.env)) { const treffer = name.match(/^ENC_KEY_V(\d+)$/) - if (treffer) { - schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name)) - } + if (!treffer) continue + + // Eine LEERE Variable ist kein Schluessel. + // + // docker compose reicht ENC_KEY_V2 immer durch - mit leerem Wert, wenn + // in der .env keiner steht. Wuerde hier allein der Name zaehlen, waere + // jede Installation ohne zweiten Schluessel kaputt, und zwar mit der + // irrefuehrenden Meldung "ENC_KEY_V2 fehlt" - obwohl niemand einen + // zweiten Schluessel wollte. + if (!wert || wert.trim() === '') continue + + schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name)) } + if (schluessel.size === 0) { throw new Error( 'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' + @@ -78,8 +89,11 @@ const holeVerschluesselungsschluessel = einmal((): Map => { const holeAktiveVersion = einmal((): number => { const version = zahl('ENC_KEY_AKTIV', 1) if (!holeVerschluesselungsschluessel().has(version)) { + const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ') throw new Error( - `ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt.`, + `ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt oder leer. ` + + `Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` + + `neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`, ) } return version diff --git a/src/lib/version.ts b/src/lib/version.ts index c6dd525..b349249 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.0.1' +export const VERSION = '1.0.2' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/tests/umgebung.test.ts b/tests/umgebung.test.ts new file mode 100644 index 0000000..14c3c8e --- /dev/null +++ b/tests/umgebung.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Auswertung der Umgebungsvariablen. + * + * Der Anlass für diese Datei war ein Fehler im Betrieb: docker compose + * reicht `ENC_KEY_V2` immer durch — mit leerem Wert, wenn in der .env + * keiner steht. Die Schlüsselsammlung erkannte Schlüssel aber am + * Variablennamen statt am Inhalt, fand also ein leeres `ENC_KEY_V2` und + * brach ab. Die Meldung lautete „ENC_KEY_V2 fehlt", obwohl niemand einen + * zweiten Schlüssel wollte. + * + * Jede Prüfung hier lädt `@/lib/env` frisch, weil die Werte beim ersten + * Zugriff gemerkt werden. + */ + +const ECHT = Buffer.alloc(32, 7).toString('base64') +const ECHT_ZWEI = Buffer.alloc(32, 9).toString('base64') + +let vorher: NodeJS.ProcessEnv + +async function frischesEnv() { + // Leert die Modulregistrierung, damit der naechste Import das Modul neu + // auswertet - die Schluessel werden sonst nur beim ersten Mal gelesen. + vi.resetModules() + const { env } = await import('@/lib/env') + return env +} + +beforeEach(() => { + vorher = { ...process.env } + for (const name of Object.keys(process.env)) { + if (/^ENC_KEY_V\d+$/.test(name)) delete process.env[name] + } + delete process.env.ENC_KEY_AKTIV +}) + +afterEach(() => { + process.env = vorher +}) + +describe('Verschlüsselungsschlüssel aus der Umgebung', () => { + it('nimmt einen einzelnen Schlüssel an', async () => { + process.env.ENC_KEY_V1 = ECHT + + const env = await frischesEnv() + expect([...env.verschluesselungsschluessel.keys()]).toEqual([1]) + expect(env.aktiveSchluesselversion).toBe(1) + }) + + it('überspringt eine leere Schlüsselvariable', async () => { + // Genau das setzt docker compose, wenn in der .env kein zweiter + // Schlüssel steht: ENC_KEY_V2: ${ENC_KEY_V2:-} + process.env.ENC_KEY_V1 = ECHT + process.env.ENC_KEY_V2 = '' + + const env = await frischesEnv() + expect([...env.verschluesselungsschluessel.keys()]).toEqual([1]) + expect(env.aktiveSchluesselversion).toBe(1) + }) + + it('überspringt eine Variable, in der nur Leerzeichen stehen', async () => { + process.env.ENC_KEY_V1 = ECHT + process.env.ENC_KEY_V2 = ' ' + + const env = await frischesEnv() + expect([...env.verschluesselungsschluessel.keys()]).toEqual([1]) + }) + + it('nimmt beide Schlüssel an, wenn wirklich rotiert wird', async () => { + process.env.ENC_KEY_V1 = ECHT + process.env.ENC_KEY_V2 = ECHT_ZWEI + process.env.ENC_KEY_AKTIV = '2' + + const env = await frischesEnv() + expect([...env.verschluesselungsschluessel.keys()].sort()).toEqual([1, 2]) + expect(env.aktiveSchluesselversion).toBe(2) + }) + + it('sagt beim Rotieren deutlich, wenn der neue Schlüssel fehlt', async () => { + process.env.ENC_KEY_V1 = ECHT + process.env.ENC_KEY_V2 = '' + process.env.ENC_KEY_AKTIV = '2' + + const env = await frischesEnv() + expect(() => env.aktiveSchluesselversion).toThrow(/ENC_KEY_AKTIV=2/) + // Die Meldung nennt, was da ist, und die richtige Reihenfolge. + expect(() => env.aktiveSchluesselversion).toThrow(/Vorhanden sind die Versionen: 1/) + expect(() => env.aktiveSchluesselversion).toThrow(/zuerst den neuen Schluessel/) + }) + + it('beschwert sich, wenn gar kein Schlüssel gesetzt ist', async () => { + process.env.ENC_KEY_V2 = '' + + const env = await frischesEnv() + expect(() => env.verschluesselungsschluessel).toThrow(/mindestens ENC_KEY_V1/) + }) + + it('weist einen Schlüssel falscher Länge ab', async () => { + process.env.ENC_KEY_V1 = Buffer.alloc(16, 1).toString('base64') + + const env = await frischesEnv() + expect(() => env.verschluesselungsschluessel).toThrow(/32 Bytes/) + }) +})