'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 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( PASSWORT_MINDESTLAENGE, `Bitte mindestens ${PASSWORT_MINDESTLAENGE} Zeichen. Eine Folge aus vier zufälligen ` + `Wörtern ist leichter zu merken und sicherer als ein kurzes Kunstwort.`, ) .max(200, 'Höchstens 200 Zeichen.'), passwortWiederholung: z.string(), }) .refine((daten) => daten.passwort === daten.passwortWiederholung, { message: 'Die beiden Passwörter stimmen nicht überein.', path: ['passwortWiederholung'], }) export type ZugangZustand = { fehler?: 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 { 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'), }) if (!gelesen.success) { const felder = gelesen.error.flatten().fieldErrors return { fehler: 'Bitte prüfe deine Eingaben.', felder: { name: felder.name?.[0], email: felder.email?.[0], geheimnis: felder.geheimnis?.[0], passwort: felder.passwort?.[0], passwortWiederholung: felder.passwortWiederholung?.[0], }, werte: { name: String(formular.get('name') ?? ''), email: String(formular.get('email') ?? ''), }, } } const { name, email, passwort, geheimnis } = gelesen.data 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: benutzerId, bezeichnung: `${name} (erster Zugang)`, userId: benutzerId, userEmail: email, userName: name, }) // Weiter zur Anmeldung: der zweite Faktor wird direkt danach eingerichtet. redirect('/anmelden?neu=1') }