Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
+17
-4
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,9 +131,26 @@ export async function erstenZugangAnlegen(
|
||||
}
|
||||
}
|
||||
|
||||
const { name, email, passwort } = gelesen.data
|
||||
const { name, email, passwort, geheimnis } = gelesen.data
|
||||
|
||||
const benutzer = await db.user.create({
|
||||
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,
|
||||
@@ -85,13 +159,22 @@ export async function erstenZugangAnlegen(
|
||||
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user