Files
betreuungs-cockpit/src/lib/env.ts
T
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00

292 lines
9.8 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
}
/**
* Ein Ja/Nein-Schalter. Bewusst nur "true"/"1"/"ja" als Ja - alles andere,
* auch ein Tippfehler, gilt als Nein. Bei einem Sicherheitsschalter ist das
* die richtige Richtung: Wer sich verschreibt, bekommt die strengere
* Einstellung, nicht die laxere.
*/
function schalter(name: string, standard = false): boolean {
const wert = process.env[name]?.trim().toLowerCase()
if (wert === undefined || wert === '') return standard
return wert === 'true' || wert === '1' || wert === 'ja'
}
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',
}
},
/**
* Darf der Host-Kopf der Anfrage die oeffentliche Adresse bestimmen?
*
* Standard: nur dann, wenn AUTH_URL nicht gesetzt ist. Ist die Adresse
* konfiguriert - der dokumentierte Betrieb setzt sie, Passkeys brauchen
* sie ohnehin -, gibt es keinen Grund, sie aus einer Kopfzeile von aussen
* abzuleiten. Vorher stand in auth.ts fest "true", und ein
* "X-Forwarded-Host: fremde.example" haette in erzeugte Adressen
* durchgeschlagen.
*
* AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
* nicht stillschweigend uebergangen wird.
*/
get vertraueHost() {
return schalter('AUTH_TRUST_HOST', !process.env.AUTH_URL)
},
/**
* Geheimnis fuer die Ersteinrichtung.
*
* Zwischen dem ersten Start und dem Anlegen des Zugangs steht die Seite
* offen: Wer sie in diesem Fenster findet, legt sich selbst einen
* Administratorzugang an. Das Fenster ist kurz, aber es ist da - und bei
* einer Anwendung mit Gesundheits- und Finanzdaten ist "kurz" kein
* Argument.
*
* Ohne diesen Wert ist die Ersteinrichtung gesperrt. Das ist Absicht:
* Eine Installation, bei der das Feld vergessen wurde, soll nicht
* einrichtbar sein, sondern eine Meldung zeigen.
*/
get ersteinrichtungGeheimnis() {
return process.env.ERSTEINRICHTUNG_GEHEIMNIS?.trim() ?? ''
},
/**
* Steht ein Reverse Proxy davor, dem die weitergereichten Koepfe zu
* glauben sind?
*
* Standard ist nein. Das ist die unbequeme, aber richtige Voreinstellung:
* Ohne Proxy ist "x-forwarded-for" frei erfunden, und eine Sperre, die
* darauf zaehlt, laesst sich mit einer Kopfzeile aushebeln. Wer einen
* Proxy davorstellt - der dokumentierte Betrieb tut das -, sagt es hier
* ausdruecklich. Siehe src/lib/auth/anschluss.ts.
*/
get vertraueProxy() {
return schalter('VERTRAUE_PROXY', false)
},
/**
* Wie viele Zwischenknoten vertraut werden. Bei einem Proxy: 1.
*
* Die Zahl sagt, wie weit von hinten in der Kette der Aufrufer steht.
* Zu hoch gesetzt liest man wieder fremdbestimmte Eintraege, deshalb
* bleibt es bei eins, solange nichts anderes dasteht.
*/
get vertraueProxyStufen() {
return Math.max(1, zahl('VERTRAUE_PROXY_STUFEN', 1))
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},
} as const