Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
6 changed files with 153 additions and 23 deletions
Showing only changes of commit 5b54d7bc5f - Show all commits
+17 -4
View File
@@ -156,6 +156,7 @@ const w = n => b(n).replace(/[+/=]/g,'x');
console.log('AUTH_SECRET=' + b(32));
console.log('ENC_KEY_V1=' + b(32));
console.log('INDEX_KEY=' + b(32));
console.log('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
console.log('POSTGRES_PASSWORD=' + w(24));
console.log('SICHERUNG_PASSWORT=' + w(24));
console.log('S3_ACCESS_KEY_ID=' + w(12));
@@ -192,7 +193,15 @@ POSTGRES_PASSWORD=<aus Schritt 2>
# --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
AUTH_TRUST_HOST=true
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen. Absicht:
# sonst steht die Einrichtungsseite bis zum ersten Anlegen fuer jeden offen.
ERSTEINRICHTUNG_GEHEIMNIS=<aus Schritt 2>
# Ein Reverse Proxy steht davor (Schritt 7). Nur damit wird der
# weitergereichte Kopf geglaubt, aus dem die Anmeldesperre den Anschluss
# liest. Ohne die Zeile zaehlt sie nur nach E-Mail-Adresse.
VERTRAUE_PROXY=true
# --- Verschlüsselung ---
ENC_KEY_V1=<aus Schritt 2>
@@ -258,12 +267,16 @@ chmod 600 .env
```bash
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ERSTEINRICHTUNG_GEHEIMNIS|VERTRAUE_PROXY|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
| sed 's/=.*/ = gesetzt/'
```
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche.
Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche. Zwei Ausnahmen: Fehlt
`ERSTEINRICHTUNG_GEHEIMNIS`, startet die Anwendung, aber der erste Zugang
lässt sich nicht anlegen. Fehlt `VERTRAUE_PROXY`, läuft alles — die
Anmeldesperre zählt dann nur nach E-Mail-Adresse und schreibt beim ersten
Mal eine Zeile ins Log.
---
+8
View File
@@ -112,6 +112,14 @@ async function ersterZugang() {
},
})
// Die Ersteinrichtung ist damit erledigt. Ohne diese Zeile stuende die
// Einrichtungsseite nach dem Seed weiter offen.
await db.ersteinrichtung.upsert({
where: { id: 'einmalig' },
update: {},
create: { id: 'einmalig', userId: benutzer.id },
})
console.log(`Zugang ${email} angelegt.`)
if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
+1
View File
@@ -18,6 +18,7 @@ AUTH_SECRET=${b64(32)}
ENC_KEY_V1=${b64(32)}
ENC_KEY_AKTIV=1
INDEX_KEY=${b64(32)}
ERSTEINRICHTUNG_GEHEIMNIS=${b64(24)}
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
+9 -1
View File
@@ -21,8 +21,16 @@ export const dynamic = 'force-dynamic'
export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// Die Sperrzeile ist die eigentliche Auskunft darueber, ob eingerichtet
// wurde. Der Benutzerzaehler allein genuegt nicht: Liegt der erste Zugang
// im Papierkorb, stuende Schritt 1 sonst wieder offen.
const sperre = await db.ersteinrichtung.findUnique({
where: { id: 'einmalig' },
select: { id: true },
})
// --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) {
if (anzahlBenutzer === 0 && !sperre) {
return (
<>
<Card>
@@ -61,6 +61,23 @@ export function ZugangFormular() {
/>
</Feld>
<Feld
id="geheimnis"
beschriftung="Einrichtungsgeheimnis"
erklaerung="Der Wert von ERSTEINRICHTUNG_GEHEIMNIS aus der .env. Er verhindert, dass jemand
anders diesen Zugang anlegt, solange die Seite noch offensteht."
fehler={zustand.felder?.geheimnis}
pflicht
>
<Input
name="geheimnis"
type="password"
autoComplete="off"
spellCheck={false}
required
/>
</Feld>
<Feld
id="passwort"
beschriftung="Passwort"
+101 -18
View File
@@ -1,25 +1,49 @@
'use server'
import { redirect } from 'next/navigation'
import { createHash, timingSafeEqual } from 'node:crypto'
import { z } from 'zod'
import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { passwortHashen } from '@/lib/crypto/hash'
import { protokollieren } from '@/lib/audit'
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
/**
* Anlegen des allerersten Zugangs.
*
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von
* aussen einen Administratorzugang anlegen.
* Diese Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
* Anmeldung etwas schreiben darf. Entsprechend eng ist sie gebaut - zwei
* Dinge sichern sie ab:
*
* ## 1. Ein Geheimnis aus der Umgebung
*
* Vorher galt: "Wenn es noch keinen Benutzer gibt, darf jeder einen
* anlegen." Zwischen dem ersten Start und dem Anlegen des Zugangs stand die
* Seite damit offen - wer sie in diesem Fenster fand, wurde Administrator
* einer Anwendung mit Gesundheits- und Finanzdaten. Das Fenster ist kurz,
* aber Zufall ist keine Zugriffskontrolle. Jetzt muss ERSTEINRICHTUNG_
* GEHEIMNIS aus der .env mit eingegeben werden.
*
* ## 2. Eine Sperre in der Datenbank
*
* Die Pruefung "gibt es schon einen Benutzer?" und das Anlegen waren zwei
* getrennte Schritte. Zwei Anfragen gleichzeitig kamen beide durch die
* Pruefung, bevor eine von ihnen schrieb - und legten beide einen
* Administrator an. Jetzt entscheidet der Primaerschluessel der Tabelle
* ersteinrichtung, und der laesst genau einen Gewinner zu.
*/
const PASSWORT_MINDESTLAENGE = 12
/** Fester Schluessel der einen Zeile, die die Sperre bildet. */
const SPERRE = 'einmalig'
const ZugangSchema = z
.object({
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'),
geheimnis: z.string().min(1, 'Bitte das Einrichtungsgeheimnis aus der .env eingeben.'),
passwort: z
.string()
.min(
@@ -37,22 +61,54 @@ const ZugangSchema = z
export type ZugangZustand = {
fehler?: string
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>>
felder?: Partial<
Record<'name' | 'email' | 'passwort' | 'passwortWiederholung' | 'geheimnis', string>
>
werte?: { name?: string; email?: string }
}
/**
* Vergleicht zwei Zeichenketten in gleichbleibender Zeit.
*
* Ueber die Laenge wird gehasht, nicht abgeschnitten: timingSafeEqual will
* gleich lange Puffer, und ein vorgeschalteter Laengenvergleich verriete
* die Laenge des Geheimnisses.
*/
function gleich(eingabe: string, erwartet: string): boolean {
const a = createHash('sha256').update(eingabe).digest()
const b = createHash('sha256').update(erwartet).digest()
return timingSafeEqual(a, b)
}
export async function erstenZugangAnlegen(
_vorher: ZugangZustand,
formular: FormData,
): Promise<ZugangZustand> {
const vorhandene = await db.user.count()
if (vorhandene > 0) {
const begonnen = Date.now()
// Ist die Einrichtung schon gelaufen, ist hier Schluss - unabhaengig
// davon, ob der Benutzer inzwischen im Papierkorb liegt.
const bereitsErledigt = await db.ersteinrichtung.findUnique({
where: { id: SPERRE },
select: { id: true },
})
if (bereitsErledigt || (await db.user.count()) > 0) {
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
}
if (env.ersteinrichtungGeheimnis === '') {
return {
fehler:
'Die Ersteinrichtung ist gesperrt, weil ERSTEINRICHTUNG_GEHEIMNIS in der .env fehlt. ' +
'Trage dort einen zufälligen Wert ein („npm run schluessel“ erzeugt passende), starte ' +
'die Anwendung neu und gib ihn hier ein.',
}
}
const gelesen = ZugangSchema.safeParse({
name: formular.get('name'),
email: formular.get('email'),
geheimnis: formular.get('geheimnis'),
passwort: formular.get('passwort'),
passwortWiederholung: formular.get('passwortWiederholung'),
})
@@ -64,6 +120,7 @@ export async function erstenZugangAnlegen(
felder: {
name: felder.name?.[0],
email: felder.email?.[0],
geheimnis: felder.geheimnis?.[0],
passwort: felder.passwort?.[0],
passwortWiederholung: felder.passwortWiederholung?.[0],
},
@@ -74,24 +131,50 @@ export async function erstenZugangAnlegen(
}
}
const { name, email, passwort } = gelesen.data
const { name, email, passwort, geheimnis } = gelesen.data
const benutzer = await db.user.create({
data: {
name,
email,
passwortHash: await passwortHashen(passwort),
// Der erste Zugang verwaltet auch die Anwendung.
rolle: 'ADMIN',
},
})
if (!gleich(geheimnis, env.ersteinrichtungGeheimnis)) {
// Dieselbe Mindestdauer wie am Anmeldeformular: Sonst verraet die
// Antwortzeit, ob das Geheimnis stimmte und erst das Anlegen scheiterte.
await aufMindestdauerWarten(begonnen)
return {
fehler: 'Das Einrichtungsgeheimnis stimmt nicht.',
felder: { geheimnis: 'Stimmt nicht mit ERSTEINRICHTUNG_GEHEIMNIS überein.' },
werte: { name, email },
}
}
// Anlegen und Sperren in einem Zug. Kommt eine zweite Anfrage gleichzeitig
// hier an, scheitert sie am Primaerschluessel der Sperrzeile - und zwar
// bevor ein zweiter Administrator entstanden ist.
let benutzerId: string
try {
const ergebnis = await db.$transaction(async (tx) => {
const benutzer = await tx.user.create({
data: {
name,
email,
passwortHash: await passwortHashen(passwort),
// Der erste Zugang verwaltet auch die Anwendung.
rolle: 'ADMIN',
},
})
await tx.ersteinrichtung.create({ data: { id: SPERRE, userId: benutzer.id } })
return benutzer
})
benutzerId = ergebnis.id
} catch {
// Der haeufigste Fall ist die verlorene Wettlauf-Anfrage. Für den, der
// davorsitzt, ist die Auskunft dieselbe wie oben.
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
}
await protokollieren({
aktion: 'ERSTELLT',
entitaet: 'User',
entitaetId: benutzer.id,
entitaetId: benutzerId,
bezeichnung: `${name} (erster Zugang)`,
userId: benutzer.id,
userId: benutzerId,
userEmail: email,
userName: name,
})