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('AUTH_SECRET=' + b(32));
|
||||||
console.log('ENC_KEY_V1=' + b(32));
|
console.log('ENC_KEY_V1=' + b(32));
|
||||||
console.log('INDEX_KEY=' + b(32));
|
console.log('INDEX_KEY=' + b(32));
|
||||||
|
console.log('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
|
||||||
console.log('POSTGRES_PASSWORD=' + w(24));
|
console.log('POSTGRES_PASSWORD=' + w(24));
|
||||||
console.log('SICHERUNG_PASSWORT=' + w(24));
|
console.log('SICHERUNG_PASSWORT=' + w(24));
|
||||||
console.log('S3_ACCESS_KEY_ID=' + w(12));
|
console.log('S3_ACCESS_KEY_ID=' + w(12));
|
||||||
@@ -192,7 +193,15 @@ POSTGRES_PASSWORD=<aus Schritt 2>
|
|||||||
# --- Anmeldung ---
|
# --- Anmeldung ---
|
||||||
AUTH_SECRET=<aus Schritt 2>
|
AUTH_SECRET=<aus Schritt 2>
|
||||||
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
|
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 ---
|
# --- Verschlüsselung ---
|
||||||
ENC_KEY_V1=<aus Schritt 2>
|
ENC_KEY_V1=<aus Schritt 2>
|
||||||
@@ -258,12 +267,16 @@ chmod 600 .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
|
# 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/'
|
| sed 's/=.*/ = gesetzt/'
|
||||||
```
|
```
|
||||||
|
|
||||||
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
|
Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
|
||||||
und sagt dir beim Start auch, welche.
|
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.`)
|
console.log(`Zugang ${email} angelegt.`)
|
||||||
if (devSecret) {
|
if (devSecret) {
|
||||||
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
|
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ AUTH_SECRET=${b64(32)}
|
|||||||
ENC_KEY_V1=${b64(32)}
|
ENC_KEY_V1=${b64(32)}
|
||||||
ENC_KEY_AKTIV=1
|
ENC_KEY_AKTIV=1
|
||||||
INDEX_KEY=${b64(32)}
|
INDEX_KEY=${b64(32)}
|
||||||
|
ERSTEINRICHTUNG_GEHEIMNIS=${b64(24)}
|
||||||
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
|
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
|
||||||
|
|
||||||
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
|
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
|
||||||
|
|||||||
@@ -21,8 +21,16 @@ export const dynamic = 'force-dynamic'
|
|||||||
export default async function ErsteinrichtungSeite() {
|
export default async function ErsteinrichtungSeite() {
|
||||||
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
|
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 -------------------------------
|
// --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
|
||||||
if (anzahlBenutzer === 0) {
|
if (anzahlBenutzer === 0 && !sperre) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ export function ZugangFormular() {
|
|||||||
/>
|
/>
|
||||||
</Feld>
|
</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
|
<Feld
|
||||||
id="passwort"
|
id="passwort"
|
||||||
beschriftung="Passwort"
|
beschriftung="Passwort"
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
import { env } from '@/lib/env'
|
||||||
import { passwortHashen } from '@/lib/crypto/hash'
|
import { passwortHashen } from '@/lib/crypto/hash'
|
||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
|
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anlegen des allerersten Zugangs.
|
* Anlegen des allerersten Zugangs.
|
||||||
*
|
*
|
||||||
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer
|
* Diese Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
|
||||||
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von
|
* Anmeldung etwas schreiben darf. Entsprechend eng ist sie gebaut - zwei
|
||||||
* aussen einen Administratorzugang anlegen.
|
* 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
|
const PASSWORT_MINDESTLAENGE = 12
|
||||||
|
|
||||||
|
/** Fester Schluessel der einen Zeile, die die Sperre bildet. */
|
||||||
|
const SPERRE = 'einmalig'
|
||||||
|
|
||||||
const ZugangSchema = z
|
const ZugangSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
|
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.'),
|
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
|
passwort: z
|
||||||
.string()
|
.string()
|
||||||
.min(
|
.min(
|
||||||
@@ -37,22 +61,54 @@ const ZugangSchema = z
|
|||||||
|
|
||||||
export type ZugangZustand = {
|
export type ZugangZustand = {
|
||||||
fehler?: string
|
fehler?: string
|
||||||
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>>
|
felder?: Partial<
|
||||||
|
Record<'name' | 'email' | 'passwort' | 'passwortWiederholung' | 'geheimnis', string>
|
||||||
|
>
|
||||||
werte?: { name?: string; email?: 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(
|
export async function erstenZugangAnlegen(
|
||||||
_vorher: ZugangZustand,
|
_vorher: ZugangZustand,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
): Promise<ZugangZustand> {
|
): Promise<ZugangZustand> {
|
||||||
const vorhandene = await db.user.count()
|
const begonnen = Date.now()
|
||||||
if (vorhandene > 0) {
|
|
||||||
|
// 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.' }
|
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({
|
const gelesen = ZugangSchema.safeParse({
|
||||||
name: formular.get('name'),
|
name: formular.get('name'),
|
||||||
email: formular.get('email'),
|
email: formular.get('email'),
|
||||||
|
geheimnis: formular.get('geheimnis'),
|
||||||
passwort: formular.get('passwort'),
|
passwort: formular.get('passwort'),
|
||||||
passwortWiederholung: formular.get('passwortWiederholung'),
|
passwortWiederholung: formular.get('passwortWiederholung'),
|
||||||
})
|
})
|
||||||
@@ -64,6 +120,7 @@ export async function erstenZugangAnlegen(
|
|||||||
felder: {
|
felder: {
|
||||||
name: felder.name?.[0],
|
name: felder.name?.[0],
|
||||||
email: felder.email?.[0],
|
email: felder.email?.[0],
|
||||||
|
geheimnis: felder.geheimnis?.[0],
|
||||||
passwort: felder.passwort?.[0],
|
passwort: felder.passwort?.[0],
|
||||||
passwortWiederholung: felder.passwortWiederholung?.[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: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
@@ -85,13 +159,22 @@ export async function erstenZugangAnlegen(
|
|||||||
rolle: 'ADMIN',
|
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({
|
await protokollieren({
|
||||||
aktion: 'ERSTELLT',
|
aktion: 'ERSTELLT',
|
||||||
entitaet: 'User',
|
entitaet: 'User',
|
||||||
entitaetId: benutzer.id,
|
entitaetId: benutzerId,
|
||||||
bezeichnung: `${name} (erster Zugang)`,
|
bezeichnung: `${name} (erster Zugang)`,
|
||||||
userId: benutzer.id,
|
userId: benutzerId,
|
||||||
userEmail: email,
|
userEmail: email,
|
||||||
userName: name,
|
userName: name,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user