From 6bd8f7e8a4ddb2f37ebc4616cf0c771677fffca1 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 24 Aug 2026 08:03:32 +0200 Subject: [PATCH] Schema: Zeitschritt fuer TOTP und Sperre fuer die Ersteinrichtung Zwei Felder fuer zwei Luecken, in einer Migration: benutzer.totpLetzterSchritt haelt das zuletzt akzeptierte TOTP-Fenster fest. Ohne das gilt ein Code bis zu 90 Sekunden lang beliebig oft. Die Tabelle ersteinrichtung hat genau eine Zeile mit fester Id. Das ist kein Protokoll, sondern eine Sperre: Bisher entschied "gibt es schon einen Benutzer?" und danach wurde angelegt - zwei Anfragen gleichzeitig kamen beide durch. Jetzt entscheidet der Primaerschluessel. Enthalten ist eine Datenmigration: Wo schon ein Zugang besteht, wird die Sperrzeile gesetzt. Ohne sie stuende die Einrichtungsseite auf jedem bestehenden System nach dem Update wieder offen. Die DROP-INDEX-Zeilen zu den Trigramm-Indizes hat das Skript aus #36 wieder entfernt; "npm run db:pruefen" bestaetigt, dass beide stehen. Refs #38 --- .../migration.sql | 31 +++++++++++++++++++ prisma/schema.prisma | 26 ++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 prisma/migrations/20260824074643_sicherheitsaudit/migration.sql diff --git a/prisma/migrations/20260824074643_sicherheitsaudit/migration.sql b/prisma/migrations/20260824074643_sicherheitsaudit/migration.sql new file mode 100644 index 0000000..88826c6 --- /dev/null +++ b/prisma/migrations/20260824074643_sicherheitsaudit/migration.sql @@ -0,0 +1,31 @@ +-- sicherheitsaudit +-- +-- Erzeugt mit "npm run db:migration". Entfernt wurden die +-- DROP-INDEX-Anweisungen zu diesen Indizes: +-- dokument_absender_trgm +-- vorgang_titel_trgm +-- +-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt; +-- ohne sie waere die Volltextsuche still langsam geworden. + +-- AlterTable +ALTER TABLE "benutzer" ADD COLUMN "totpLetzterSchritt" INTEGER; + +-- CreateTable +CREATE TABLE "ersteinrichtung" ( + "id" TEXT NOT NULL, + "abgeschlossenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT, + + CONSTRAINT "ersteinrichtung_pkey" PRIMARY KEY ("id") +); + +-- Bestehende Installationen nachziehen. +-- +-- Die Tabelle oben ist die Sperre fuer die Ersteinrichtung. Auf einem +-- System, das schon laeuft, ist die Einrichtung laengst erledigt - ohne +-- diese Zeile stuende die Sperre nach dem Update offen, und der Weg zum +-- Anlegen eines Administrators waere wieder frei. +INSERT INTO "ersteinrichtung" ("id", "userId") +SELECT 'einmalig', (SELECT "id" FROM "benutzer" ORDER BY "erstelltAm" ASC LIMIT 1) +WHERE EXISTS (SELECT 1 FROM "benutzer"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8dfb5c..13577ae 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,13 @@ model User { // TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext. totpSecretVerschluesselt String? totpAktiviertAm DateTime? + /// Der zuletzt akzeptierte TOTP-Zeitschritt (Unixzeit / 30). + /// + /// Ein TOTP-Code gilt 30 Sekunden, mit Toleranz bis zu 90. In dieser Zeit + /// liesse sich derselbe Code ein zweites Mal einloesen - abgelesen ueber + /// die Schulter oder abgefangen. Deshalb wird der Schritt festgehalten + /// und nur noch ein groesserer akzeptiert. + totpLetzterSchritt Int? // Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht. wiederherstellungscodes String[] @default([]) @@ -128,6 +135,25 @@ model BetreuungsZugang { @@map("betreuungs_zugang") } +/// Haelt fest, dass die Ersteinrichtung stattgefunden hat. +/// +/// Die Tabelle hat genau eine Zeile mit fester Id. Das ist kein Protokoll, +/// sondern eine Sperre: Vorher stand im Code "wenn es noch keinen Benutzer +/// gibt, lege einen an" - zwei Anfragen gleichzeitig kamen beide durch die +/// Pruefung und legten beide einen Administrator an. Ueber den +/// Primaerschluessel entscheidet das jetzt die Datenbank, und zwar fuer +/// genau einen Gewinner. +model Ersteinrichtung { + /// Immer "einmalig". Der feste Wert ist die Sperre. + id String @id + + abgeschlossenAm DateTime @default(now()) + /// Wer den Zugang angelegt hat - nur zum Nachvollziehen. + userId String? + + @@map("ersteinrichtung") +} + /// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das /// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt. model LoginVersuch {