From 01d42662caf01cd3fe70901e25df2288f8e8018f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 21:30:12 +0200 Subject: [PATCH] feat(db): Datenmodell fuer Benutzer, Sitzungen und Passkeys Neue Tabellen: - User Konto mit Rolle, optionalem Passwort-Hash, Sperrzaehler. Das Passwort ist optional, weil ein Konto auch ausschliesslich per Passkey nutzbar sein darf. - Session serverseitige Sitzung. Gespeichert wird nur der SHA-256-Hash des Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt. - Credential ein Passkey je Zeile, mit oeffentlichem Schluessel, Transporten und Signaturzaehler. - WebAuthnChallenge kurzlebig und einmalig verwendbar, deshalb in der Datenbank statt im Cookie. - AppSettings Betriebsart und Registrierung, genau eine Zeile. Project und GitConnection bekommen einen Besitzer. Bewusst nullable: so ueberstehen Bestandsdaten die Migration und werden beim Anlegen des ersten Kontos uebernommen. Slug und Verbindungsname sind dadurch nur noch je Besitzer eindeutig - zwei Personen duerfen beide ein Projekt "dashboard" haben. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 143 ++++++++++++++++++ prisma/schema.prisma | 126 ++++++++++++++- prisma/seed.ts | 7 +- 3 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260725190000_auth_users_passkeys/migration.sql diff --git a/prisma/migrations/20260725190000_auth_users_passkeys/migration.sql b/prisma/migrations/20260725190000_auth_users_passkeys/migration.sql new file mode 100644 index 0000000..e282a2e --- /dev/null +++ b/prisma/migrations/20260725190000_auth_users_passkeys/migration.sql @@ -0,0 +1,143 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER'); + +-- CreateEnum +CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER'); + +-- DropIndex +DROP INDEX "GitConnection_name_key"; + +-- DropIndex +DROP INDEX "Project_slug_key"; + +-- AlterTable +ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT; + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "displayName" TEXT, + "email" TEXT, + "passwordHash" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastLoginAt" TIMESTAMP(3), + "failedLoginCount" INTEGER NOT NULL DEFAULT 0, + "lockedUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userAgent" TEXT, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Credential" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "publicKey" BYTEA NOT NULL, + "counter" INTEGER NOT NULL DEFAULT 0, + "transports" TEXT[] DEFAULT ARRAY[]::TEXT[], + "deviceType" TEXT, + "backedUp" BOOLEAN NOT NULL DEFAULT false, + "name" TEXT, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Credential_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebAuthnChallenge" ( + "id" TEXT NOT NULL, + "challenge" TEXT NOT NULL, + "userId" TEXT, + "purpose" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AppSettings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER', + "allowRegistration" BOOLEAN NOT NULL DEFAULT false, + "rpName" TEXT NOT NULL DEFAULT 'Nebula', + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "User_role_idx" ON "User"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId"); + +-- CreateIndex +CREATE INDEX "Credential_userId_idx" ON "Credential"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge"); + +-- CreateIndex +CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt"); + +-- CreateIndex +CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name"); + +-- CreateIndex +CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0e2c50d..5a97205 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,113 @@ enum GitProvider { GITEA } +/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat +/// auch ein Admin keinen Zugriff โ€“ die Trennung ist bewusst hart. +enum UserRole { + ADMIN + USER +} + +/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste. +/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten. +enum AppMode { + SINGLE_USER + MULTI_USER +} + +/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch +/// ausschliesslich per Passkey nutzbar sein darf. +model User { + id String @id @default(cuid()) + /// Immer kleingeschrieben gespeichert, damit die Anmeldung + /// gross-/kleinschreibungsunabhaengig funktioniert. + username String @unique + displayName String? + email String? @unique + /// scrypt-Hash im Format scrypt$N$r$p$$; null = nur Passkey. + passwordHash String? + role UserRole @default(USER) + isActive Boolean @default(true) + + lastLoginAt DateTime? + /// Zaehler und Sperre gegen Passwortraten. + failedLoginCount Int @default(0) + lockedUntil DateTime? + + sessions Session[] + credentials Credential[] + projects Project[] + connections GitConnection[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([role]) +} + +/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des +/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt. +model Session { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique + expiresAt DateTime + lastSeenAt DateTime @default(now()) + userAgent String? + ip String? + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([expiresAt]) +} + +/// Ein registrierter Passkey (WebAuthn-Credential). +model Credential { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// base64url-kodierte Credential-ID des Authenticators. + credentialId String @unique + publicKey Bytes + counter Int @default(0) + transports String[] @default([]) + deviceType String? + backedUp Boolean @default(false) + /// Frei waehlbarer Name, z. B. "MacBook Touch ID". + name String? + lastUsedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) +} + +/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie +/// kein zweites Mal verwendet werden kann. +model WebAuthnChallenge { + id String @id @default(cuid()) + challenge String @unique + /// null bei der Anmeldung ueber auffindbare Passkeys. + userId String? + /// "registration" oder "authentication" + purpose String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([expiresAt]) +} + +/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton". +model AppSettings { + id String @id @default("singleton") + mode AppMode @default(SINGLE_USER) + /// Nur im Mehrbenutzerbetrieb wirksam. + allowRegistration Boolean @default(false) + /// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys. + rpName String @default("Nebula") + updatedAt DateTime @updatedAt +} + /// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token. model GitConnection { id String @id @default(cuid()) @@ -53,20 +160,28 @@ model GitConnection { lastCheckOk Boolean? lastCheckMessage String? + /// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der + /// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten + /// Kontos werden sie diesem zugeordnet. + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + projects Project[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([name]) + @@unique([ownerId, name]) @@index([provider]) + @@index([ownerId]) } /// Ein Vibe-Coding-Projekt โ€“ von der reinen Idee bis zum fertigen Repo. model Project { id String @id @default(cuid()) name String - slug String @unique + /// Pro Besitzer eindeutig โ€“ siehe @@unique unten. + slug String /// Einzeiler fuer die Karte im Dashboard. summary String? /// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich). @@ -93,15 +208,22 @@ model Project { lastSyncedAt DateTime? syncError String? + /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim + /// Einrichten des ersten Kontos uebernommen werden koennen. + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + notes Note[] snapshot RepoSnapshot? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + @@unique([ownerId, slug]) @@index([status]) @@index([connectionId]) @@index([updatedAt]) + @@index([ownerId]) } /// Freie Notizen zu einem Projekt. diff --git a/prisma/seed.ts b/prisma/seed.ts index 0ec60bd..f3e802f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -86,9 +86,12 @@ async function main() { }, ]; + // Der Slug ist seit der Benutzerverwaltung nur je Besitzer eindeutig. + // Die Seed-Projekte entstehen ohne Besitzer und werden beim Einrichten + // des ersten Kontos uebernommen. for (const sample of samples) { - const existing = await prisma.project.findUnique({ - where: { slug: sample.slug }, + const existing = await prisma.project.findFirst({ + where: { slug: sample.slug, ownerId: null }, }); if (existing) { console.log(` ยท uebersprungen (existiert): ${sample.slug}`);