Ersteinrichtung schliessen: Geheimnis und eine Sperre statt zweier Schritte
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne Anmeldung schreiben darf. Zwei Dinge fehlten. Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des Zugangs fuer jeden 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; verglichen wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert antwortet mit derselben Mindestdauer wie das Anmeldeformular. Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine Meldung zeigen und nicht einrichtbar sein. Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein zweiter Administrator entsteht. Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb, stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls. Refs #38
This commit is contained in:
+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