diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d5c2955..f1e307a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -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= # --- Anmeldung --- AUTH_SECRET= 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= + +# 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= @@ -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. --- diff --git a/prisma/seed.ts b/prisma/seed.ts index b013b46..438fbbf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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.') diff --git a/scripts/schluessel-erzeugen.mjs b/scripts/schluessel-erzeugen.mjs index 043168b..9551708 100644 --- a/scripts/schluessel-erzeugen.mjs +++ b/scripts/schluessel-erzeugen.mjs @@ -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. diff --git a/src/app/(auth)/ersteinrichtung/page.tsx b/src/app/(auth)/ersteinrichtung/page.tsx index b3ab902..181b1bd 100644 --- a/src/app/(auth)/ersteinrichtung/page.tsx +++ b/src/app/(auth)/ersteinrichtung/page.tsx @@ -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 ( <> diff --git a/src/app/(auth)/ersteinrichtung/zugang-formular.tsx b/src/app/(auth)/ersteinrichtung/zugang-formular.tsx index fca2619..ad935a5 100644 --- a/src/app/(auth)/ersteinrichtung/zugang-formular.tsx +++ b/src/app/(auth)/ersteinrichtung/zugang-formular.tsx @@ -61,6 +61,23 @@ export function ZugangFormular() { /> + + + + > + 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 { - 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, })