Files
betreuungs-cockpit/src/lib/env.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

221 lines
7.1 KiB
TypeScript

import 'server-only'
/**
* Zugriff auf die Umgebungsvariablen - mit Pruefung, aber bewusst faul.
*
* Warum faul? `next build` importiert Servermodule, ohne dass eine .env mit
* echten Schluesseln vorliegen muss (z.B. im Docker-Build). Wuerden wir beim
* Import validieren, schluege jeder Build ohne Produktivschluessel fehl.
* Stattdessen prueft jeder Zugriff beim ersten Mal und merkt sich das
* Ergebnis. Fehlt etwas, kommt eine Meldung, die sagt, was zu tun ist.
*/
function pflicht(name: string): string {
const wert = process.env[name]
if (!wert || wert.trim() === '') {
throw new Error(
`Umgebungsvariable ${name} fehlt. Lege sie in der .env an - ` +
`mit "npm run schluessel" erzeugst du passende Werte.`,
)
}
return wert
}
function zahl(name: string, standard: number): number {
const wert = process.env[name]
if (!wert) return standard
const n = Number.parseInt(wert, 10)
if (Number.isNaN(n)) {
throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein, ist aber "${wert}".`)
}
return n
}
function schluessel32(name: string): Buffer {
const roh = pflicht(name)
const buf = Buffer.from(roh, 'base64')
if (buf.length !== 32) {
throw new Error(
`Umgebungsvariable ${name} muss 32 Bytes base64-kodiert sein ` +
`(ist ${buf.length} Bytes). Erzeuge sie mit "npm run schluessel".`,
)
}
return buf
}
/** Merkt sich das Ergebnis, damit nicht bei jedem Zugriff neu geparst wird. */
function einmal<T>(fn: () => T): () => T {
let wert: T
let geladen = false
return () => {
if (!geladen) {
wert = fn()
geladen = true
}
return wert
}
}
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const schluessel = new Map<number, Buffer>()
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
// stehen, verschluesselt wird immer mit der aktiven Version.
for (const [name, wert] of Object.entries(process.env)) {
const treffer = name.match(/^ENC_KEY_V(\d+)$/)
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 ' +
'gesetzt sein. Erzeuge ihn mit "npm run schluessel".',
)
}
return schluessel
})
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 oder leer. ` +
`Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` +
`neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`,
)
}
return version
})
const holeIndexSchluessel = einmal(() => schluessel32('INDEX_KEY'))
export const env = {
/** Alle bekannten Verschluesselungsschluessel nach Version. */
get verschluesselungsschluessel() {
return holeVerschluesselungsschluessel()
},
/** Version, mit der neu verschluesselt wird. */
get aktiveSchluesselversion() {
return holeAktiveVersion()
},
/** Separater HMAC-Schluessel fuer Blindindizes. */
get indexSchluessel() {
return holeIndexSchluessel()
},
get datenbankUrl() {
return pflicht('DATABASE_URL')
},
// --- Sitzung und Anmeldung ------------------------------------------------
get sitzungMaxAlterSekunden() {
return zahl('SESSION_MAX_AGE', 30 * 60)
},
get reauthMaxAlterSekunden() {
return zahl('REAUTH_MAX_AGE', 5 * 60)
},
get loginMaxVersuche() {
return zahl('LOGIN_MAX_VERSUCHE', 5)
},
get loginFensterSekunden() {
return zahl('LOGIN_FENSTER_SEKUNDEN', 15 * 60)
},
get loginSperreSekunden() {
return zahl('LOGIN_SPERRE_SEKUNDEN', 15 * 60)
},
// --- Objektspeicher -------------------------------------------------------
get speicherTreiber(): 'datei' | 's3' {
const wert = process.env.SPEICHER_TREIBER ?? (process.env.S3_ACCESS_KEY_ID ? 's3' : 'datei')
return wert === 's3' ? 's3' : 'datei'
},
get dateiSpeicherPfad() {
return process.env.DATEI_SPEICHER_PFAD ?? './daten/dokumente'
},
get s3() {
return {
endpoint: pflicht('S3_ENDPOINT'),
region: process.env.S3_REGION ?? 'eu-central',
bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
}
},
// --- OCR ------------------------------------------------------------------
get tesseractBefehl() {
return process.env.TESSERACT_BEFEHL ?? 'tesseract'
},
get ocrAktiv() {
return process.env.OCR_AKTIV !== 'false'
},
// --- Uploads --------------------------------------------------------------
get uploadMaxBytes() {
return zahl('UPLOAD_MAX_BYTES', 25 * 1024 * 1024)
},
// --- E-Mail ---------------------------------------------------------------
get smtpKonfiguriert() {
return Boolean(process.env.SMTP_HOST)
},
get smtp() {
return {
host: pflicht('SMTP_HOST'),
port: zahl('SMTP_PORT', 587),
user: process.env.SMTP_USER,
passwort: process.env.SMTP_PASSWORD,
absender: process.env.SMTP_FROM ?? 'Betreuungs-Cockpit <noreply@localhost>',
}
},
get basisUrl() {
return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
},
/**
* Herkunft und Bereichskennung fuer Passkeys (WebAuthn).
*
* Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert -
* eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen
* zu lassen.
*
* Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie
* bindet der Authenticator den Schluessel: Ein Passkey fuer
* cockpit.example.de funktioniert auf keiner anderen Adresse - genau das
* macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain
* wechselt, muss alle Passkeys neu einrichten.
*
* Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei -
* also unter https oder auf localhost. Hinter einem Proxy, der auf http
* weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse
* nennen; verglichen wird gegen das, was im Browser in der Adresszeile
* stand.
*/
get passkey() {
const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000')
return {
rpId: url.hostname,
rpName: 'Betreuungs-Cockpit',
herkunft: url.origin,
sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost',
}
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},
} as const