From c56cfb30ada12f7ab0801c2bfbb540925573d80c Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Mon, 28 Sep 2026 19:51:47 +0200 Subject: [PATCH 1/6] Add login-required user management (admin/user roles, Config->Benutzer) No self-signup: admins create every account in Config -> Benutzer with a typed start password; a forced password change gates first use. Session- cookie auth protects all /api/* routes; /api/users additionally requires the admin role. See docs/authentication.md. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 11 + compose.dev.yaml | 6 +- compose.yaml | 11 +- docs/README.md | 2 + docs/authentication.md | 61 + src/app/config/benutzer/page.tsx | 325 ++ src/app/config/konto/page.tsx | 103 + src/app/config/layout.tsx | 51 + src/app/config/page.tsx | 5 + src/app/globals.css | 113 + src/app/layout.tsx | 8 +- src/db/migrations/0012_lively_turbo.sql | 13 + src/db/migrations/meta/0012_snapshot.json | 3017 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/repositories/user.repository.ts | 87 + src/db/schema/users.ts | 27 + src/domain/services/user.service.ts | 104 + src/frontend/components/AuthProvider.tsx | 61 + src/frontend/components/LoginGate.tsx | 179 + src/frontend/components/Sidebar.tsx | 25 + src/frontend/utils/auth-api.ts | 86 + src/server/auth/auth-config.ts | 44 + src/server/auth/auth.middleware.ts | 111 + src/server/auth/cookie.ts | 15 + src/server/auth/password.ts | 63 + src/server/auth/sessions.ts | 55 + .../composition/application-repositories.ts | 2 + src/server/composition/auth.ts | 8 + src/server/controllers/auth.controller.ts | 88 + src/server/controllers/user.controller.ts | 103 + src/server/index.ts | 88 +- src/server/routes/auth.routes.ts | 16 + src/server/routes/user.routes.ts | 16 + src/shared/constants/user-roles.ts | 7 + src/shared/validation/auth.schemas.ts | 15 + src/shared/validation/user.schemas.ts | 33 + 36 files changed, 4928 insertions(+), 38 deletions(-) create mode 100644 docs/authentication.md create mode 100644 src/app/config/benutzer/page.tsx create mode 100644 src/app/config/konto/page.tsx create mode 100644 src/app/config/layout.tsx create mode 100644 src/app/config/page.tsx create mode 100644 src/db/migrations/0012_lively_turbo.sql create mode 100644 src/db/migrations/meta/0012_snapshot.json create mode 100644 src/db/repositories/user.repository.ts create mode 100644 src/db/schema/users.ts create mode 100644 src/domain/services/user.service.ts create mode 100644 src/frontend/components/AuthProvider.tsx create mode 100644 src/frontend/components/LoginGate.tsx create mode 100644 src/frontend/utils/auth-api.ts create mode 100644 src/server/auth/auth-config.ts create mode 100644 src/server/auth/auth.middleware.ts create mode 100644 src/server/auth/cookie.ts create mode 100644 src/server/auth/password.ts create mode 100644 src/server/auth/sessions.ts create mode 100644 src/server/composition/auth.ts create mode 100644 src/server/controllers/auth.controller.ts create mode 100644 src/server/controllers/user.controller.ts create mode 100644 src/server/routes/auth.routes.ts create mode 100644 src/server/routes/user.routes.ts create mode 100644 src/shared/constants/user-roles.ts create mode 100644 src/shared/validation/auth.schemas.ts create mode 100644 src/shared/validation/user.schemas.ts diff --git a/AGENTS.md b/AGENTS.md index 8312d55..5c70f07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,13 @@ It must support circuits, device rows, project devices, drag-and-drop restructur - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard repositories expose only active reads. Runtime writes belong in typed command repositories; direct integration fixtures belong under `tests/support`. +- Authentication: session-cookie login with two fixed roles (`admin`, `user`), + no self-signup. `src/server/auth/*` (password hashing, sessions, Express + middleware), `src/domain/services/user.service.ts`, + `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a + signed-in user (`src/server/index.ts`); `/api/users/*` additionally + requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` + (`src/app/layout.tsx`). See `docs/authentication.md` for the full picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. @@ -704,6 +711,10 @@ for the early-phase power balance by DIN 276 cost group (see - logical feed link between distribution boards (`fedFrom` stays free text until the project owner decides whether it references a board, a feeding circuit or both) and the resulting hierarchical power summation +- configurable role/rights matrix instead of the fixed `admin`/`user` roles +- account lockout after failed login attempts +- email/one-time-link password provisioning, OIDC/Entra login +- linking user accounts to a personnel/employee master table Do not implement these while working on an unrelated phase. diff --git a/compose.dev.yaml b/compose.dev.yaml index b9c64b4..2c362bc 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -35,8 +35,10 @@ services: PORT: "3000" CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-debug}" - # Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf - # nicht aus dem LAN erreichbar sein. + AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" + AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" + AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. ports: - "127.0.0.1:3000:3000" # Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als diff --git a/compose.yaml b/compose.yaml index 3438f90..bab8c3d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,8 +45,15 @@ services: NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - # Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung - # und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. + # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die + # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte + # .env - siehe docs/AUTH.md. + AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" + AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" + AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + COOKIE_SECURE: "${COOKIE_SECURE:-false}" + # Nicht auf dem Host veroeffentlicht: die API wird ueber den + # Next.js-Rewrite im Web-Dienst erreicht. expose: - "3000" # Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit diff --git a/docs/README.md b/docs/README.md index 01b5588..3569b42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we bewusst noch nicht implementierte oder begrenzte Funktionen - [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul, Rechenweg, Datenbasis und Prüfnachweis der Tabellen +- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim + Anlegen eines Zugangs, Sessions, Bootstrap-Admin ## Entwicklung und Betrieb diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..3d197cf --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,61 @@ +# Anmeldung und Benutzerverwaltung + +Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder +Zugang wird von einem Administrator über **Config → Benutzer** angelegt. + +## Rollen + +Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): + +- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich + (Benutzerverwaltung, eigenes Konto) +- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, + Grobbilanz, …), kein Zugriff auf Config + +Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine +Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. + +## Ablauf beim Anlegen eines Zugangs + +1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit + Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst + eintippt. +2. Der Zugang startet mit `mustChangePassword = true`. +3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das + ein eigenes Passwort verlangt, bevor es weitergeht. +4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues + Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn + jemand sein Passwort vergessen hat. + +## Technischer Aufbau + +- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) – + `username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`, + `role`, `active`, `mustChangePassword`. +- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie + `lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein + Neustart der API beendet alle Sessions – für einen Single-Container-Dienst + ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle. +- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*` + (außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und + erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist. + `/api/users/*` verlangt zusätzlich die Rolle `admin`. +- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate` + (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht + angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. + +## Bootstrap-Admin + +`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe +`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die +`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen +ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte +gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht +in `compose.yaml` selbst. + +## Bewusst nicht umgesetzt (Stand jetzt) + +- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen +- Konten-Sperre nach Fehlversuchen +- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung +- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle diff --git a/src/app/config/benutzer/page.tsx b/src/app/config/benutzer/page.tsx new file mode 100644 index 0000000..134890d --- /dev/null +++ b/src/app/config/benutzer/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api"; +import { useAuth } from "../../../frontend/components/AuthProvider"; +import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; + +const zeitpunkt = new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "short", +}); + +export default function BenutzerPage() { + const { user: currentUser } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyUserId, setBusyUserId] = useState(null); + const [resetPasswordFor, setResetPasswordFor] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const rows = await userApi.list(); + setUsers(rows); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function withBusy(userId: string, action: () => Promise) { + setBusyUserId(userId); + setError(null); + try { + await action(); + } catch (err) { + setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen."); + } finally { + setBusyUserId(null); + } + } + + async function changeRole(item: UserDto, role: UserRole) { + await withBusy(item.id, async () => { + const updated = await userApi.update(item.id, { role }); + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + }); + } + + async function toggleActive(item: UserDto) { + await withBusy(item.id, async () => { + const updated = await userApi.update(item.id, { active: !item.active }); + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + }); + } + + return ( +
+
+

+ Benutzer +

+
+

+ Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem + Administrator angelegt. Das Startpasswort wird direkt eingetragen; die + Person muss es beim ersten Anmelden durch ein eigenes ersetzen. +

+ + { + setUsers((list) => [...list, created]); + }} + /> + + {error &&

{error}

} + {loading &&

Lädt…

} + + {!loading && users.length > 0 && ( + + + + + + + + + + + + + + {users.map((item) => { + const isSelf = item.id === currentUser?.id; + const busy = busyUserId === item.id; + return ( + + + + + + + + + + ); + })} + +
NameAnmeldenameRolleAktivStatusAngelegtAktionen
{item.name}{item.username} + + + void toggleActive(item)} + /> + {item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}{zeitpunkt.format(new Date(item.createdAt))} + +
+ )} + + {!loading && users.length === 0 &&

Noch keine Benutzer angelegt.

} + + {resetPasswordFor && ( + setResetPasswordFor(null)} + onDone={(updated) => { + setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row))); + setResetPasswordFor(null); + }} + /> + )} +
+ ); +} + +function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) { + const [username, setUsername] = useState(""); + const [name, setName] = useState(""); + const [role, setRole] = useState("user"); + const [initialPassword, setInitialPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const created = await userApi.create({ username, name, role, initialPassword }); + onCreated(created); + setUsername(""); + setName(""); + setRole("user"); + setInitialPassword(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ + setUsername(event.target.value)} + required + /> +
+
+ + setName(event.target.value)} + required + /> +
+
+ + +
+
+ + setInitialPassword(event.target.value)} + minLength={8} + required + /> +
+
+ +
+ {error && ( +
+

{error}

+
+ )} +
+ ); +} + +function PasswortZuruecksetzenDialog({ + item, + onClose, + onDone, +}: { + item: UserDto; + onClose: () => void; + onDone: (updated: UserDto) => void; +}) { + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const updated = await userApi.resetPassword(item.id, newPassword); + onDone(updated); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Passwort zurücksetzen für {item.name}

+
+ + setNewPassword(event.target.value)} + minLength={8} + autoFocus + required + /> +
+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/src/app/config/konto/page.tsx b/src/app/config/konto/page.tsx new file mode 100644 index 0000000..2b58ee7 --- /dev/null +++ b/src/app/config/konto/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { authApi } from "../../../frontend/utils/auth-api"; +import { useAuth } from "../../../frontend/components/AuthProvider"; +import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles"; + +export default function KontoPage() { + const { user } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (newPassword !== newPasswordRepeat) { + setError("Die beiden neuen Passwörter stimmen nicht überein."); + return; + } + setSubmitting(true); + setError(null); + setSuccess(false); + try { + await authApi.changePassword(currentPassword, newPassword); + setCurrentPassword(""); + setNewPassword(""); + setNewPasswordRepeat(""); + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Konto

+ {user && ( +

+ Angemeldet als {user.name} ({user.username}) ·{" "} + {USER_ROLE_LABEL[user.role]} +

+ )} + +

Passwort ändern

+
+
+ + setCurrentPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+
+ + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+
+ + setNewPasswordRepeat(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+ {error &&

{error}

} + {success &&

Passwort wurde geändert.

} + +
+
+ ); +} diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx new file mode 100644 index 0000000..1356d78 --- /dev/null +++ b/src/app/config/layout.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useAuth } from "../../frontend/components/AuthProvider"; + +const CONFIG_TABS = [ + { href: "/config/benutzer", label: "Benutzer" }, + { href: "/config/konto", label: "Konto" }, +] as const; + +export default function ConfigLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { status, isAdmin } = useAuth(); + + useEffect(() => { + // LoginGate already guarantees a signed-in user with a set password by + // the time this layout renders; only the role still needs checking. + if (status === "signed-in" && !isAdmin) { + router.replace("/"); + } + }, [status, isAdmin, router]); + + if (status !== "signed-in" || !isAdmin) { + return null; + } + + return ( +
+
+

Config

+
+ + + + {children} +
+ ); +} diff --git a/src/app/config/page.tsx b/src/app/config/page.tsx new file mode 100644 index 0000000..7897dc7 --- /dev/null +++ b/src/app/config/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ConfigPage() { + redirect("/config/benutzer"); +} diff --git a/src/app/globals.css b/src/app/globals.css index de46d37..f670173 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2716,3 +2716,116 @@ button.structure-entry-main:focus-visible { justify-content: center; } } + +/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */ + +.sidebar-nav-bottom { + flex: 0 0 auto; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.sidebar-identity { + display: flex; + flex-direction: column; + gap: 1px; + padding: 8px 14px 10px; +} + +.sidebar-identity-label { + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(199, 214, 224, 0.7); +} + +.sidebar-identity-name { + font-size: 0.88rem; + font-weight: 600; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-logout { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + padding: 8px 14px; + border-radius: 8px; + color: #c7d6e0; + font-size: 0.85rem; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.16); + background: transparent; + width: 100%; + cursor: pointer; +} + +.sidebar-logout:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.sidebar-collapsed .sidebar-identity { + display: none; +} + +.login-gate-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md); + background: linear-gradient( + 160deg, + var(--color-primary-deep) 0%, + var(--color-primary-dark) 45%, + var(--color-primary) 100% + ); +} + +.login-gate-card { + width: 100%; + max-width: 360px; + padding: 28px 26px; +} + +.config-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); + margin-bottom: var(--space-md); +} + +.config-tab { + padding: 8px 16px; + border-radius: 6px 6px 0 0; + color: inherit; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + opacity: 0.7; +} + +.config-tab:hover { + opacity: 1; + text-decoration: none; +} + +.config-tab.active { + opacity: 1; + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +.modal-backdrop-inline { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cd2407b..c830c99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,8 @@ import Script from "next/script"; import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; import { AppShell } from "../frontend/components/AppShell"; +import { AuthProvider } from "../frontend/components/AuthProvider"; +import { LoginGate } from "../frontend/components/LoginGate"; export const metadata: Metadata = { title: "Leistungsbilanz", @@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac - {children} + + + {children} + + ); diff --git a/src/db/migrations/0012_lively_turbo.sql b/src/db/migrations/0012_lively_turbo.sql new file mode 100644 index 0000000..6c07010 --- /dev/null +++ b/src/db/migrations/0012_lively_turbo.sql @@ -0,0 +1,13 @@ +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `name` text NOT NULL, + `role` text DEFAULT 'user' NOT NULL, + `active` integer DEFAULT true NOT NULL, + `must_change_password` integer DEFAULT true NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/src/db/migrations/meta/0012_snapshot.json b/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..ed30f77 --- /dev/null +++ b/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,3017 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c6307b6-2516-445c-a088-c8ad746b9fde", + "prevId": "63f27b3a-7378-462d-8119-b67d67903dbb", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "area_m2": { + "name": "area_m2", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 90c3bd0..ddde03a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1790531351492, "tag": "0011_glossy_norman_osborn", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1790617581434, + "tag": "0012_lively_turbo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/user.repository.ts b/src/db/repositories/user.repository.ts new file mode 100644 index 0000000..071fc33 --- /dev/null +++ b/src/db/repositories/user.repository.ts @@ -0,0 +1,87 @@ +import crypto from "node:crypto"; +import { asc, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { users } from "../schema/users.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; + +export interface CreateUserRow { + username: string; + name: string; + role: UserRole; + passwordHash: string; +} + +export class UserRepository { + constructor(private readonly database: AppDatabase) {} + + async list() { + const db = this.database; + return db.select().from(users).orderBy(asc(users.name)); + } + + async count() { + const db = this.database; + const rows = await db.select({ id: users.id }).from(users).limit(1); + return rows.length; + } + + async findById(userId: string) { + const db = this.database; + const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + return row ?? null; + } + + async findByUsername(username: string) { + const db = this.database; + const [row] = await db + .select() + .from(users) + .where(eq(users.username, username.trim().toLowerCase())) + .limit(1); + return row ?? null; + } + + async create(input: CreateUserRow) { + const db = this.database; + const id = crypto.randomUUID(); + await db.insert(users).values({ + id, + username: input.username.trim().toLowerCase(), + name: input.name, + role: input.role, + passwordHash: input.passwordHash, + active: true, + mustChangePassword: true, + }); + return this.findById(id); + } + + async updateRoleAndActive( + userId: string, + patch: { role?: UserRole; active?: boolean } + ) { + const db = this.database; + await db + .update(users) + .set({ + ...(patch.role !== undefined ? { role: patch.role } : {}), + ...(patch.active !== undefined ? { active: patch.active } : {}), + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); + return this.findById(userId); + } + + async setPassword( + userId: string, + passwordHash: string, + mustChangePassword: boolean + ) { + const db = this.database; + await db + .update(users) + .set({ passwordHash, mustChangePassword, updatedAt: new Date() }) + .where(eq(users.id, userId)); + return this.findById(userId); + } +} diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts new file mode 100644 index 0000000..825b5e1 --- /dev/null +++ b/src/db/schema/users.ts @@ -0,0 +1,27 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + name: text("name").notNull(), + role: text("role").notNull().default("user"), + active: integer("active", { mode: "boolean" }).notNull().default(true), + mustChangePassword: integer("must_change_password", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(unixepoch())`), + }, + (table) => [uniqueIndex("users_username_unique").on(table.username)] +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/src/domain/services/user.service.ts b/src/domain/services/user.service.ts new file mode 100644 index 0000000..55720c1 --- /dev/null +++ b/src/domain/services/user.service.ts @@ -0,0 +1,104 @@ +import type { UserRepository } from "../../db/repositories/user.repository.js"; +import { hashPassword, verifyPassword } from "../../server/auth/password.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; +import type { AuthConfig } from "../../server/auth/auth-config.js"; +import { createLogger } from "../../shared/logging/logger.js"; + +const logger = createLogger("user-service"); + +export class InvalidCredentialsError extends Error { + constructor() { + super("Anmeldung fehlgeschlagen."); + } +} + +export class UsernameTakenError extends Error { + constructor() { + super("Dieser Anmeldename ist bereits vergeben."); + } +} + +export class WrongPasswordError extends Error { + constructor() { + super("Das aktuelle Passwort ist falsch."); + } +} + +export class UserService { + constructor( + private readonly repository: UserRepository, + private readonly authConfig: AuthConfig + ) {} + + /** + * Creates the bootstrap admin from env vars, but only while the users + * table is still completely empty. After the first account exists, every + * further account is created through Config -> Benutzer, and the bootstrap + * env vars are ignored - that's the whole point of them being a one-time + * escape hatch instead of a standing backdoor. + */ + async ensureBootstrapAdmin(): Promise { + if (!this.authConfig.bootstrap) return; + const existingCount = await this.repository.count(); + if (existingCount > 0) return; + const passwordHash = await hashPassword(this.authConfig.bootstrap.password); + await this.repository.create({ + username: this.authConfig.bootstrap.username, + name: "Administrator", + role: "admin", + passwordHash, + }); + logger.info("bootstrap admin created", { + username: this.authConfig.bootstrap.username, + }); + } + + async authenticate(username: string, password: string) { + const user = await this.repository.findByUsername(username); + // Always hash something, even for an unknown user: otherwise the + // response time would leak which usernames exist. + const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder")); + const passwordMatches = await verifyPassword(password, passwordHash); + if (!user || !user.active || !passwordMatches) { + throw new InvalidCredentialsError(); + } + return user; + } + + async createUser(input: { + username: string; + name: string; + role: UserRole; + initialPassword: string; + }) { + const existing = await this.repository.findByUsername(input.username); + if (existing) throw new UsernameTakenError(); + const passwordHash = await hashPassword(input.initialPassword); + return this.repository.create({ + username: input.username, + name: input.name, + role: input.role, + passwordHash, + }); + } + + async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { + return this.repository.updateRoleAndActive(userId, patch); + } + + /** Admin-triggered reset with a new start password; forces a change on next login. */ + async resetPassword(userId: string, newPassword: string) { + const passwordHash = await hashPassword(newPassword); + return this.repository.setPassword(userId, passwordHash, true); + } + + /** Self-service change by the signed-in user; clears mustChangePassword. */ + async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) { + const user = await this.repository.findById(userId); + if (!user) throw new WrongPasswordError(); + const currentMatches = await verifyPassword(currentPassword, user.passwordHash); + if (!currentMatches) throw new WrongPasswordError(); + const passwordHash = await hashPassword(newPassword); + return this.repository.setPassword(userId, passwordHash, false); + } +} diff --git a/src/frontend/components/AuthProvider.tsx b/src/frontend/components/AuthProvider.tsx new file mode 100644 index 0000000..02ab833 --- /dev/null +++ b/src/frontend/components/AuthProvider.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { authApi, type AuthUserDto } from "../utils/auth-api"; + +type AuthStatus = "loading" | "signed-in" | "signed-out"; + +interface AuthContextValue { + status: AuthStatus; + user: AuthUserDto | undefined; + isAdmin: boolean; + reload: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = useState("loading"); + const [user, setUser] = useState(undefined); + + const reload = useCallback(async () => { + try { + const current = await authApi.me(); + setUser(current); + setStatus("signed-in"); + } catch { + setUser(undefined); + setStatus("signed-out"); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + async function logout() { + try { + await authApi.logout(); + } finally { + setUser(undefined); + setStatus("signed-out"); + } + } + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/src/frontend/components/LoginGate.tsx b/src/frontend/components/LoginGate.tsx new file mode 100644 index 0000000..23a2aeb --- /dev/null +++ b/src/frontend/components/LoginGate.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { authApi } from "../utils/auth-api"; +import { useAuth } from "./AuthProvider"; + +export function LoginGate({ children }: { children: React.ReactNode }) { + const { status, user, reload } = useAuth(); + + if (status === "loading") { + return ( +
+

Lädt…

+
+ ); + } + + if (status === "signed-out") { + return ( +
+ +
+ ); + } + + if (user?.mustChangePassword) { + return ( +
+ +
+ ); + } + + return <>{children}; +} + +function LoginForm({ onSignedIn }: { onSignedIn: () => Promise }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + await authApi.login(username, password); + await onSignedIn(); + } catch (err) { + setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Leistungsbilanz

+
+ + setUsername(event.target.value)} + autoFocus + autoComplete="username" + required + /> +
+
+ + setPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+ ); +} + +function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordRepeat, setNewPasswordRepeat] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (newPassword !== newPasswordRepeat) { + setError("Die beiden neuen Passwörter stimmen nicht überein."); + return; + } + setSubmitting(true); + setError(null); + try { + await authApi.changePassword(currentPassword, newPassword); + await onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden."); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Neues Passwort setzen

+

+ Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der + weiteren Nutzung ein eigenes Passwort vergeben. +

+
+ + setCurrentPassword(event.target.value)} + autoComplete="current-password" + autoFocus + required + /> +
+
+ + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+
+ + setNewPasswordRepeat(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> +
+ {error &&

{error}

} + +
+ ); +} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index 395cd7c..bb8aecd 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; +import { useAuth } from "./AuthProvider"; // Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ @@ -30,6 +31,7 @@ interface SidebarProps { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { const pathname = usePathname(); + const { user, isAdmin, logout } = useAuth(); const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const activeProjectId = projectMatch ? projectMatch[1] : null; const projectSubPath = projectMatch?.[2] ?? ""; @@ -179,8 +181,31 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { )} + {isAdmin && ( + + )}
+ {user && !isCollapsed && ( +
+ Angemeldet als + {user.name} +
+ )} + {user && ( + + )}
); diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts new file mode 100644 index 0000000..f7057f9 --- /dev/null +++ b/src/frontend/utils/auth-api.ts @@ -0,0 +1,86 @@ +// Small, self-contained fetch helper for the auth/user endpoints - kept +// separate from the large domain api.ts so this feature doesn't have to +// touch that file's shared `request()` internals. + +export type UserRole = "admin" | "user"; + +export interface AuthStatusDto { + signedIn: boolean; + mustChangePassword: boolean; +} + +export interface AuthUserDto { + id: string; + username: string; + name: string; + role: UserRole; + mustChangePassword: boolean; +} + +export interface UserDto { + id: string; + username: string; + name: string; + role: UserRole; + active: boolean; + mustChangePassword: boolean; + createdAt: string; +} + +export class AuthApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + } +} + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + cache: "no-store", + }); + if (!response.ok) { + let message = `Anfrage fehlgeschlagen (Status ${response.status})`; + try { + const body = (await response.json()) as { error?: unknown }; + if (typeof body.error === "string") message = body.error; + } catch { + // Keep the generic message for non-JSON error responses. + } + throw new AuthApiError(message, response.status); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +export const authApi = { + status: () => request("/api/auth/status"), + login: (username: string, password: string) => + request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + me: () => request("/api/auth/me"), + changePassword: (currentPassword: string, newPassword: string) => + request<{ changed: true }>("/api/auth/change-password", { + method: "POST", + body: JSON.stringify({ currentPassword, newPassword }), + }), + logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }), +}; + +export const userApi = { + list: () => request("/api/users"), + create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) => + request("/api/users", { method: "POST", body: JSON.stringify(input) }), + update: (userId: string, patch: { role?: UserRole; active?: boolean }) => + request(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }), + resetPassword: (userId: string, newPassword: string) => + request(`/api/users/${userId}/reset-password`, { + method: "POST", + body: JSON.stringify({ newPassword }), + }), +}; diff --git a/src/server/auth/auth-config.ts b/src/server/auth/auth-config.ts new file mode 100644 index 0000000..d2157f4 --- /dev/null +++ b/src/server/auth/auth-config.ts @@ -0,0 +1,44 @@ +export const SESSION_COOKIE = "lb_session"; + +const DEFAULT_SESSION_HOURS = 12; + +export interface AuthConfig { + sessionTtlMs: number; + cookieSecure: boolean; + /** + * Only used to create the very first admin when the users table is still + * empty - after that, every account is created through Config -> Benutzer. + * Unset means: no bootstrap account, the table must already have an admin. + */ + bootstrap: { username: string; password: string } | null; +} + +export class AuthConfigError extends Error {} + +export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { + const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS); + if (!Number.isFinite(hours) || hours <= 0) { + throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number."); + } + + const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase(); + const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? ""; + if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) { + throw new AuthConfigError( + "AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither." + ); + } + if (bootstrapPassword && bootstrapPassword.length < 12) { + throw new AuthConfigError( + "AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long." + ); + } + + return { + sessionTtlMs: hours * 60 * 60 * 1000, + cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true", + bootstrap: bootstrapUsername + ? { username: bootstrapUsername, password: bootstrapPassword } + : null, + }; +} diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts new file mode 100644 index 0000000..53a1767 --- /dev/null +++ b/src/server/auth/auth.middleware.ts @@ -0,0 +1,111 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express"; +import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; +import { readCookie } from "./cookie.js"; +import type { SessionStore } from "./sessions.js"; +import type { UserRepository } from "../../db/repositories/user.repository.js"; +import type { UserRole } from "../../shared/constants/user-roles.js"; + +export interface AuthUser { + id: string; + username: string; + name: string; + role: UserRole; + mustChangePassword: boolean; +} + +declare global { + namespace Express { + interface Request { + authUser?: AuthUser; + } + } +} + +export function sessionIdFromRequest(req: { + headers: { cookie?: string | undefined }; +}): string | undefined { + return readCookie(req.headers.cookie, SESSION_COOKIE); +} + +export async function identityFromRequest( + req: { headers: { cookie?: string | undefined } }, + sessions: SessionStore, + users: UserRepository, + slideExpiry = true +): Promise { + const session = sessions.get(sessionIdFromRequest(req), slideExpiry); + if (!session) return undefined; + const user = await users.findById(session.userId); + if (!user || !user.active) return undefined; + return { + id: user.id, + username: user.username, + name: user.name, + role: user.role as UserRole, + mustChangePassword: user.mustChangePassword, + }; +} + +export function cookieOptions(authConfig: AuthConfig) { + return { + httpOnly: true, + sameSite: "lax" as const, + secure: authConfig.cookieSecure, + path: "/", + maxAge: authConfig.sessionTtlMs, + }; +} + +/** Protects /api. Only the login route and the status check stay reachable without a session. */ +export function createAuthGuard( + authConfig: AuthConfig, + sessions: SessionStore, + users: UserRepository, + openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] +): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (openPaths.includes(req.path)) { + next(); + return; + } + identityFromRequest(req, sessions, users) + .then((identity) => { + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + req.authUser = identity; + const sessionId = sessionIdFromRequest(req); + if (sessionId) { + res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); + } + next(); + }) + .catch(next); + }; +} + +/** Blocks everything except logout and the password change while a start password is still in use. */ +export function requirePasswordChanged(): RequestHandler { + const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]); + return (req: Request, res: Response, next: NextFunction) => { + if (!req.authUser?.mustChangePassword || allowed.has(req.path)) { + next(); + return; + } + res.status(403).json({ + error: "Bitte zuerst ein eigenes Passwort setzen.", + mustChangePassword: true, + }); + }; +} + +export function requireRole(role: UserRole): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (req.authUser?.role !== role) { + res.status(403).json({ error: "Keine Berechtigung für diese Aktion." }); + return; + } + next(); + }; +} diff --git a/src/server/auth/cookie.ts b/src/server/auth/cookie.ts new file mode 100644 index 0000000..0204ca3 --- /dev/null +++ b/src/server/auth/cookie.ts @@ -0,0 +1,15 @@ +/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */ +export function readCookie( + header: string | undefined, + name: string +): string | undefined { + if (!header) return undefined; + for (const part of header.split(";")) { + const separatorIndex = part.indexOf("="); + if (separatorIndex === -1) continue; + const key = part.slice(0, separatorIndex).trim(); + if (key !== name) continue; + return decodeURIComponent(part.slice(separatorIndex + 1).trim()); + } + return undefined; +} diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts new file mode 100644 index 0000000..96814e1 --- /dev/null +++ b/src/server/auth/password.ts @@ -0,0 +1,63 @@ +import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto"; + +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LENGTH = 64; + +// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only +// resolves to scrypt's 3-argument overload, so the 4-argument form used for +// verification (with explicit N/r/p) doesn't type-check through it. +function scryptAsync( + password: string, + salt: Buffer, + keyLength: number, + options?: ScryptOptions +): Promise { + return new Promise((resolve, reject) => { + const callback = (error: Error | null, derivedKey: Buffer) => { + if (error) reject(error); + else resolve(derivedKey); + }; + if (options) { + scrypt(password, salt, keyLength, options, callback); + } else { + scrypt(password, salt, keyLength, callback); + } + }); +} + +// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the +// hash so they can be tuned later without invalidating existing rows. +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); + return [ + "scrypt", + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + salt.toString("hex"), + derivedKey.toString("hex"), + ].join("$"); +} + +export async function verifyPassword( + password: string, + storedHash: string +): Promise { + const parts = storedHash.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return false; + const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts; + const n = Number(nRaw); + const r = Number(rRaw); + const p = Number(pRaw); + if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) { + return false; + } + const salt = Buffer.from(saltHex, "hex"); + const expected = Buffer.from(hashHex, "hex"); + const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); + if (derivedKey.length !== expected.length) return false; + return timingSafeEqual(derivedKey, expected); +} diff --git a/src/server/auth/sessions.ts b/src/server/auth/sessions.ts new file mode 100644 index 0000000..501f4c4 --- /dev/null +++ b/src/server/auth/sessions.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; + +interface Session { + id: string; + userId: string; + expiresAt: number; +} + +/** + * In-memory session store. The API runs as a single container/process, so a + * restart clearing every session is an acceptable trade-off for the + * simplicity of not needing a persisted session table. + */ +export class SessionStore { + private readonly sessions = new Map(); + + constructor(private readonly ttlMs: number) {} + + create(userId: string): Session { + const session: Session = { + id: randomUUID(), + userId, + expiresAt: Date.now() + this.ttlMs, + }; + this.sessions.set(session.id, session); + return session; + } + + /** Returns the session and slides its expiry forward, or undefined if gone/expired. */ + get(sessionId: string | undefined, slideExpiry = true): Session | undefined { + if (!sessionId) return undefined; + const session = this.sessions.get(sessionId); + if (!session) return undefined; + if (session.expiresAt < Date.now()) { + this.sessions.delete(sessionId); + return undefined; + } + if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs; + return session; + } + + destroy(sessionId: string | undefined): void { + if (!sessionId) return; + this.sessions.delete(sessionId); + } + + /** Ends every other session for a user, e.g. after a password change. */ + destroyAllForUser(userId: string, exceptSessionId?: string): void { + for (const [id, session] of this.sessions) { + if (session.userId === userId && id !== exceptSessionId) { + this.sessions.delete(id); + } + } + } +} diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 66fdd92..846a0da 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -16,6 +16,7 @@ import { RoomRepository } from "../../db/repositories/room.repository.js"; import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; +import { UserRepository } from "../../db/repositories/user.repository.js"; export const circuitDeviceRowRepository = new CircuitDeviceRowRepository(db); @@ -39,3 +40,4 @@ export const externalCsvConfigurationRepository = new ExternalCsvConfigurationRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db); +export const userRepository = new UserRepository(db); diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts new file mode 100644 index 0000000..751f324 --- /dev/null +++ b/src/server/composition/auth.ts @@ -0,0 +1,8 @@ +import { readAuthConfig } from "../auth/auth-config.js"; +import { SessionStore } from "../auth/sessions.js"; +import { UserService } from "../../domain/services/user.service.js"; +import { userRepository } from "./application-repositories.js"; + +export const authConfig = readAuthConfig(); +export const sessionStore = new SessionStore(authConfig.sessionTtlMs); +export const userService = new UserService(userRepository, authConfig); diff --git a/src/server/controllers/auth.controller.ts b/src/server/controllers/auth.controller.ts new file mode 100644 index 0000000..9dabee5 --- /dev/null +++ b/src/server/controllers/auth.controller.ts @@ -0,0 +1,88 @@ +import type { Request, Response } from "express"; +import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js"; +import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js"; +import { authConfig, sessionStore, userService } from "../composition/auth.js"; +import { + cookieOptions, + identityFromRequest, + sessionIdFromRequest, +} from "../auth/auth.middleware.js"; +import { SESSION_COOKIE } from "../auth/auth-config.js"; +import { userRepository } from "../composition/application-repositories.js"; + +export async function getAuthStatus(req: Request, res: Response) { + const identity = await identityFromRequest(req, sessionStore, userRepository); + const sessionId = sessionIdFromRequest(req); + if (identity && sessionId) { + res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); + } + res.json({ + signedIn: Boolean(identity), + mustChangePassword: identity?.mustChangePassword ?? false, + }); +} + +export async function login(req: Request, res: Response) { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(401).json({ error: "Anmeldung fehlgeschlagen." }); + return; + } + try { + const user = await userService.authenticate(parsed.data.username, parsed.data.password); + const session = sessionStore.create(user.id); + res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig)); + res.json({ signedIn: true, mustChangePassword: user.mustChangePassword }); + } catch (error) { + if (error instanceof InvalidCredentialsError) { + res.status(401).json({ error: error.message }); + return; + } + throw error; + } +} + +export function getMe(req: Request, res: Response) { + const identity = req.authUser; + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + res.json(identity); +} + +export async function changePassword(req: Request, res: Response) { + const identity = req.authUser; + if (!identity) { + res.status(401).json({ error: "Nicht angemeldet." }); + return; + } + const parsed = changePasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + try { + await userService.changeOwnPassword( + identity.id, + parsed.data.currentPassword, + parsed.data.newPassword + ); + } catch (error) { + if (error instanceof WrongPasswordError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + // A password change invalidates every other session of this account; the + // current one stays so the change doesn't log the user out themselves. + sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req)); + res.json({ changed: true }); +} + +export function logout(req: Request, res: Response) { + sessionStore.destroy(sessionIdFromRequest(req)); + res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined }); + res.json({ signedOut: true }); +} diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts new file mode 100644 index 0000000..0ad3cbc --- /dev/null +++ b/src/server/controllers/user.controller.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express"; +import { + createUserSchema, + resetPasswordSchema, + updateUserSchema, +} from "../../shared/validation/user.schemas.js"; +import { UsernameTakenError } from "../../domain/services/user.service.js"; +import { userService } from "../composition/auth.js"; +import { userRepository } from "../composition/application-repositories.js"; + +// The password hash never leaves this layer. +function toDto(user: { + id: string; + username: string; + name: string; + role: string; + active: boolean; + mustChangePassword: boolean; + createdAt: Date; +}) { + return { + id: user.id, + username: user.username, + name: user.name, + role: user.role, + active: user.active, + mustChangePassword: user.mustChangePassword, + createdAt: user.createdAt, + }; +} + +export async function listUsers(_req: Request, res: Response) { + const rows = await userRepository.list(); + res.json(rows.map(toDto)); +} + +export async function createUser(req: Request, res: Response) { + const parsed = createUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + try { + const created = await userService.createUser(parsed.data); + if (!created) { + res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." }); + return; + } + res.status(201).json(toDto(created)); + } catch (error) { + if (error instanceof UsernameTakenError) { + res.status(409).json({ error: error.message }); + return; + } + throw error; + } +} + +export async function updateUser(req: Request, res: Response) { + const { userId } = req.params; + if (typeof userId !== "string") { + res.status(400).json({ error: "Invalid userId" }); + return; + } + const parsed = updateUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + // The signed-in admin can't lock themselves out by deactivating or + // demoting their own account through this endpoint. + if (userId === req.authUser?.id) { + res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." }); + return; + } + const existing = await userRepository.findById(userId); + if (!existing) { + res.status(404).json({ error: "Benutzer nicht gefunden." }); + return; + } + const updated = await userService.updateRoleAndActive(userId, parsed.data); + res.json(updated ? toDto(updated) : null); +} + +export async function resetUserPassword(req: Request, res: Response) { + const { userId } = req.params; + if (typeof userId !== "string") { + res.status(400).json({ error: "Invalid userId" }); + return; + } + const parsed = resetPasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.flatten() }); + return; + } + const existing = await userRepository.findById(userId); + if (!existing) { + res.status(404).json({ error: "Benutzer nicht gefunden." }); + return; + } + const updated = await userService.resetPassword(userId, parsed.data.newPassword); + res.json(updated ? toDto(updated) : null); +} diff --git a/src/server/index.ts b/src/server/index.ts index 7cad7e3..868de79 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,12 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { cableSizingRouter } from "./routes/cable-sizing.routes.js"; +import { authRouter } from "./routes/auth.routes.js"; +import { userRouter } from "./routes/user.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; +import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js"; +import { authConfig, sessionStore, userService } from "./composition/auth.js"; +import { userRepository } from "./composition/application-repositories.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { closeDefaultDatabase } from "../db/client.js"; @@ -50,6 +55,15 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +// Everything below /api requires a signed-in, active user, except +// /auth/login, /auth/status and /auth/logout (see createAuthGuard's +// openPaths). authRouter is mounted AFTER the guard (not before) so that +// /auth/me and /auth/change-password still get req.authUser populated by it. +app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository)); +app.use("/api", requirePasswordChanged()); + +app.use("/api/auth", authRouter); +app.use("/api/users", userRouter); app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/projects", projectRouter); app.use("/api/global-devices", globalDeviceRouter); @@ -77,40 +91,48 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -const server = app.listen(port, () => { - logger.info("server started", { port }); -}); - -let shuttingDown = false; - -function shutdown(signal: NodeJS.Signals): void { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down", { signal }); - - // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der - // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. - const forceExit = setTimeout(() => { - logger.error("shutdown timed out, forcing exit"); +userService + .ensureBootstrapAdmin() + .catch((error) => { + logger.error("bootstrap admin setup failed", toErrorMeta(error)); process.exit(1); - }, shutdownTimeoutMs); - forceExit.unref(); + }) + .then(() => { + const server = app.listen(port, () => { + logger.info("server started", { port }); + }); - server.close((error) => { - if (error) logger.error("http server close failed", toErrorMeta(error)); - try { - closeDefaultDatabase(); - } catch (closeError) { - logger.error("database close failed", toErrorMeta(closeError)); + let shuttingDown = false; + + function shutdown(signal: NodeJS.Signals): void { + if (shuttingDown) return; + shuttingDown = true; + logger.info("shutting down", { signal }); + + // Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der + // Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten. + const forceExit = setTimeout(() => { + logger.error("shutdown timed out, forcing exit"); + process.exit(1); + }, shutdownTimeoutMs); + forceExit.unref(); + + server.close((error) => { + if (error) logger.error("http server close failed", toErrorMeta(error)); + try { + closeDefaultDatabase(); + } catch (closeError) { + logger.error("database close failed", toErrorMeta(closeError)); + } + logger.info("shutdown complete"); + process.exit(error ? 1 : 0); + }); + + // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des + // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. + server.closeIdleConnections(); } - logger.info("shutdown complete"); - process.exit(error ? 1 : 0); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); }); - - // Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des - // Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter. - server.closeIdleConnections(); -} - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); diff --git a/src/server/routes/auth.routes.ts b/src/server/routes/auth.routes.ts new file mode 100644 index 0000000..ee1cdd1 --- /dev/null +++ b/src/server/routes/auth.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { + changePassword, + getAuthStatus, + getMe, + login, + logout, +} from "../controllers/auth.controller.js"; + +export const authRouter = Router(); + +authRouter.get("/status", getAuthStatus); +authRouter.post("/login", login); +authRouter.get("/me", getMe); +authRouter.post("/change-password", changePassword); +authRouter.post("/logout", logout); diff --git a/src/server/routes/user.routes.ts b/src/server/routes/user.routes.ts new file mode 100644 index 0000000..a34699c --- /dev/null +++ b/src/server/routes/user.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { requireRole } from "../auth/auth.middleware.js"; +import { + createUser, + listUsers, + resetUserPassword, + updateUser, +} from "../controllers/user.controller.js"; + +export const userRouter = Router(); + +userRouter.use(requireRole("admin")); +userRouter.get("/", listUsers); +userRouter.post("/", createUser); +userRouter.patch("/:userId", updateUser); +userRouter.post("/:userId/reset-password", resetUserPassword); diff --git a/src/shared/constants/user-roles.ts b/src/shared/constants/user-roles.ts new file mode 100644 index 0000000..f123d5f --- /dev/null +++ b/src/shared/constants/user-roles.ts @@ -0,0 +1,7 @@ +export const USER_ROLES = ["admin", "user"] as const; +export type UserRole = (typeof USER_ROLES)[number]; + +export const USER_ROLE_LABEL: Record = { + admin: "Administrator", + user: "Benutzer", +}; diff --git a/src/shared/validation/auth.schemas.ts b/src/shared/validation/auth.schemas.ts new file mode 100644 index 0000000..544b052 --- /dev/null +++ b/src/shared/validation/auth.schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const loginSchema = z + .object({ + username: z.string().trim().min(1), + password: z.string().min(1), + }) + .strict(); + +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8).max(200), + }) + .strict(); diff --git a/src/shared/validation/user.schemas.ts b/src/shared/validation/user.schemas.ts new file mode 100644 index 0000000..ceb8961 --- /dev/null +++ b/src/shared/validation/user.schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { USER_ROLES } from "../constants/user-roles.js"; + +const MIN_PASSWORD_LENGTH = 8; + +export const createUserSchema = z + .object({ + username: z.string().trim().min(1).max(200), + name: z.string().trim().min(1).max(200), + role: z.enum(USER_ROLES), + initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + }) + .strict(); + +export const updateUserSchema = z + .object({ + role: z.enum(USER_ROLES).optional(), + active: z.boolean().optional(), + }) + .strict() + .refine((value) => value.role !== undefined || value.active !== undefined, { + message: "Nothing to update.", + }); + +export const resetPasswordSchema = z + .object({ + newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + }) + .strict(); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; +export type ResetPasswordInput = z.infer; -- 2.49.1 From 8d2948cdb1efdee5e28a72b65ea9b45b9a281c72 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Mon, 28 Sep 2026 20:32:20 +0200 Subject: [PATCH 2/6] Match sidebar bottom layout to the Wochenplanung reference (theme toggle + Abmelden in one row) Square icon-only theme toggle sits next to the Abmelden button in a shared row, collapsing to just the toggle when the sidebar is collapsed - same arrangement as pm-app's Sidebar.tsx. Co-Authored-By: Claude Sonnet 5 --- src/app/globals.css | 31 ++++++++++++++++++------- src/frontend/components/Sidebar.tsx | 17 +++++++++----- src/frontend/components/ThemeToggle.tsx | 9 +++---- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f670173..fec2745 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -323,23 +323,24 @@ a { } .sidebar-footer { - padding: 12px; + padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.12); } +/* Square and icon-only so it fits next to Abmelden in .sidebar-bottom-row. */ .sidebar-theme-toggle { display: flex; align-items: center; - gap: 10px; - padding: 10px 14px; + justify-content: center; + flex: 0 0 auto; + width: 36px; + height: 36px; + padding: 0; border-radius: 8px; color: #c7d6e0; font-size: 0.92rem; - font-weight: 500; border: 1px solid rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.06); - width: 100%; - text-align: left; cursor: pointer; } @@ -2747,12 +2748,20 @@ button.structure-entry-main:focus-visible { white-space: nowrap; } +/* Toggle (square) and Abmelden (fills the rest) share one row. */ +.sidebar-bottom-row { + display: flex; + align-items: stretch; + gap: 8px; + padding: 0 14px 12px; +} + .sidebar-logout { display: flex; align-items: center; justify-content: center; - gap: 8px; - margin-top: 8px; + flex: 1 1 auto; + min-width: 0; padding: 8px 14px; border-radius: 8px; color: #c7d6e0; @@ -2760,7 +2769,6 @@ button.structure-entry-main:focus-visible { font-weight: 500; border: 1px solid rgba(255, 255, 255, 0.16); background: transparent; - width: 100%; cursor: pointer; } @@ -2773,6 +2781,11 @@ button.structure-entry-main:focus-visible { display: none; } +.sidebar-collapsed .sidebar-bottom-row { + justify-content: center; + padding-inline: 0; +} + .login-gate-screen { min-height: 100vh; display: flex; diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index bb8aecd..5130d5d 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -200,12 +200,17 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { {user.name} )} - - {user && ( - - )} + {/* Toggle and Abmelden share one row: the toggle stays a fixed-size + square, Abmelden takes the rest of the width. Collapsed, only + the square toggle remains (centered). */} +
+ + {user && !isCollapsed && ( + + )} +
); diff --git a/src/frontend/components/ThemeToggle.tsx b/src/frontend/components/ThemeToggle.tsx index 355e0e2..2073a5e 100644 --- a/src/frontend/components/ThemeToggle.tsx +++ b/src/frontend/components/ThemeToggle.tsx @@ -48,16 +48,17 @@ export function ThemeToggle() { } return ( + // Square, icon-only: it shares a row with the Abmelden button, so there + // is no room for a text label. The label moves to title/aria-label. ); } -- 2.49.1 From 5073d9342a287ec6d961f53b5178b7ca344ef3f2 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Mon, 28 Sep 2026 21:30:30 +0200 Subject: [PATCH 3/6] Harden authentication and put filter toggle and fields in one row Security: per-username login throttling, constant-time unknown-user check, Sec-Fetch-Site CSRF guard for writes, session rotation on login and password change, absolute session lifetime, sessions ended on reset and deactivation, last-admin protection, input length caps, ASCII usernames, clickjacking headers. Covered by tests/authentication.test.ts. Filter panel: toggle, fields and count share one row to save space. Co-Authored-By: Claude Opus 5.5 --- compose.yaml | 1 + docs/authentication.md | 28 ++- next.config.mjs | 16 ++ src/app/globals.css | 21 +- src/domain/services/user.service.ts | 67 +++++- src/frontend/components/filter-panel.tsx | 47 ++-- src/server/auth/auth-config.ts | 23 +- src/server/auth/auth.middleware.ts | 48 +++- src/server/auth/cookie.ts | 7 +- src/server/auth/login-throttle.ts | 53 +++++ src/server/auth/sessions.ts | 60 ++++- src/server/composition/auth.ts | 5 +- src/server/controllers/auth.controller.ts | 47 ++-- src/server/controllers/user.controller.ts | 32 ++- src/server/index.ts | 8 +- src/shared/validation/auth.schemas.ts | 13 +- src/shared/validation/user.schemas.ts | 23 +- tests/authentication.test.ts | 265 ++++++++++++++++++++++ 18 files changed, 683 insertions(+), 81 deletions(-) create mode 100644 src/server/auth/login-throttle.ts create mode 100644 tests/authentication.test.ts diff --git a/compose.yaml b/compose.yaml index bab8c3d..2a2eecd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -51,6 +51,7 @@ services: AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" COOKIE_SECURE: "${COOKIE_SECURE:-false}" # Nicht auf dem Host veroeffentlicht: die API wird ueber den # Next.js-Rewrite im Web-Dienst erreicht. diff --git a/docs/authentication.md b/docs/authentication.md index 3d197cf..13a9f72 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -33,7 +33,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. `username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`, `role`, `active`, `mustChangePassword`. - **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie - `lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein + `lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit + absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein Neustart der API beendet alle Sessions – für einen Single-Container-Dienst ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle. - **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*` @@ -44,6 +45,28 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. +## Schutzmechanismen + +| Angriff | Schutz | +|---|---| +| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). | +| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash | +| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung | +| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten | +| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel | +| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) | +| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen | +| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt | +| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` | +| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt | +| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben | +| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) | +| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt | + +**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im +Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen +Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen. + ## Bootstrap-Admin `AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe @@ -56,6 +79,7 @@ in `compose.yaml` selbst. ## Bewusst nicht umgesetzt (Stand jetzt) - Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen -- Konten-Sperre nach Fehlversuchen +- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich + begrenzte Drosselung, siehe oben) - E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung - Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle diff --git a/next.config.mjs b/next.config.mjs index e0610b9..a6cb01e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,6 +11,22 @@ const nextConfig = { typescript: { tsconfigPath: "./tsconfig.next.json", }, + poweredByHeader: false, + // Clickjacking: the Config pages hold admin actions, so no foreign page + // may frame the app. Same-origin framing stays allowed. + async headers() { + return [ + { + source: "/:path*", + headers: [ + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "Content-Security-Policy", value: "frame-ancestors 'self'" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "same-origin" }, + ], + }, + ]; + }, async rewrites() { return [ { diff --git a/src/app/globals.css b/src/app/globals.css index fec2745..0f1a642 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -452,13 +452,25 @@ a { background: var(--color-bg); } -.filter-panel-header { +.filter-panel-row { display: flex; align-items: center; gap: 10px; } -.filter-panel-header .filter-panel-toggle { +/* Expanded: the fields carry a label above their input, so toggle and + count sit on the input line (bottom) instead of the label line. */ +.filter-panel-row-expanded { + align-items: flex-end; +} + +.filter-panel-row-expanded .filter-panel-toggle, +.filter-panel-row-expanded .filter-panel-count { + margin-bottom: calc((2.1rem - 24px) / 2); +} + +.filter-panel-row .filter-panel-toggle { + flex: 0 0 auto; width: 24px; height: 24px; padding: 0; @@ -468,7 +480,7 @@ a { color: var(--color-ink-soft); } -.filter-panel-header .filter-panel-toggle:hover { +.filter-panel-row .filter-panel-toggle:hover { background: var(--color-surface); } @@ -503,10 +515,11 @@ a { .filter-bar { display: flex; + flex: 1 1 auto; + min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: var(--space-sm) var(--space-md); - margin-top: 0.5rem; } .filter-bar-field, diff --git a/src/domain/services/user.service.ts b/src/domain/services/user.service.ts index 55720c1..0968d02 100644 --- a/src/domain/services/user.service.ts +++ b/src/domain/services/user.service.ts @@ -24,7 +24,29 @@ export class WrongPasswordError extends Error { } } +export class PasswordUnchangedError extends Error { + constructor() { + super("Das neue Passwort muss sich vom bisherigen unterscheiden."); + } +} + +export class LastAdminError extends Error { + constructor() { + super("Es muss mindestens ein aktiver Administrator bleiben."); + } +} + +function isUniqueConstraintError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE" + ); +} + export class UserService { + private dummyHash: Promise | null = null; + constructor( private readonly repository: UserRepository, private readonly authConfig: AuthConfig @@ -38,6 +60,9 @@ export class UserService { * escape hatch instead of a standing backdoor. */ async ensureBootstrapAdmin(): Promise { + // Warm the dummy hash at startup so even the first unknown-user login + // costs one scrypt run, not two. + await this.getDummyHash(); if (!this.authConfig.bootstrap) return; const existingCount = await this.repository.count(); if (existingCount > 0) return; @@ -55,9 +80,10 @@ export class UserService { async authenticate(username: string, password: string) { const user = await this.repository.findByUsername(username); - // Always hash something, even for an unknown user: otherwise the - // response time would leak which usernames exist. - const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder")); + // Unknown users are verified against a fixed dummy hash: exactly one + // scrypt run either way, so response time doesn't reveal which usernames + // exist. + const passwordHash = user?.passwordHash ?? (await this.getDummyHash()); const passwordMatches = await verifyPassword(password, passwordHash); if (!user || !user.active || !passwordMatches) { throw new InvalidCredentialsError(); @@ -74,15 +100,32 @@ export class UserService { const existing = await this.repository.findByUsername(input.username); if (existing) throw new UsernameTakenError(); const passwordHash = await hashPassword(input.initialPassword); - return this.repository.create({ - username: input.username, - name: input.name, - role: input.role, - passwordHash, - }); + try { + return await this.repository.create({ + username: input.username, + name: input.name, + role: input.role, + passwordHash, + }); + } catch (error) { + // Two admins creating the same name at once: the unique index decides. + if (isUniqueConstraintError(error)) throw new UsernameTakenError(); + throw error; + } } async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) { + const removesAdmin = patch.role === "user" || patch.active === false; + if (removesAdmin) { + const target = await this.repository.findById(userId); + const targetIsActiveAdmin = target?.role === "admin" && target.active; + if (targetIsActiveAdmin) { + const activeAdmins = (await this.repository.list()).filter( + (user) => user.role === "admin" && user.active + ); + if (activeAdmins.length <= 1) throw new LastAdminError(); + } + } return this.repository.updateRoleAndActive(userId, patch); } @@ -98,7 +141,13 @@ export class UserService { if (!user) throw new WrongPasswordError(); const currentMatches = await verifyPassword(currentPassword, user.passwordHash); if (!currentMatches) throw new WrongPasswordError(); + if (newPassword === currentPassword) throw new PasswordUnchangedError(); const passwordHash = await hashPassword(newPassword); return this.repository.setPassword(userId, passwordHash, false); } + + private getDummyHash(): Promise { + this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login"); + return this.dummyHash; + } } diff --git a/src/frontend/components/filter-panel.tsx b/src/frontend/components/filter-panel.tsx index afa1fde..9c9923b 100644 --- a/src/frontend/components/filter-panel.tsx +++ b/src/frontend/components/filter-panel.tsx @@ -55,9 +55,15 @@ export function CollapsibleFilterPanel({ writeCollapsed(storageKey, next); } + // One row: toggle, then either the "Filter" label (collapsed) or the filter + // fields themselves (expanded), and the count at the right edge. return (
-
+
- Filter - {isCollapsed && isFilterActive ? ( - Filter aktiv - ) : null} + {isCollapsed ? ( + <> + Filter + {isFilterActive ? Filter aktiv : null} + + ) : ( +
+ {children} + {isFilterActive ? ( + + ) : null} +
+ )} {visibleCount} von {totalCount}
- {isCollapsed ? null : ( -
- {children} - {isFilterActive ? ( - - ) : null} -
- )}
); } diff --git a/src/server/auth/auth-config.ts b/src/server/auth/auth-config.ts index d2157f4..c4c3b44 100644 --- a/src/server/auth/auth-config.ts +++ b/src/server/auth/auth-config.ts @@ -1,9 +1,13 @@ export const SESSION_COOKIE = "lb_session"; const DEFAULT_SESSION_HOURS = 12; +const DEFAULT_SESSION_MAX_DAYS = 7; export interface AuthConfig { + /** Idle timeout; slides forward on activity. */ sessionTtlMs: number; + /** Hard upper bound for a session, regardless of activity. */ + sessionMaxAgeMs: number; cookieSecure: boolean; /** * Only used to create the very first admin when the users table is still @@ -15,11 +19,21 @@ export interface AuthConfig { export class AuthConfigError extends Error {} -export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { - const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS); - if (!Number.isFinite(hours) || hours <= 0) { - throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number."); +function positiveNumber(raw: string | undefined, fallback: number, name: string): number { + const value = Number(raw ?? fallback); + if (!Number.isFinite(value) || value <= 0) { + throw new AuthConfigError(`${name} must be a positive number.`); } + return value; +} + +export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig { + const hours = positiveNumber(env.AUTH_SESSION_HOURS, DEFAULT_SESSION_HOURS, "AUTH_SESSION_HOURS"); + const maxDays = positiveNumber( + env.AUTH_SESSION_MAX_DAYS, + DEFAULT_SESSION_MAX_DAYS, + "AUTH_SESSION_MAX_DAYS" + ); const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase(); const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? ""; @@ -36,6 +50,7 @@ export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig return { sessionTtlMs: hours * 60 * 60 * 1000, + sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000, cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true", bootstrap: bootstrapUsername ? { username: bootstrapUsername, password: bootstrapPassword } diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts index 53a1767..ce65bfb 100644 --- a/src/server/auth/auth.middleware.ts +++ b/src/server/auth/auth.middleware.ts @@ -46,17 +46,55 @@ export async function identityFromRequest( }; } -export function cookieOptions(authConfig: AuthConfig) { +export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) { return { httpOnly: true, sameSite: "lax" as const, secure: authConfig.cookieSecure, path: "/", - maxAge: authConfig.sessionTtlMs, + maxAge: maxAgeMs, }; } -/** Protects /api. Only the login route and the status check stay reachable without a session. */ +/** Sets the session cookie with the same remaining lifetime the server uses. */ +export function setSessionCookie( + res: Response, + authConfig: AuthConfig, + sessions: SessionStore, + sessionId: string +): void { + const session = sessions.get(sessionId, false); + if (!session) return; + res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session))); +} + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * CSRF defense for cookie-authenticated writes. + * + * SameSite=Lax alone is not enough here: every other service on the same + * host (other ports of the same IP) counts as "same-site" and would get the + * cookie attached. Browsers send Sec-Fetch-Site on every request, so a write + * is only accepted from the app's own origin. Clients without the header + * (curl, scripts) carry no ambient browser cookie and are not a CSRF vector. + */ +export function requireSameOriginForWrites(): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + const fetchSite = req.headers["sec-fetch-site"]; + if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { + next(); + return; + } + res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." }); + }; +} + +/** Protects /api. Only login, status and logout stay reachable without a session. */ export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, @@ -76,9 +114,7 @@ export function createAuthGuard( } req.authUser = identity; const sessionId = sessionIdFromRequest(req); - if (sessionId) { - res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); - } + if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId); next(); }) .catch(next); diff --git a/src/server/auth/cookie.ts b/src/server/auth/cookie.ts index 0204ca3..344ff65 100644 --- a/src/server/auth/cookie.ts +++ b/src/server/auth/cookie.ts @@ -9,7 +9,12 @@ export function readCookie( if (separatorIndex === -1) continue; const key = part.slice(0, separatorIndex).trim(); if (key !== name) continue; - return decodeURIComponent(part.slice(separatorIndex + 1).trim()); + // A malformed value must read as "no session", not throw into a 500. + try { + return decodeURIComponent(part.slice(separatorIndex + 1).trim()); + } catch { + return undefined; + } } return undefined; } diff --git a/src/server/auth/login-throttle.ts b/src/server/auth/login-throttle.ts new file mode 100644 index 0000000..d81719b --- /dev/null +++ b/src/server/auth/login-throttle.ts @@ -0,0 +1,53 @@ +/** + * Slows down online password guessing per username. + * + * Keyed by username, not by client IP: every request reaches the API through + * the Next.js rewrite, so the socket address is always the web container. + * The trade-off is that someone who knows a username can keep that account + * temporarily throttled; the block lifts by itself after the window, so it is + * a nuisance, not a lockout. + */ +export class LoginThrottle { + private readonly failures = new Map(); + + constructor( + private readonly maxFailures = 5, + private readonly windowMs = 15 * 60 * 1000, + private readonly maxTrackedKeys = 10_000, + private readonly now: () => number = Date.now + ) {} + + /** Milliseconds until another attempt is allowed; 0 = allowed now. */ + retryAfterMs(username: string): number { + const recent = this.recentFailures(this.key(username)); + if (recent.length < this.maxFailures) return 0; + return Math.max(0, recent[0] + this.windowMs - this.now()); + } + + recordFailure(username: string): void { + const key = this.key(username); + const recent = this.recentFailures(key); + recent.push(this.now()); + if (!this.failures.has(key) && this.failures.size >= this.maxTrackedKeys) { + // Bound memory under a flood of random usernames: drop the oldest key. + const oldestKey = this.failures.keys().next().value; + if (oldestKey !== undefined) this.failures.delete(oldestKey); + } + this.failures.set(key, recent); + } + + recordSuccess(username: string): void { + this.failures.delete(this.key(username)); + } + + private recentFailures(key: string): number[] { + const cutoff = this.now() - this.windowMs; + const recent = (this.failures.get(key) ?? []).filter((time) => time > cutoff); + if (recent.length === 0) this.failures.delete(key); + return recent; + } + + private key(username: string): string { + return username.trim().toLowerCase(); + } +} diff --git a/src/server/auth/sessions.ts b/src/server/auth/sessions.ts index 501f4c4..9ee1fe0 100644 --- a/src/server/auth/sessions.ts +++ b/src/server/auth/sessions.ts @@ -3,24 +3,50 @@ import { randomUUID } from "node:crypto"; interface Session { id: string; userId: string; + createdAt: number; expiresAt: number; } +const MAX_SESSIONS_PER_USER = 20; +const SWEEP_INTERVAL_MS = 10 * 60 * 1000; + /** * In-memory session store. The API runs as a single container/process, so a * restart clearing every session is an acceptable trade-off for the * simplicity of not needing a persisted session table. + * + * Sessions slide forward on activity (`idleTtlMs`) but never outlive + * `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever. */ export class SessionStore { private readonly sessions = new Map(); - constructor(private readonly ttlMs: number) {} + constructor( + private readonly idleTtlMs: number, + private readonly absoluteTtlMs: number, + private readonly now: () => number = Date.now + ) {} + + /** Removes expired sessions; unreferenced ones would otherwise pile up. */ + startSweeping(): void { + setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref(); + } + + sweep(): void { + const now = this.now(); + for (const [id, session] of this.sessions) { + if (this.isExpired(session, now)) this.sessions.delete(id); + } + } create(userId: string): Session { + this.enforcePerUserLimit(userId); + const now = this.now(); const session: Session = { id: randomUUID(), userId, - expiresAt: Date.now() + this.ttlMs, + createdAt: now, + expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs), }; this.sessions.set(session.id, session); return session; @@ -31,20 +57,28 @@ export class SessionStore { if (!sessionId) return undefined; const session = this.sessions.get(sessionId); if (!session) return undefined; - if (session.expiresAt < Date.now()) { + const now = this.now(); + if (this.isExpired(session, now)) { this.sessions.delete(sessionId); return undefined; } - if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs; + if (slideExpiry) { + session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs); + } return session; } + /** Remaining lifetime, used as cookie max-age so browser and server agree. */ + remainingMs(session: Session): number { + return Math.max(0, session.expiresAt - this.now()); + } + destroy(sessionId: string | undefined): void { if (!sessionId) return; this.sessions.delete(sessionId); } - /** Ends every other session for a user, e.g. after a password change. */ + /** Ends every session of a user, optionally except one (e.g. the caller's own). */ destroyAllForUser(userId: string, exceptSessionId?: string): void { for (const [id, session] of this.sessions) { if (session.userId === userId && id !== exceptSessionId) { @@ -52,4 +86,20 @@ export class SessionStore { } } } + + private isExpired(session: Session, now: number): boolean { + return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now; + } + + // Oldest sessions go first, so a legitimate user logging in on a new + // device is never refused - only memory growth is bounded. + private enforcePerUserLimit(userId: string): void { + const own = [...this.sessions.values()] + .filter((session) => session.userId === userId) + .sort((a, b) => a.createdAt - b.createdAt); + while (own.length >= MAX_SESSIONS_PER_USER) { + const oldest = own.shift(); + if (oldest) this.sessions.delete(oldest.id); + } + } } diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts index 751f324..1b5ce09 100644 --- a/src/server/composition/auth.ts +++ b/src/server/composition/auth.ts @@ -1,8 +1,11 @@ import { readAuthConfig } from "../auth/auth-config.js"; +import { LoginThrottle } from "../auth/login-throttle.js"; import { SessionStore } from "../auth/sessions.js"; import { UserService } from "../../domain/services/user.service.js"; import { userRepository } from "./application-repositories.js"; export const authConfig = readAuthConfig(); -export const sessionStore = new SessionStore(authConfig.sessionTtlMs); +export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs); +sessionStore.startSweeping(); +export const loginThrottle = new LoginThrottle(); export const userService = new UserService(userRepository, authConfig); diff --git a/src/server/controllers/auth.controller.ts b/src/server/controllers/auth.controller.ts index 9dabee5..5eaf699 100644 --- a/src/server/controllers/auth.controller.ts +++ b/src/server/controllers/auth.controller.ts @@ -1,11 +1,16 @@ import type { Request, Response } from "express"; import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js"; -import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js"; -import { authConfig, sessionStore, userService } from "../composition/auth.js"; +import { + InvalidCredentialsError, + PasswordUnchangedError, + WrongPasswordError, +} from "../../domain/services/user.service.js"; +import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js"; import { cookieOptions, identityFromRequest, sessionIdFromRequest, + setSessionCookie, } from "../auth/auth.middleware.js"; import { SESSION_COOKIE } from "../auth/auth-config.js"; import { userRepository } from "../composition/application-repositories.js"; @@ -13,9 +18,7 @@ import { userRepository } from "../composition/application-repositories.js"; export async function getAuthStatus(req: Request, res: Response) { const identity = await identityFromRequest(req, sessionStore, userRepository); const sessionId = sessionIdFromRequest(req); - if (identity && sessionId) { - res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig)); - } + if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId); res.json({ signedIn: Boolean(identity), mustChangePassword: identity?.mustChangePassword ?? false, @@ -28,13 +31,29 @@ export async function login(req: Request, res: Response) { res.status(401).json({ error: "Anmeldung fehlgeschlagen." }); return; } + const { username, password } = parsed.data; + + const retryAfterMs = loginThrottle.retryAfterMs(username); + if (retryAfterMs > 0) { + res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString()); + res.status(429).json({ + error: "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.", + }); + return; + } + try { - const user = await userService.authenticate(parsed.data.username, parsed.data.password); + const user = await userService.authenticate(username, password); + loginThrottle.recordSuccess(username); + // A fresh session id on every login: an id planted before login + // (session fixation) never becomes authenticated. + sessionStore.destroy(sessionIdFromRequest(req)); const session = sessionStore.create(user.id); - res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig)); + setSessionCookie(res, authConfig, sessionStore, session.id); res.json({ signedIn: true, mustChangePassword: user.mustChangePassword }); } catch (error) { if (error instanceof InvalidCredentialsError) { + loginThrottle.recordFailure(username); res.status(401).json({ error: error.message }); return; } @@ -59,7 +78,7 @@ export async function changePassword(req: Request, res: Response) { } const parsed = changePasswordSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: parsed.error.flatten() }); + res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." }); return; } try { @@ -69,20 +88,22 @@ export async function changePassword(req: Request, res: Response) { parsed.data.newPassword ); } catch (error) { - if (error instanceof WrongPasswordError) { + if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) { res.status(400).json({ error: error.message }); return; } throw error; } - // A password change invalidates every other session of this account; the - // current one stays so the change doesn't log the user out themselves. - sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req)); + // Every session of this account ends - including the current one, which is + // replaced by a fresh id so the user stays signed in on this device only. + sessionStore.destroyAllForUser(identity.id); + const session = sessionStore.create(identity.id); + setSessionCookie(res, authConfig, sessionStore, session.id); res.json({ changed: true }); } export function logout(req: Request, res: Response) { sessionStore.destroy(sessionIdFromRequest(req)); - res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined }); + res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig, 0), maxAge: undefined }); res.json({ signedOut: true }); } diff --git a/src/server/controllers/user.controller.ts b/src/server/controllers/user.controller.ts index 0ad3cbc..bdf8d0f 100644 --- a/src/server/controllers/user.controller.ts +++ b/src/server/controllers/user.controller.ts @@ -1,11 +1,12 @@ import type { Request, Response } from "express"; +import type { ZodError } from "zod"; import { createUserSchema, resetPasswordSchema, updateUserSchema, } from "../../shared/validation/user.schemas.js"; -import { UsernameTakenError } from "../../domain/services/user.service.js"; -import { userService } from "../composition/auth.js"; +import { LastAdminError, UsernameTakenError } from "../../domain/services/user.service.js"; +import { sessionStore, userService } from "../composition/auth.js"; import { userRepository } from "../composition/application-repositories.js"; // The password hash never leaves this layer. @@ -29,6 +30,10 @@ function toDto(user: { }; } +function firstIssue(error: ZodError): string { + return error.issues[0]?.message ?? "Ungültige Eingabe."; +} + export async function listUsers(_req: Request, res: Response) { const rows = await userRepository.list(); res.json(rows.map(toDto)); @@ -37,7 +42,7 @@ export async function listUsers(_req: Request, res: Response) { export async function createUser(req: Request, res: Response) { const parsed = createUserSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: parsed.error.flatten() }); + res.status(400).json({ error: firstIssue(parsed.error) }); return; } try { @@ -64,7 +69,7 @@ export async function updateUser(req: Request, res: Response) { } const parsed = updateUserSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: parsed.error.flatten() }); + res.status(400).json({ error: firstIssue(parsed.error) }); return; } // The signed-in admin can't lock themselves out by deactivating or @@ -78,8 +83,18 @@ export async function updateUser(req: Request, res: Response) { res.status(404).json({ error: "Benutzer nicht gefunden." }); return; } - const updated = await userService.updateRoleAndActive(userId, parsed.data); - res.json(updated ? toDto(updated) : null); + try { + const updated = await userService.updateRoleAndActive(userId, parsed.data); + // Deactivation takes effect immediately, not just at the next login. + if (parsed.data.active === false) sessionStore.destroyAllForUser(userId); + res.json(updated ? toDto(updated) : null); + } catch (error) { + if (error instanceof LastAdminError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } } export async function resetUserPassword(req: Request, res: Response) { @@ -90,7 +105,7 @@ export async function resetUserPassword(req: Request, res: Response) { } const parsed = resetPasswordSchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ error: parsed.error.flatten() }); + res.status(400).json({ error: firstIssue(parsed.error) }); return; } const existing = await userRepository.findById(userId); @@ -99,5 +114,8 @@ export async function resetUserPassword(req: Request, res: Response) { return; } const updated = await userService.resetPassword(userId, parsed.data.newPassword); + // A reset is usually a response to a suspected compromise: every open + // session of that account ends now. + sessionStore.destroyAllForUser(userId); res.json(updated ? toDto(updated) : null); } diff --git a/src/server/index.ts b/src/server/index.ts index 868de79..9a65ce0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,7 +7,11 @@ import { cableSizingRouter } from "./routes/cable-sizing.routes.js"; import { authRouter } from "./routes/auth.routes.js"; import { userRouter } from "./routes/user.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; -import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js"; +import { + createAuthGuard, + requirePasswordChanged, + requireSameOriginForWrites, +} from "./auth/auth.middleware.js"; import { authConfig, sessionStore, userService } from "./composition/auth.js"; import { userRepository } from "./composition/application-repositories.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; @@ -19,6 +23,7 @@ const port = Number(process.env.PORT || 3000); const heartbeatIntervalMs = 5 * 60 * 1000; const shutdownTimeoutMs = 15 * 1000; +app.disable("x-powered-by"); app.use(express.json({ limit: "25mb" })); app.use((req, res, next) => { @@ -59,6 +64,7 @@ app.get("/health", (_req, res) => { // /auth/login, /auth/status and /auth/logout (see createAuthGuard's // openPaths). authRouter is mounted AFTER the guard (not before) so that // /auth/me and /auth/change-password still get req.authUser populated by it. +app.use("/api", requireSameOriginForWrites()); app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository)); app.use("/api", requirePasswordChanged()); diff --git a/src/shared/validation/auth.schemas.ts b/src/shared/validation/auth.schemas.ts index 544b052..7d95eec 100644 --- a/src/shared/validation/auth.schemas.ts +++ b/src/shared/validation/auth.schemas.ts @@ -1,15 +1,20 @@ import { z } from "zod"; +// Upper bounds keep a huge request body from being fed into scrypt. +export const MAX_USERNAME_LENGTH = 100; +export const MAX_PASSWORD_LENGTH = 200; +export const MIN_PASSWORD_LENGTH = 8; + export const loginSchema = z .object({ - username: z.string().trim().min(1), - password: z.string().min(1), + username: z.string().trim().min(1).max(MAX_USERNAME_LENGTH), + password: z.string().min(1).max(MAX_PASSWORD_LENGTH), }) .strict(); export const changePasswordSchema = z .object({ - currentPassword: z.string().min(1), - newPassword: z.string().min(8).max(200), + currentPassword: z.string().min(1).max(MAX_PASSWORD_LENGTH), + newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), }) .strict(); diff --git a/src/shared/validation/user.schemas.ts b/src/shared/validation/user.schemas.ts index ceb8961..cec4c5f 100644 --- a/src/shared/validation/user.schemas.ts +++ b/src/shared/validation/user.schemas.ts @@ -1,14 +1,29 @@ import { z } from "zod"; import { USER_ROLES } from "../constants/user-roles.js"; +import { + MAX_PASSWORD_LENGTH, + MAX_USERNAME_LENGTH, + MIN_PASSWORD_LENGTH, +} from "./auth.schemas.js"; -const MIN_PASSWORD_LENGTH = 8; +// ASCII only: rules out look-alike Unicode names ("admіn" with a Cyrillic i) +// and invisible whitespace. E-mail addresses fit this pattern. +const USERNAME_PATTERN = /^[a-z0-9._@-]+$/; export const createUserSchema = z .object({ - username: z.string().trim().min(1).max(200), + username: z + .string() + .trim() + .toLowerCase() + .min(1) + .max(MAX_USERNAME_LENGTH) + .regex(USERNAME_PATTERN, { + message: "Anmeldename: nur a-z, 0-9 sowie . _ @ - erlaubt.", + }), name: z.string().trim().min(1).max(200), role: z.enum(USER_ROLES), - initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), }) .strict(); @@ -24,7 +39,7 @@ export const updateUserSchema = z export const resetPasswordSchema = z .object({ - newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200), + newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH), }) .strict(); diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts new file mode 100644 index 0000000..e634716 --- /dev/null +++ b/tests/authentication.test.ts @@ -0,0 +1,265 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Request, Response } from "express"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { createDatabaseContext } from "../src/db/database-context.js"; +import { UserRepository } from "../src/db/repositories/user.repository.js"; +import { + InvalidCredentialsError, + LastAdminError, + PasswordUnchangedError, + UserService, + UsernameTakenError, + WrongPasswordError, +} from "../src/domain/services/user.service.js"; +import { hashPassword, verifyPassword } from "../src/server/auth/password.js"; +import { SessionStore } from "../src/server/auth/sessions.js"; +import { LoginThrottle } from "../src/server/auth/login-throttle.js"; +import { readCookie } from "../src/server/auth/cookie.js"; +import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js"; +import type { AuthConfig } from "../src/server/auth/auth-config.js"; +import { createUserSchema } from "../src/shared/validation/user.schemas.js"; +import { loginSchema } from "../src/shared/validation/auth.schemas.js"; + +const HOUR = 60 * 60 * 1000; + +function createUserService() { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + const repository = new UserRepository(context.db); + const config: AuthConfig = { + sessionTtlMs: 12 * HOUR, + sessionMaxAgeMs: 7 * 24 * HOUR, + cookieSecure: false, + bootstrap: { username: "admin", password: "bootstrap-password-123" }, + }; + return { repository, service: new UserService(repository, config) }; +} + +describe("password hashing", () => { + it("verifies the right password and rejects a wrong one", async () => { + const hash = await hashPassword("richtiges-passwort"); + assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/); + assert.equal(await verifyPassword("richtiges-passwort", hash), true); + assert.equal(await verifyPassword("falsches-passwort", hash), false); + }); + + it("uses a fresh salt per hash", async () => { + assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich")); + }); + + it("rejects malformed stored hashes instead of throwing", async () => { + assert.equal(await verifyPassword("x", "not-a-hash"), false); + assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false); + }); +}); + +describe("session store", () => { + it("expires idle sessions and slides on activity", () => { + let now = 0; + const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now); + const session = store.create("user-1"); + now = 50 * 60 * 1000; + assert.ok(store.get(session.id)); + now += 50 * 60 * 1000; + assert.ok(store.get(session.id), "activity must have extended the idle window"); + now += 2 * HOUR; + assert.equal(store.get(session.id), undefined); + }); + + it("never outlives the absolute lifetime, even when constantly used", () => { + let now = 0; + const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now); + const session = store.create("user-1"); + for (let step = 0; step < 5; step += 1) { + now += 30 * 60 * 1000; + store.get(session.id); + } + now = 3 * HOUR; + assert.equal(store.get(session.id), undefined); + }); + + it("ends all sessions of a user except an optional one", () => { + const store = new SessionStore(HOUR, 24 * HOUR); + const first = store.create("user-1"); + const second = store.create("user-1"); + const other = store.create("user-2"); + store.destroyAllForUser("user-1", second.id); + assert.equal(store.get(first.id), undefined); + assert.ok(store.get(second.id)); + assert.ok(store.get(other.id)); + }); + + it("bounds the number of sessions per user by dropping the oldest", () => { + let now = 0; + const store = new SessionStore(HOUR, 24 * HOUR, () => now); + const oldest = store.create("user-1"); + for (let index = 0; index < 20; index += 1) { + now += 1; + store.create("user-1"); + } + assert.equal(store.get(oldest.id), undefined); + }); +}); + +describe("login throttle", () => { + it("blocks a username after repeated failures and releases it after the window", () => { + let now = 0; + const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now); + for (let attempt = 0; attempt < 3; attempt += 1) { + assert.equal(throttle.retryAfterMs("Admin"), 0); + throttle.recordFailure("admin "); + } + assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter"); + assert.equal(throttle.retryAfterMs("someone-else"), 0); + now = 15 * 60 * 1000 + 1; + assert.equal(throttle.retryAfterMs("admin"), 0); + }); + + it("resets the counter after a successful login", () => { + const throttle = new LoginThrottle(2); + throttle.recordFailure("admin"); + throttle.recordSuccess("admin"); + throttle.recordFailure("admin"); + assert.equal(throttle.retryAfterMs("admin"), 0); + }); +}); + +describe("cookie parsing", () => { + it("reads the session cookie and ignores malformed encodings", () => { + assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc"); + assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined); + assert.equal(readCookie(undefined, "lb_session"), undefined); + }); +}); + +describe("same-origin guard for writes", () => { + function run(method: string, fetchSite?: string) { + let status: number | undefined; + let passed = false; + const req = { + method, + headers: fetchSite ? { "sec-fetch-site": fetchSite } : {}, + } as unknown as Request; + const res = { + status(code: number) { + status = code; + return this; + }, + json() { + return this; + }, + } as unknown as Response; + requireSameOriginForWrites()(req, res, () => { + passed = true; + }); + return { passed, status }; + } + + it("lets reads and same-origin writes through", () => { + assert.equal(run("GET", "cross-site").passed, true); + assert.equal(run("POST", "same-origin").passed, true); + assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie"); + }); + + it("rejects writes from other sites and other ports of the same host", () => { + assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 }); + assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 }); + assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 }); + }); +}); + +describe("input validation", () => { + it("rejects look-alike and whitespace usernames and normalizes case", () => { + const base = { name: "Test", role: "user", initialPassword: "start-passwort" }; + assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false); + assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false); + const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " }); + assert.equal(parsed.username, "max.muster@firma.de"); + }); + + it("caps login input length so huge bodies never reach scrypt", () => { + assert.equal( + loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success, + false + ); + }); +}); + +describe("user service", () => { + it("creates the bootstrap admin only while the table is empty", async () => { + const { repository, service } = createUserService(); + await service.ensureBootstrapAdmin(); + await service.ensureBootstrapAdmin(); + const users = await repository.list(); + assert.equal(users.length, 1); + assert.equal(users[0].role, "admin"); + assert.equal(users[0].mustChangePassword, true); + }); + + it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => { + const { service } = createUserService(); + const user = await service.createUser({ + username: "max", + name: "Max", + role: "user", + initialPassword: "start-passwort", + }); + assert.ok(user); + await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError); + await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError); + assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id); + await service.updateRoleAndActive(user.id, { active: false }); + await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError); + }); + + it("refuses duplicate usernames", async () => { + const { service } = createUserService(); + const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" }; + await service.createUser(input); + await assert.rejects(service.createUser(input), UsernameTakenError); + }); + + it("never removes the last active admin", async () => { + const { repository, service } = createUserService(); + await service.ensureBootstrapAdmin(); + const [admin] = await repository.list(); + await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError); + await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError); + + const second = await service.createUser({ + username: "zweiter", + name: "Zweiter", + role: "admin", + initialPassword: "start-passwort", + }); + assert.ok(second); + await service.updateRoleAndActive(admin.id, { role: "user" }); + await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError); + }); + + it("requires the current password and a different new one", async () => { + const { service } = createUserService(); + const user = await service.createUser({ + username: "max", + name: "Max", + role: "user", + initialPassword: "start-passwort", + }); + assert.ok(user); + await assert.rejects( + service.changeOwnPassword(user.id, "falsch", "neues-passwort"), + WrongPasswordError + ); + await assert.rejects( + service.changeOwnPassword(user.id, "start-passwort", "start-passwort"), + PasswordUnchangedError + ); + const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort"); + assert.equal(changed?.mustChangePassword, false); + + const reset = await service.resetPassword(user.id, "admin-vergeben"); + assert.equal(reset?.mustChangePassword, true); + }); +}); -- 2.49.1 From 728132e52a2cdf230a0a86a65d57da5f03e454c1 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Mon, 28 Sep 2026 21:47:30 +0200 Subject: [PATCH 4/6] Warn at startup when no user exists and no bootstrap admin is configured Also document that Sec-Fetch-Site is only sent over HTTPS, so plain-HTTP LAN access relies on SameSite=Lax alone. Co-Authored-By: Claude Opus 5.5 --- docs/authentication.md | 1 + src/domain/services/user.service.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/authentication.md b/docs/authentication.md index 13a9f72..15729aa 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -53,6 +53,7 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. | Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash | | Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung | | CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten | +| CSRF über reines HTTP | Browser senden `Sec-Fetch-Site` nur an HTTPS-Adressen. Beim Zugriff per `http://…` im LAN greift deshalb nur SameSite=Lax (schützt gegen fremde Domains, nicht gegen andere Ports desselben Hosts). | | Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel | | Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) | | Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen | diff --git a/src/domain/services/user.service.ts b/src/domain/services/user.service.ts index 0968d02..1a3c27c 100644 --- a/src/domain/services/user.service.ts +++ b/src/domain/services/user.service.ts @@ -63,9 +63,14 @@ export class UserService { // Warm the dummy hash at startup so even the first unknown-user login // costs one scrypt run, not two. await this.getDummyHash(); - if (!this.authConfig.bootstrap) return; const existingCount = await this.repository.count(); if (existingCount > 0) return; + if (!this.authConfig.bootstrap) { + logger.warn( + "no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in" + ); + return; + } const passwordHash = await hashPassword(this.authConfig.bootstrap.password); await this.repository.create({ username: this.authConfig.bootstrap.username, -- 2.49.1 From 847f4e9493c1c6fb55d63c7728d176c781592f0a Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Mon, 28 Sep 2026 22:42:03 +0200 Subject: [PATCH 5/6] Harden user management integration --- src/app/config/layout.tsx | 27 +++---- src/app/config/page.tsx | 4 +- src/db/repositories/user.repository.ts | 84 ++++++++++++++------- src/db/schema/users.ts | 3 +- src/domain/ports/user.store.ts | 40 ++++++++++ src/domain/services/user.service.ts | 64 ++++++++-------- src/frontend/components/Sidebar.tsx | 24 +++--- src/frontend/components/cable-sizing-api.ts | 9 ++- src/frontend/utils/api.ts | 5 ++ src/frontend/utils/auth-api.ts | 11 ++- src/frontend/utils/config-access.ts | 25 ++++++ src/server/auth/auth.middleware.ts | 23 ++++-- src/server/auth/password.ts | 44 +++++++++-- src/server/composition/auth.ts | 7 +- src/shared/constants/request-security.ts | 2 + tests/authentication.test.ts | 67 ++++++++++++++-- tests/config-access.test.ts | 26 +++++++ tests/write-request-headers.test.ts | 40 ++++++++++ 18 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 src/domain/ports/user.store.ts create mode 100644 src/frontend/utils/config-access.ts create mode 100644 src/shared/constants/request-security.ts create mode 100644 tests/config-access.test.ts create mode 100644 tests/write-request-headers.test.ts diff --git a/src/app/config/layout.tsx b/src/app/config/layout.tsx index 1356d78..72d9b6d 100644 --- a/src/app/config/layout.tsx +++ b/src/app/config/layout.tsx @@ -4,26 +4,27 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "../../frontend/components/AuthProvider"; - -const CONFIG_TABS = [ - { href: "/config/benutzer", label: "Benutzer" }, - { href: "/config/konto", label: "Konto" }, -] as const; +import { + getConfigRedirect, + getConfigTabs, +} from "../../frontend/utils/config-access"; export default function ConfigLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); const { status, isAdmin } = useAuth(); + const tabs = getConfigTabs(isAdmin); + const redirectTarget = getConfigRedirect(pathname, isAdmin); useEffect(() => { - // LoginGate already guarantees a signed-in user with a set password by - // the time this layout renders; only the role still needs checking. - if (status === "signed-in" && !isAdmin) { - router.replace("/"); - } - }, [status, isAdmin, router]); + if (status !== "signed-in") return; + if (redirectTarget) router.replace(redirectTarget); + }, [status, redirectTarget, router]); - if (status !== "signed-in" || !isAdmin) { + if ( + status !== "signed-in" || + redirectTarget !== null + ) { return null; } @@ -34,7 +35,7 @@ export default function ConfigLayout({ children }: { children: React.ReactNode }
- {isAdmin && ( - - )} +
{user && !isCollapsed && (
diff --git a/src/frontend/components/cable-sizing-api.ts b/src/frontend/components/cable-sizing-api.ts index 28530d2..73582b5 100644 --- a/src/frontend/components/cable-sizing-api.ts +++ b/src/frontend/components/cable-sizing-api.ts @@ -6,6 +6,10 @@ import type { CableSizingInput, CableSizingResult, } from "../../cable-sizing/domain/cable-sizing-calculation"; +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../../shared/constants/request-security"; export interface CalculateCableSizingResponse { result: CableSizingResult; @@ -17,7 +21,10 @@ export async function calculateCableSizing( ): Promise { const response = await fetch("/api/cable-sizing/calculate", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, + }, body: JSON.stringify(input), cache: "no-store", }); diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 8f5a878..5256195 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,3 +1,7 @@ +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../../shared/constants/request-security"; import type { BuildingDto, CircuitListDto, @@ -122,6 +126,7 @@ async function request(url: string, init?: RequestInit): Promise { ...init, headers: { "Content-Type": "application/json", + [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, ...init?.headers, }, cache: "no-store", diff --git a/src/frontend/utils/auth-api.ts b/src/frontend/utils/auth-api.ts index f7057f9..a6f12b3 100644 --- a/src/frontend/utils/auth-api.ts +++ b/src/frontend/utils/auth-api.ts @@ -1,3 +1,8 @@ +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../../shared/constants/request-security"; + // Small, self-contained fetch helper for the auth/user endpoints - kept // separate from the large domain api.ts so this feature doesn't have to // touch that file's shared `request()` internals. @@ -39,7 +44,11 @@ export class AuthApiError extends Error { async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, + headers: { + "Content-Type": "application/json", + [WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE, + ...init?.headers, + }, cache: "no-store", }); if (!response.ok) { diff --git a/src/frontend/utils/config-access.ts b/src/frontend/utils/config-access.ts new file mode 100644 index 0000000..86dd2a2 --- /dev/null +++ b/src/frontend/utils/config-access.ts @@ -0,0 +1,25 @@ +export interface ConfigNavigationEntry { + href: string; + label: string; +} + +const ACCOUNT_ENTRY = { href: "/config/konto", label: "Konto" } as const; +const USERS_ENTRY = { href: "/config/benutzer", label: "Benutzer" } as const; + +export function getConfigTabs(isAdmin: boolean): ConfigNavigationEntry[] { + return isAdmin ? [USERS_ENTRY, ACCOUNT_ENTRY] : [ACCOUNT_ENTRY]; +} + +export function getConfigEntry(isAdmin: boolean): ConfigNavigationEntry { + return isAdmin ? { href: USERS_ENTRY.href, label: "Config" } : ACCOUNT_ENTRY; +} + +export function getConfigRedirect(pathname: string | null, isAdmin: boolean): string | null { + if (pathname === "/config") { + return isAdmin ? USERS_ENTRY.href : ACCOUNT_ENTRY.href; + } + if (!isAdmin && pathname?.startsWith(USERS_ENTRY.href)) { + return ACCOUNT_ENTRY.href; + } + return null; +} diff --git a/src/server/auth/auth.middleware.ts b/src/server/auth/auth.middleware.ts index ce65bfb..bafae03 100644 --- a/src/server/auth/auth.middleware.ts +++ b/src/server/auth/auth.middleware.ts @@ -2,8 +2,12 @@ import type { NextFunction, Request, RequestHandler, Response } from "express"; import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js"; import { readCookie } from "./cookie.js"; import type { SessionStore } from "./sessions.js"; -import type { UserRepository } from "../../db/repositories/user.repository.js"; +import type { UserStore } from "../../domain/ports/user.store.js"; import type { UserRole } from "../../shared/constants/user-roles.js"; +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../../shared/constants/request-security.js"; export interface AuthUser { id: string; @@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: { export async function identityFromRequest( req: { headers: { cookie?: string | undefined } }, sessions: SessionStore, - users: UserRepository, + users: Pick, slideExpiry = true ): Promise { const session = sessions.get(sessionIdFromRequest(req), slideExpiry); @@ -74,10 +78,11 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); * CSRF defense for cookie-authenticated writes. * * SameSite=Lax alone is not enough here: every other service on the same - * host (other ports of the same IP) counts as "same-site" and would get the - * cookie attached. Browsers send Sec-Fetch-Site on every request, so a write - * is only accepted from the app's own origin. Clients without the header - * (curl, scripts) carry no ambient browser cookie and are not a CSRF vector. + * host (other ports of the same IP) counts as "same-site" and may receive the + * cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN. + * Every write therefore needs an application header that HTML forms cannot + * set; a cross-origin fetch would require a CORS preflight, which the API does + * not permit. Sec-Fetch-Site remains an additional browser-side check. */ export function requireSameOriginForWrites(): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { @@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler { next(); return; } + if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) { + res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." }); + return; + } const fetchSite = req.headers["sec-fetch-site"]; if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") { next(); @@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler { export function createAuthGuard( authConfig: AuthConfig, sessions: SessionStore, - users: UserRepository, + users: Pick, openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"] ): RequestHandler { return (req: Request, res: Response, next: NextFunction) => { diff --git a/src/server/auth/password.ts b/src/server/auth/password.ts index 96814e1..97132af 100644 --- a/src/server/auth/password.ts +++ b/src/server/auth/password.ts @@ -4,6 +4,12 @@ const SCRYPT_N = 16384; const SCRYPT_R = 8; const SCRYPT_P = 1; const KEY_LENGTH = 64; +const SALT_LENGTH = 16; +const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024; +const MIN_SCRYPT_N = 1 << 14; +const MAX_SCRYPT_N = 1 << 16; +const MAX_SCRYPT_R = 16; +const MAX_SCRYPT_P = 8; // A hand-rolled wrapper instead of util.promisify(scrypt): promisify only // resolves to scrypt's 3-argument overload, so the 4-argument form used for @@ -30,8 +36,13 @@ function scryptAsync( // Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the // hash so they can be tuned later without invalidating existing rows. export async function hashPassword(password: string): Promise { - const salt = randomBytes(16); - const derivedKey = await scryptAsync(password, salt, KEY_LENGTH); + const salt = randomBytes(SALT_LENGTH); + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + maxmem: MAX_SCRYPT_MEMORY, + }); return [ "scrypt", SCRYPT_N, @@ -52,12 +63,33 @@ export async function verifyPassword( const n = Number(nRaw); const r = Number(rRaw); const p = Number(pRaw); - if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) { + if ( + !Number.isSafeInteger(n) || + n < MIN_SCRYPT_N || + n > MAX_SCRYPT_N || + (n & (n - 1)) !== 0 || + !Number.isSafeInteger(r) || + r < 1 || + r > MAX_SCRYPT_R || + !Number.isSafeInteger(p) || + p < 1 || + p > MAX_SCRYPT_P || + !/^[0-9a-f]{32}$/i.test(saltHex) || + !/^[0-9a-f]{128}$/i.test(hashHex) + ) { return false; } const salt = Buffer.from(saltHex, "hex"); const expected = Buffer.from(hashHex, "hex"); - const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p }); - if (derivedKey.length !== expected.length) return false; - return timingSafeEqual(derivedKey, expected); + try { + const derivedKey = await scryptAsync(password, salt, expected.length, { + N: n, + r, + p, + maxmem: MAX_SCRYPT_MEMORY, + }); + return timingSafeEqual(derivedKey, expected); + } catch { + return false; + } } diff --git a/src/server/composition/auth.ts b/src/server/composition/auth.ts index 1b5ce09..07b822b 100644 --- a/src/server/composition/auth.ts +++ b/src/server/composition/auth.ts @@ -3,9 +3,14 @@ import { LoginThrottle } from "../auth/login-throttle.js"; import { SessionStore } from "../auth/sessions.js"; import { UserService } from "../../domain/services/user.service.js"; import { userRepository } from "./application-repositories.js"; +import { hashPassword, verifyPassword } from "../auth/password.js"; export const authConfig = readAuthConfig(); export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs); sessionStore.startSweeping(); export const loginThrottle = new LoginThrottle(); -export const userService = new UserService(userRepository, authConfig); +export const userService = new UserService( + userRepository, + { bootstrap: authConfig.bootstrap }, + { hash: hashPassword, verify: verifyPassword } +); diff --git a/src/shared/constants/request-security.ts b/src/shared/constants/request-security.ts new file mode 100644 index 0000000..f89ce10 --- /dev/null +++ b/src/shared/constants/request-security.ts @@ -0,0 +1,2 @@ +export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request"; +export const WRITE_REQUEST_HEADER_VALUE = "1"; diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts index e634716..e5c431d 100644 --- a/tests/authentication.test.ts +++ b/tests/authentication.test.ts @@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j import type { AuthConfig } from "../src/server/auth/auth-config.js"; import { createUserSchema } from "../src/shared/validation/user.schemas.js"; import { loginSchema } from "../src/shared/validation/auth.schemas.js"; +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../src/shared/constants/request-security.js"; const HOUR = 60 * 60 * 1000; @@ -34,7 +38,14 @@ function createUserService() { cookieSecure: false, bootstrap: { username: "admin", password: "bootstrap-password-123" }, }; - return { repository, service: new UserService(repository, config) }; + return { + repository, + service: new UserService( + repository, + { bootstrap: config.bootstrap }, + { hash: hashPassword, verify: verifyPassword } + ), + }; } describe("password hashing", () => { @@ -52,6 +63,17 @@ describe("password hashing", () => { it("rejects malformed stored hashes instead of throwing", async () => { assert.equal(await verifyPassword("x", "not-a-hash"), false); assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false); + assert.equal( + await verifyPassword( + "x", + `scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}` + ), + false + ); + assert.equal( + await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`), + false + ); }); }); @@ -135,12 +157,20 @@ describe("cookie parsing", () => { }); describe("same-origin guard for writes", () => { - function run(method: string, fetchSite?: string) { + function run(method: string, fetchSite?: string, includeApplicationHeader = true) { let status: number | undefined; let passed = false; const req = { method, - headers: fetchSite ? { "sec-fetch-site": fetchSite } : {}, + headers: { + ...(fetchSite ? { "sec-fetch-site": fetchSite } : {}), + ...(includeApplicationHeader + ? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE } + : {}), + }, + get(name: string) { + return this.headers[name.toLowerCase() as keyof typeof this.headers]; + }, } as unknown as Request; const res = { status(code: number) { @@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => { } it("lets reads and same-origin writes through", () => { - assert.equal(run("GET", "cross-site").passed, true); + assert.equal(run("GET", "cross-site", false).passed, true); assert.equal(run("POST", "same-origin").passed, true); - assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie"); + assert.equal(run("POST").passed, true, "scripts can send the explicit application header"); }); - it("rejects writes from other sites and other ports of the same host", () => { + it("rejects writes without the application header or from another origin", () => { + assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 }); assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 }); assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 }); assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 }); @@ -239,6 +270,30 @@ describe("user service", () => { await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError); }); + it("preserves one active admin across concurrent updates", async () => { + const { repository, service } = createUserService(); + await service.ensureBootstrapAdmin(); + const second = await service.createUser({ + username: "zweiter-admin", + name: "Zweiter Admin", + role: "admin", + initialPassword: "start-passwort", + }); + assert.ok(second); + const [first] = (await repository.list()).filter((user) => user.username === "admin"); + + const results = await Promise.allSettled([ + service.updateRoleAndActive(first.id, { role: "user" }), + service.updateRoleAndActive(second.id, { role: "user" }), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); + const activeAdmins = (await repository.list()).filter( + (user) => user.role === "admin" && user.active + ); + assert.equal(activeAdmins.length, 1); + }); + it("requires the current password and a different new one", async () => { const { service } = createUserService(); const user = await service.createUser({ diff --git a/tests/config-access.test.ts b/tests/config-access.test.ts new file mode 100644 index 0000000..3fdc83d --- /dev/null +++ b/tests/config-access.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getConfigEntry, + getConfigRedirect, + getConfigTabs, +} from "../src/frontend/utils/config-access.js"; + +describe("config access", () => { + it("offers account settings to every signed-in user", () => { + assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" }); + assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]); + assert.equal(getConfigRedirect("/config", false), "/config/konto"); + assert.equal(getConfigRedirect("/config/konto", false), null); + }); + + it("keeps user administration admin-only", () => { + assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto"); + assert.deepEqual(getConfigTabs(true), [ + { href: "/config/benutzer", label: "Benutzer" }, + { href: "/config/konto", label: "Konto" }, + ]); + assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" }); + assert.equal(getConfigRedirect("/config/benutzer", true), null); + }); +}); diff --git a/tests/write-request-headers.test.ts b/tests/write-request-headers.test.ts new file mode 100644 index 0000000..c78a277 --- /dev/null +++ b/tests/write-request-headers.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { authApi } from "../src/frontend/utils/auth-api.js"; +import { createProject } from "../src/frontend/utils/api.js"; +import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js"; +import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js"; +import { + WRITE_REQUEST_HEADER_NAME, + WRITE_REQUEST_HEADER_VALUE, +} from "../src/shared/constants/request-security.js"; + +describe("write request clients", () => { + it("send the application header from every frontend API client", async () => { + const originalFetch = globalThis.fetch; + const requests: RequestInit[] = []; + globalThis.fetch = async (_input, init) => { + requests.push(init ?? {}); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await authApi.login("admin", "start-passwort"); + await createProject("Testprojekt"); + await calculateCableSizing({} as CableSizingInput); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(requests.length, 3); + for (const request of requests) { + assert.equal( + new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME), + WRITE_REQUEST_HEADER_VALUE + ); + } + }); +}); -- 2.49.1 From 3670f02ccdec07dacac7b475ea328dd72dad670a Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Mon, 28 Sep 2026 22:42:11 +0200 Subject: [PATCH 6/6] Document authenticated deployment --- .env.example | 7 +++++++ .gitignore | 3 +++ AGENTS.md | 5 ++++- README.md | 21 ++++++++++++++++---- compose.dev.yaml | 2 ++ compose.yaml | 2 +- docs/authentication.md | 25 ++++++++++++++++++------ docs/current-architecture.md | 14 +++++++++++++ docs/deployment.md | 38 ++++++++++++++++++++++++++++-------- 9 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46fa42a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Nur beim ersten Start mit leerer Benutzertabelle erforderlich. +AUTH_BOOTSTRAP_USERNAME= +AUTH_BOOTSTRAP_PASSWORT= +AUTH_SESSION_HOURS=12 +AUTH_SESSION_MAX_DAYS=7 +# Hinter HTTPS true, bei direktem HTTP-Zugriff im LAN false. +COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore index a6e1ff5..b237969 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ .next/ +.env +.env.* +!.env.example # Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. /drizzle.config.js data/*.db diff --git a/AGENTS.md b/AGENTS.md index 65b5fab..b01e6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ It must support circuits, device rows, project devices, drag-and-drop restructur `src/db/repositories/user.repository.ts`. Every `/api/*` route requires a signed-in user (`src/server/index.ts`); `/api/users/*` additionally requires `admin`. Frontend gate: `AuthProvider` + `LoginGate` - (`src/app/layout.tsx`). See `docs/authentication.md` for the full picture. + (`src/app/layout.tsx`). Every unsafe API request also requires the explicit + `X-Leistungsbilanz-Request: 1` application header, including over plain HTTP. + Account settings are available to every signed-in user; user administration + remains admin-only. See `docs/authentication.md` for the full picture. The supported runtime model is Circuit-First. The former Consumer UI, API, tables and upgrade tooling are removed; do not reintroduce them. diff --git a/README.md b/README.md index b01ae85..f64ae04 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein Docker-Server-Stack mit gebautem Anwendungscode und ein separater Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment -und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau +für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstütztes +Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind +vorhanden. Revit-CSV-Konfiguration, Vorschau und bestätigter Erstimport sind implementiert; die Editoroberfläche zur Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import @@ -60,6 +62,8 @@ Voraussetzungen: ```powershell git clone Set-Location leistungsbilanz-ts +$env:AUTH_BOOTSTRAP_USERNAME = "admin" +$env:AUTH_BOOTSTRAP_PASSWORT = "" docker compose up --build --detach ``` @@ -68,9 +72,15 @@ Danach: - Frontend: - API-Healthcheck: (über den Next.js-Rewrite) -Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine -Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der -Zugriff läuft über den Rewrite im Web-Dienst. +Beim ersten Start erzeugen diese beiden Variablen genau einen Administrator, +solange die Benutzertabelle noch leer ist. Danach werden sie ignoriert. Für +einen dauerhaften Serverbetrieb gehören die Werte in eine nicht versionierte +`.env` neben `compose.yaml`; Details stehen unter +[Anmeldung und Benutzerverwaltung](docs/authentication.md). + +Die Express-API ist nicht auf dem Host veröffentlicht. Sämtliche fachlichen +API-Routen verlangen eine gültige Anmeldung; der Zugriff läuft über den +Rewrite im Web-Dienst. Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start automatisch aus. Die SQLite-Datei liegt auf dem Host unter @@ -116,6 +126,8 @@ Voraussetzungen: npm ci npm run db:migrate npm run db:verify:circuit-schema +$env:AUTH_BOOTSTRAP_USERNAME = "admin" +$env:AUTH_BOOTSTRAP_PASSWORT = "" npm run dev:api ``` @@ -162,6 +174,7 @@ vor der Baseline werden nicht unterstützt. - [Gebäudestruktur und Raumflächen](docs/building-structure.md) - [Grobe Leistungsbilanz](docs/estimate-balance.md) - [Leitungsberechnung](docs/cable-sizing-module.md) +- [Anmeldung und Benutzerverwaltung](docs/authentication.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md) diff --git a/compose.dev.yaml b/compose.dev.yaml index 2c362bc..f49e9e6 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -38,6 +38,8 @@ services: AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" + AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}" + COOKIE_SECURE: "${COOKIE_SECURE:-false}" # Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein. ports: - "127.0.0.1:3000:3000" diff --git a/compose.yaml b/compose.yaml index 2a2eecd..1de9104 100644 --- a/compose.yaml +++ b/compose.yaml @@ -47,7 +47,7 @@ services: LOG_LEVEL: "${LOG_LEVEL:-info}" # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte - # .env - siehe docs/AUTH.md. + # .env - siehe docs/authentication.md. AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}" AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}" AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}" diff --git a/docs/authentication.md b/docs/authentication.md index 15729aa..32039cf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -7,10 +7,9 @@ Zugang wird von einem Administrator über **Config → Benutzer** angelegt. Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`): -- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich - (Benutzerverwaltung, eigenes Konto) +- **admin** – wie `user`, zusätzlich Zugriff auf **Config → Benutzer** - **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten, - Grobbilanz, …), kein Zugriff auf Config + Grobbilanz, …) und Zugriff auf das eigene Konto, keine Benutzerverwaltung Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. @@ -44,6 +43,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. - **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate` (`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App. + **Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für + Administratoren. ## Schutzmechanismen @@ -52,12 +53,12 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle. | Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). | | Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash | | Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung | -| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten | -| CSRF über reines HTTP | Browser senden `Sec-Fetch-Site` nur an HTTPS-Adressen. Beim Zugriff per `http://…` im LAN greift deshalb nur SameSite=Lax (schützt gegen fremde Domains, nicht gegen andere Ports desselben Hosts). | +| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: jeder Schreibzugriff braucht `X-Leistungsbilanz-Request: 1`; fremde HTML-Formulare können den Header nicht setzen und ein fremder Fetch scheitert an der notwendigen CORS-Preflight-Anfrage. Ein vorhandener `Sec-Fetch-Site`-Header wird zusätzlich geprüft. | +| CSRF über reines HTTP | Der eigene Anwendungsheader gilt auch dann, wenn Browser im HTTP-LAN keinen `Sec-Fetch-Site`-Header senden. Skripte und manuelle API-Aufrufe müssen ihn bei `POST`, `PUT`, `PATCH` und `DELETE` ausdrücklich mitsenden. | | Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel | | Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) | | Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen | -| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt | +| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; Prüfung und Änderung laufen atomar in einer SQLite-Transaktion. Der eigene Zugang ist in der Liste gesperrt. | | Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` | | DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt | | Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben | @@ -77,6 +78,18 @@ ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht in `compose.yaml` selbst. +```dotenv +AUTH_BOOTSTRAP_USERNAME=admin +AUTH_BOOTSTRAP_PASSWORT= +AUTH_SESSION_HOURS=12 +AUTH_SESSION_MAX_DAYS=7 +COOKIE_SECURE=false +``` + +Bei Zugriff über HTTPS muss `COOKIE_SECURE=true` gesetzt werden. Bei direktem +HTTP-Zugriff im LAN bleibt es `false`, da der Browser das Session-Cookie sonst +nicht speichert. + ## Bewusst nicht umgesetzt (Stand jetzt) - Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 71b7feb..20c7ccd 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -33,6 +33,15 @@ mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die Datenbank liegt über einen Host-Mount außerhalb des Containers. +Vor allen fachlichen `/api/*`-Routen liegt die Cookie-basierte Anmeldung aus +`src/server/auth`. Nur Login, Status und Logout sind ohne Session erreichbar. +Schreibzugriffe verlangen zusätzlich den Anwendungsheader +`X-Leistungsbilanz-Request: 1`, damit der Schutz auch beim HTTP-Zugriff im LAN +ohne Fetch-Metadata-Header greift. `/api/users/*` ist Administratoren +vorbehalten. Konten sind globale Betriebsdaten und gehören nicht zu +Projektkommandos, Snapshots oder Projekttransfers. Sessions liegen im Speicher +des einzelnen API-Prozesses und enden bei dessen Neustart. + Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls @@ -57,6 +66,11 @@ vor dem Command und bleiben über Undo/Redo unverändert. - `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion, Zellbesitz, Einfügen und Sicherheitsregeln - `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe +- `src/frontend/components/AuthProvider.tsx` und `LoginGate.tsx` – Anmeldung + und erzwungener Wechsel eines Startpassworts +- `src/server/auth/` – Passwort-Hashing, Sessions und Zugriffsschutz +- `src/domain/services/user.service.ts` und `src/domain/ports/user.store.ts` – + kontobezogene Regeln und schmale Persistenzgrenze - `src/server/index.ts` und `src/server/routes/` – API-Komposition - `src/domain/services/` – fachliche Command- und Synchronisierungsregeln - `src/server/composition/` – Verdrahtung fachlicher Services mit konkreten diff --git a/docs/deployment.md b/docs/deployment.md index 0b5baba..89c99a3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,10 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen Entwicklungsrechner. -Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten -Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten -Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf -deshalb nicht öffentlich erreichbar gemacht werden. +Beide Stacks enthalten Anmeldung mit festen Rollen, aber weder TLS, einen +vorgeschalteten Reverse Proxy noch ein zentral betriebenes Datenbanksystem. +Passwörter und Session-Cookies werden bei reinem HTTP unverschlüsselt +übertragen. Der Stack darf deshalb nicht öffentlich erreichbar gemacht werden. ## Topologie @@ -29,9 +29,10 @@ deshalb nicht öffentlich erreichbar gemacht werden. | Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` | | SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | -Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht -(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie -erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem +Die Express-API wird im Server-Stack nicht auf dem Host veröffentlicht +(`expose` statt `ports`). Alle fachlichen API-Routen verlangen eine aktive +Session; Schreibzugriffe zusätzlich den Anwendungsheader +`X-Leistungsbilanz-Request: 1`. Erreichbar ist die API nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. @@ -72,6 +73,26 @@ Verwendete Umgebungsvariablen: JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. +- `AUTH_BOOTSTRAP_USERNAME` und `AUTH_BOOTSTRAP_PASSWORT` – erzeugen nur bei + leerer Benutzertabelle den ersten Administrator; das Startpasswort hat + mindestens 12 Zeichen. +- `AUTH_SESSION_HOURS` – Inaktivitätsgrenze einer Session, Standard 12 Stunden. +- `AUTH_SESSION_MAX_DAYS` – absolute Session-Laufzeit, Standard 7 Tage. +- `COOKIE_SECURE` – hinter HTTPS `true`; beim direkten HTTP-Zugriff im LAN + `false`, da Browser das Cookie sonst nicht speichern. + +Vor dem ersten Start müssen mindestens die Bootstrap-Werte in einer lokalen, +nicht versionierten `.env` neben `compose.yaml` gesetzt werden: + +```dotenv +AUTH_BOOTSTRAP_USERNAME=admin +AUTH_BOOTSTRAP_PASSWORT= +``` + +Nach dem ersten erfolgreichen Start werden diese Werte ignoriert, solange +mindestens ein Benutzer existiert. Weitere Zugänge legt ein Administrator in +**Config → Benutzer** an. Details stehen in +[Anmeldung und Benutzerverwaltung](authentication.md). Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung (`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im @@ -137,7 +158,8 @@ bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie. Vor einer produktiven Installation werden mindestens benötigt: - TLS-Termination und Reverse Proxy -- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell +- zentraler Identitätsanbieter oder eine feinere Rechte-Matrix, falls die zwei + festen Rollen später nicht ausreichen - definierte Secrets- und Konfigurationsverwaltung - persistenter, gesicherter Datenbankbetrieb - kontrollierter einmaliger Migrationsschritt vor dem API-Rollout -- 2.49.1