Compare commits

..

9 commits

Author SHA1 Message Date
68d986ff80 Merge pull request 'User management with login, roles and a Config area' (#25) from feature/user-management into main 2026-09-28 22:43:14 +02:00
3670f02ccd Document authenticated deployment 2026-09-28 22:42:11 +02:00
847f4e9493 Harden user management integration 2026-09-28 22:42:03 +02:00
73579d1e79 Merge branch 'main' into feature/user-management 2026-09-28 22:32:48 +02:00
Grovy311
728132e52a 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 <noreply@anthropic.com>
2026-09-28 21:47:30 +02:00
Grovy311
95bfba2f67 Merge origin/main into feature/user-management
Renumber the users migration to 0013_users so it follows the upstream
0012_good_gateway (logical board feeds); AGENTS.md deferred-work list keeps
both the new feed items and the auth items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:46:21 +02:00
Grovy311
5073d9342a 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 <noreply@anthropic.com>
2026-09-28 21:30:30 +02:00
Grovy311
8d2948cdb1 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 <noreply@anthropic.com>
2026-09-28 20:32:26 +02:00
Grovy311
c56cfb30ad 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 <noreply@anthropic.com>
2026-09-28 19:51:47 +02:00
53 changed files with 6019 additions and 86 deletions

7
.env.example Normal file
View file

@ -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

3
.gitignore vendored
View file

@ -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

View file

@ -36,6 +36,16 @@ 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`). 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.
@ -732,6 +742,11 @@ for the early-phase power balance by DIN 276 cost group (see
- second/replacement board feeds, including changeover semantics for SV/SiBe
- Revit prefill proposals for logical board feeds through the Phase 14 preview
and explicit-confirmation workflow
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
- permanent account lockout (only temporary per-username login throttling
exists)
- 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.

View file

@ -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 <repository-url>
Set-Location leistungsbilanz-ts
$env:AUTH_BOOTSTRAP_USERNAME = "admin"
$env:AUTH_BOOTSTRAP_PASSWORT = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
docker compose up --build --detach
```
@ -68,9 +72,15 @@ Danach:
- Frontend: <http://localhost:3001>
- API-Healthcheck: <http://localhost:3001/health> (ü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 = "<eigenes Startpasswort mit mindestens 12 Zeichen>"
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)

View file

@ -35,8 +35,12 @@ 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}"
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"
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als

View file

@ -45,8 +45,16 @@ 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/authentication.md.
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.
expose:
- "3000"
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit

View file

@ -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

99
docs/authentication.md Normal file
View file

@ -0,0 +1,99 @@
# 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 **Config → Benutzer**
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
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.
## 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) 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/*`
(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.
**Konto** ist für jede angemeldete Person erreichbar; **Benutzer** nur für
Administratoren.
## 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`: 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; 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 |
| 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
`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.
```dotenv
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORT=<eigenes Startpasswort mit mindestens 12 Zeichen>
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
- 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

View file

@ -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

View file

@ -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=<eigenes Startpasswort mit mindestens 12 Zeichen>
```
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

View file

@ -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 [
{

View file

@ -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<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(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<void>) {
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 (
<div className="card" style={{ padding: "1.5rem" }}>
<div className="page-header" style={{ marginTop: 0 }}>
<h2 className="h5" style={{ margin: 0 }}>
Benutzer
</h2>
</div>
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
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.
</p>
<NeuerBenutzerFormular
onCreated={(created) => {
setUsers((list) => [...list, created]);
}}
/>
{error && <p className="text-danger">{error}</p>}
{loading && <p>Lädt…</p>}
{!loading && users.length > 0 && (
<table className="table" style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Name</th>
<th>Anmeldename</th>
<th>Rolle</th>
<th>Aktiv</th>
<th>Status</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{users.map((item) => {
const isSelf = item.id === currentUser?.id;
const busy = busyUserId === item.id;
return (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.username}</td>
<td>
<select
className="form-select form-select-sm"
value={item.role}
disabled={busy || isSelf}
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
>
{USER_ROLES.map((role) => (
<option key={role} value={role}>
{USER_ROLE_LABEL[role]}
</option>
))}
</select>
</td>
<td>
<input
type="checkbox"
checked={item.active}
disabled={busy || isSelf}
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
onChange={() => void toggleActive(item)}
/>
</td>
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
<td>
<button
type="button"
className="btn btn-outline-primary btn-sm"
disabled={busy}
onClick={() => setResetPasswordFor(item)}
>
Passwort zurücksetzen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
{resetPasswordFor && (
<PasswortZuruecksetzenDialog
item={resetPasswordFor}
onClose={() => setResetPasswordFor(null)}
onDone={(updated) => {
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
setResetPasswordFor(null);
}}
/>
)}
</div>
);
}
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
const [username, setUsername] = useState("");
const [name, setName] = useState("");
const [role, setRole] = useState<UserRole>("user");
const [initialPassword, setInitialPassword] = useState("");
const [error, setError] = useState<string | null>(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 (
<form
onSubmit={handleSubmit}
className="row g-2 align-items-end"
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-username">
Anmeldename
</label>
<input
id="new-user-username"
className="form-control form-control-sm"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-name">
Name
</label>
<input
id="new-user-name"
className="form-control form-control-sm"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="col-sm-2">
<label className="form-label" htmlFor="new-user-role">
Rolle
</label>
<select
id="new-user-role"
className="form-select form-select-sm"
value={role}
onChange={(event) => setRole(event.target.value as UserRole)}
>
{USER_ROLES.map((r) => (
<option key={r} value={r}>
{USER_ROLE_LABEL[r]}
</option>
))}
</select>
</div>
<div className="col-sm-3">
<label className="form-label" htmlFor="new-user-password">
Startpasswort
</label>
<input
id="new-user-password"
type="text"
className="form-control form-control-sm"
value={initialPassword}
onChange={(event) => setInitialPassword(event.target.value)}
minLength={8}
required
/>
</div>
<div className="col-sm-1">
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
{submitting ? "…" : "Anlegen"}
</button>
</div>
{error && (
<div className="col-12">
<p className="text-danger mb-0">{error}</p>
</div>
)}
</form>
);
}
function PasswortZuruecksetzenDialog({
item,
onClose,
onDone,
}: {
item: UserDto;
onClose: () => void;
onDone: (updated: UserDto) => void;
}) {
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className="modal-backdrop-inline">
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
<div className="mb-3">
<label className="form-label" htmlFor="reset-password">
Neues Startpasswort
</label>
<input
id="reset-password"
type="text"
className="form-control"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
autoFocus
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Speichert…" : "Zurücksetzen"}
</button>
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Abbrechen
</button>
</div>
</form>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
<h2 className="h5">Konto</h2>
{user && (
<p className="text-body-secondary">
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
{USER_ROLE_LABEL[user.role]}
</p>
)}
<h3 className="h6 mt-4">Passwort ändern</h3>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label" htmlFor="konto-current-password">
Aktuelles Passwort
</label>
<input
id="konto-current-password"
type="password"
className="form-control"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="konto-new-password">
Neues Passwort
</label>
<input
id="konto-new-password"
type="password"
className="form-control"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="konto-new-password-repeat">
Neues Passwort wiederholen
</label>
<input
id="konto-new-password-repeat"
type="password"
className="form-control"
value={newPasswordRepeat}
onChange={(event) => setNewPasswordRepeat(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
{success && <p className="text-success">Passwort wurde geändert.</p>}
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Speichert…" : "Passwort ändern"}
</button>
</form>
</div>
);
}

52
src/app/config/layout.tsx Normal file
View file

@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuth } from "../../frontend/components/AuthProvider";
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(() => {
if (status !== "signed-in") return;
if (redirectTarget) router.replace(redirectTarget);
}, [status, redirectTarget, router]);
if (
status !== "signed-in" ||
redirectTarget !== null
) {
return null;
}
return (
<div className="container-fluid py-4">
<div className="page-header">
<h1>Config</h1>
</div>
<nav className="config-tabs">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
>
{tab.label}
</Link>
))}
</nav>
{children}
</div>
);
}

3
src/app/config/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function ConfigPage() {
return null;
}

View file

@ -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;
}
@ -451,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;
@ -467,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);
}
@ -502,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,
@ -2730,3 +2744,128 @@ 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;
}
/* 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;
flex: 1 1 auto;
min-width: 0;
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;
cursor: pointer;
}
.sidebar-logout:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.sidebar-collapsed .sidebar-identity {
display: none;
}
.sidebar-collapsed .sidebar-bottom-row {
justify-content: center;
padding-inline: 0;
}
.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;
}

View file

@ -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
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<AppShell>{children}</AppShell>
<AuthProvider>
<LoginGate>
<AppShell>{children}</AppShell>
</LoginGate>
</AuthProvider>
</body>
</html>
);

View file

@ -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`);

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,13 @@
"when": 1790540142918,
"tag": "0012_good_gateway",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1790624623732,
"tag": "0013_users",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,117 @@
import crypto from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { users } from "../schema/users.js";
import {
LastActiveAdminStoreError,
UsernameAlreadyExistsStoreError,
type CreateUserAccount,
type UserStore,
} from "../../domain/ports/user.store.js";
import type { UserRole } from "../../shared/constants/user-roles.js";
function isUniqueConstraintError(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
);
}
export class UserRepository implements UserStore {
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: CreateUserAccount) {
const db = this.database;
const id = crypto.randomUUID();
try {
await db.insert(users).values({
id,
username: input.username.trim().toLowerCase(),
name: input.name,
role: input.role,
passwordHash: input.passwordHash,
active: true,
mustChangePassword: true,
});
} catch (error) {
if (isUniqueConstraintError(error)) throw new UsernameAlreadyExistsStoreError();
throw error;
}
return this.findById(id);
}
async updateRoleAndActive(
userId: string,
patch: { role?: UserRole; active?: boolean }
) {
return this.database.transaction((tx) => {
const [target] = tx.select().from(users).where(eq(users.id, userId)).limit(1).all();
if (!target) return null;
const removesActiveAdmin =
target.role === "admin" &&
target.active &&
(patch.role === "user" || patch.active === false);
if (removesActiveAdmin) {
const activeAdmins = tx
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, "admin"), eq(users.active, true)))
.limit(2)
.all();
if (activeAdmins.length <= 1) throw new LastActiveAdminStoreError();
}
tx.update(users)
.set({
...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.active !== undefined ? { active: patch.active } : {}),
updatedAt: new Date(),
})
.where(eq(users.id, userId))
.run();
return tx.select().from(users).where(eq(users.id, userId)).limit(1).all()[0] ?? null;
});
}
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);
}
}

28
src/db/schema/users.ts Normal file
View file

@ -0,0 +1,28 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { UserRole } from "../../shared/constants/user-roles.js";
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").$type<UserRole>().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;

View file

@ -0,0 +1,40 @@
import type { UserRole } from "../../shared/constants/user-roles.js";
export interface UserAccount {
id: string;
username: string;
passwordHash: string;
name: string;
role: UserRole;
active: boolean;
mustChangePassword: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserAccount {
username: string;
name: string;
role: UserRole;
passwordHash: string;
}
export class UsernameAlreadyExistsStoreError extends Error {}
export class LastActiveAdminStoreError extends Error {}
export interface UserStore {
list(): Promise<UserAccount[]>;
count(): Promise<number>;
findById(userId: string): Promise<UserAccount | null>;
findByUsername(username: string): Promise<UserAccount | null>;
create(input: CreateUserAccount): Promise<UserAccount | null>;
updateRoleAndActive(
userId: string,
patch: { role?: UserRole; active?: boolean }
): Promise<UserAccount | null>;
setPassword(
userId: string,
passwordHash: string,
mustChangePassword: boolean
): Promise<UserAccount | null>;
}

View file

@ -0,0 +1,156 @@
import type { UserRole } from "../../shared/constants/user-roles.js";
import { createLogger } from "../../shared/logging/logger.js";
import {
LastActiveAdminStoreError,
UsernameAlreadyExistsStoreError,
type UserStore,
} from "../ports/user.store.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 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.");
}
}
export interface PasswordHasher {
hash(password: string): Promise<string>;
verify(password: string, storedHash: string): Promise<boolean>;
}
export interface UserServiceOptions {
bootstrap: { username: string; password: string } | null;
}
export class UserService {
private dummyHash: Promise<string> | null = null;
constructor(
private readonly repository: UserStore,
private readonly options: UserServiceOptions,
private readonly passwords: PasswordHasher
) {}
/**
* 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<void> {
// Warm the dummy hash at startup so even the first unknown-user login
// costs one scrypt run, not two.
await this.getDummyHash();
const existingCount = await this.repository.count();
if (existingCount > 0) return;
if (!this.options.bootstrap) {
logger.warn(
"no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in"
);
return;
}
const passwordHash = await this.passwords.hash(this.options.bootstrap.password);
await this.repository.create({
username: this.options.bootstrap.username,
name: "Administrator",
role: "admin",
passwordHash,
});
logger.info("bootstrap admin created", {
username: this.options.bootstrap.username,
});
}
async authenticate(username: string, password: string) {
const user = await this.repository.findByUsername(username);
// 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 this.passwords.verify(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 this.passwords.hash(input.initialPassword);
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 (error instanceof UsernameAlreadyExistsStoreError) throw new UsernameTakenError();
throw error;
}
}
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
try {
return await this.repository.updateRoleAndActive(userId, patch);
} catch (error) {
if (error instanceof LastActiveAdminStoreError) throw new LastAdminError();
throw error;
}
}
/** Admin-triggered reset with a new start password; forces a change on next login. */
async resetPassword(userId: string, newPassword: string) {
const passwordHash = await this.passwords.hash(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 this.passwords.verify(currentPassword, user.passwordHash);
if (!currentMatches) throw new WrongPasswordError();
if (newPassword === currentPassword) throw new PasswordUnchangedError();
const passwordHash = await this.passwords.hash(newPassword);
return this.repository.setPassword(userId, passwordHash, false);
}
private getDummyHash(): Promise<string> {
this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login");
return this.dummyHash;
}
}

View file

@ -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<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<AuthUserDto | undefined>(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 (
<AuthContext.Provider
value={{ status, user, isAdmin: user?.role === "admin", reload, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View file

@ -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 (
<div className="login-gate-screen">
<p>Lädt…</p>
</div>
);
}
if (status === "signed-out") {
return (
<div className="login-gate-screen">
<LoginForm onSignedIn={reload} />
</div>
);
}
if (user?.mustChangePassword) {
return (
<div className="login-gate-screen">
<ForcedPasswordChangeForm onChanged={reload} />
</div>
);
}
return <>{children}</>;
}
function LoginForm({ onSignedIn }: { onSignedIn: () => Promise<void> }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(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 (
<form className="login-gate-card card" onSubmit={handleSubmit}>
<h1 className="h4 mb-3">Leistungsbilanz</h1>
<div className="mb-3">
<label className="form-label" htmlFor="login-username">
Anmeldename
</label>
<input
id="login-username"
className="form-control"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoFocus
autoComplete="username"
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="login-password">
Passwort
</label>
<input
id="login-password"
type="password"
className="form-control"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Meldet an…" : "Anmelden"}
</button>
</form>
);
}
function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise<void> }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
const [error, setError] = useState<string | null>(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 (
<form className="login-gate-card card" onSubmit={handleSubmit}>
<h1 className="h4 mb-3">Neues Passwort setzen</h1>
<p className="text-body-secondary">
Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der
weiteren Nutzung ein eigenes Passwort vergeben.
</p>
<div className="mb-3">
<label className="form-label" htmlFor="force-current-password">
Aktuelles (Start-)Passwort
</label>
<input
id="force-current-password"
type="password"
className="form-control"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
autoFocus
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="force-new-password">
Neues Passwort
</label>
<input
id="force-new-password"
type="password"
className="form-control"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="force-new-password-repeat">
Neues Passwort wiederholen
</label>
<input
id="force-new-password-repeat"
type="password"
className="form-control"
value={newPasswordRepeat}
onChange={(event) => setNewPasswordRepeat(event.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
{error && <p className="text-danger">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Speichert…" : "Passwort setzen"}
</button>
</form>
);
}

View file

@ -6,6 +6,8 @@ import { useEffect, useState } from "react";
import { listProjects } from "../utils/api";
import type { ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "./AuthProvider";
import { getConfigEntry } from "../utils/config-access";
// Anchors on the project overview page (/projects/[id]).
const PROJECT_SECTIONS = [
@ -30,6 +32,8 @@ interface SidebarProps {
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname();
const { user, isAdmin, logout } = useAuth();
const configEntry = getConfigEntry(isAdmin);
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
const activeProjectId = projectMatch ? projectMatch[1] : null;
const projectSubPath = projectMatch?.[2] ?? "";
@ -179,8 +183,34 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</>
)}
</nav>
<nav className="sidebar-nav sidebar-nav-bottom">
<Link
href={configEntry.href}
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
title={configEntry.label}
>
<span className="sidebar-icon">⚙</span>
<span className="sidebar-label">{configEntry.label}</span>
</Link>
</nav>
<div className="sidebar-footer">
<ThemeToggle />
{user && !isCollapsed && (
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
<span className="sidebar-identity-label">Angemeldet als</span>
<span className="sidebar-identity-name">{user.name}</span>
</div>
)}
{/* 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). */}
<div className="sidebar-bottom-row">
<ThemeToggle />
{user && !isCollapsed && (
<button type="button" className="sidebar-logout" onClick={() => void logout()}>
Abmelden
</button>
)}
</div>
</div>
</aside>
);

View file

@ -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.
<button
aria-pressed={theme === "dark"}
className="sidebar-theme-toggle"
onClick={toggleTheme}
title={theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
aria-label={theme === "dark" ? "Hellmodus einschalten" : "Dunkelmodus einschalten"}
type="button"
>
<span className="sidebar-icon" aria-hidden="true">
{theme === "dark" ? "☀" : "☾"}
</span>
{theme === "dark" ? "Hellmodus" : "Dunkelmodus"}
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span>
</button>
);
}

View file

@ -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<CalculateCableSizingResponse> {
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",
});

View file

@ -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 (
<div className="filter-panel">
<div className="filter-panel-header">
<div
className={
isCollapsed ? "filter-panel-row" : "filter-panel-row filter-panel-row-expanded"
}
>
<IconButton
ariaControls={barId}
ariaExpanded={!isCollapsed}
@ -66,29 +72,30 @@ export function CollapsibleFilterPanel({
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
onClick={toggleCollapsed}
/>
<span className="filter-panel-label">Filter</span>
{isCollapsed && isFilterActive ? (
<span className="filter-panel-badge">Filter aktiv</span>
) : null}
{isCollapsed ? (
<>
<span className="filter-panel-label">Filter</span>
{isFilterActive ? <span className="filter-panel-badge">Filter aktiv</span> : null}
</>
) : (
<div className="filter-bar" id={barId}>
{children}
{isFilterActive ? (
<IconButton
className="filter-bar-reset"
icon={FilterX}
label="Zurücksetzen"
onClick={onReset}
showLabel
title="Alle Filter zurücksetzen"
/>
) : null}
</div>
)}
<span aria-live="polite" className="filter-panel-count">
{visibleCount} von {totalCount}
</span>
</div>
{isCollapsed ? null : (
<div className="filter-bar" id={barId}>
{children}
{isFilterActive ? (
<IconButton
className="filter-bar-reset"
icon={FilterX}
label="Zurücksetzen"
onClick={onReset}
showLabel
title="Alle Filter zurücksetzen"
/>
) : null}
</div>
)}
</div>
);
}

View file

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
...init,
headers: {
"Content-Type": "application/json",
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
...init?.headers,
},
cache: "no-store",

View file

@ -0,0 +1,95 @@
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.
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<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
[WRITE_REQUEST_HEADER_NAME]: WRITE_REQUEST_HEADER_VALUE,
...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<T>;
}
export const authApi = {
status: () => request<AuthStatusDto>("/api/auth/status"),
login: (username: string, password: string) =>
request<AuthStatusDto>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<AuthUserDto>("/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<UserDto[]>("/api/users"),
create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) =>
request<UserDto>("/api/users", { method: "POST", body: JSON.stringify(input) }),
update: (userId: string, patch: { role?: UserRole; active?: boolean }) =>
request<UserDto>(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetPassword: (userId: string, newPassword: string) =>
request<UserDto>(`/api/users/${userId}/reset-password`, {
method: "POST",
body: JSON.stringify({ newPassword }),
}),
};

View file

@ -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;
}

View file

@ -0,0 +1,59 @@
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
* 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 {}
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 ?? "";
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,
sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000,
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
bootstrap: bootstrapUsername
? { username: bootstrapUsername, password: bootstrapPassword }
: null,
};
}

View file

@ -0,0 +1,156 @@
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 { 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;
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: Pick<UserStore, "findById">,
slideExpiry = true
): Promise<AuthUser | undefined> {
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, maxAgeMs: number) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: authConfig.cookieSecure,
path: "/",
maxAge: maxAgeMs,
};
}
/** 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 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) => {
if (SAFE_METHODS.has(req.method)) {
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();
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,
users: Pick<UserStore, "findById">,
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) setSessionCookie(res, authConfig, sessions, sessionId);
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();
};
}

20
src/server/auth/cookie.ts Normal file
View file

@ -0,0 +1,20 @@
/** 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;
// 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;
}

View file

@ -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<string, number[]>();
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();
}
}

View file

@ -0,0 +1,95 @@
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;
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
// verification (with explicit N/r/p) doesn't type-check through it.
function scryptAsync(
password: string,
salt: Buffer,
keyLength: number,
options?: ScryptOptions
): Promise<Buffer> {
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<string> {
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,
SCRYPT_R,
SCRYPT_P,
salt.toString("hex"),
derivedKey.toString("hex"),
].join("$");
}
export async function verifyPassword(
password: string,
storedHash: string
): Promise<boolean> {
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.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");
try {
const derivedKey = await scryptAsync(password, salt, expected.length, {
N: n,
r,
p,
maxmem: MAX_SCRYPT_MEMORY,
});
return timingSafeEqual(derivedKey, expected);
} catch {
return false;
}
}

105
src/server/auth/sessions.ts Normal file
View file

@ -0,0 +1,105 @@
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<string, Session>();
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,
createdAt: now,
expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs),
};
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;
const now = this.now();
if (this.isExpired(session, now)) {
this.sessions.delete(sessionId);
return undefined;
}
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 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) {
this.sessions.delete(id);
}
}
}
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);
}
}
}

View file

@ -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);

View file

@ -0,0 +1,16 @@
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";
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,
{ bootstrap: authConfig.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
);

View file

@ -0,0 +1,109 @@
import type { Request, Response } from "express";
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.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";
export async function getAuthStatus(req: Request, res: Response) {
const identity = await identityFromRequest(req, sessionStore, userRepository);
const sessionId = sessionIdFromRequest(req);
if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId);
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;
}
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(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);
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;
}
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: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." });
return;
}
try {
await userService.changeOwnPassword(
identity.id,
parsed.data.currentPassword,
parsed.data.newPassword
);
} catch (error) {
if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
// 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, 0), maxAge: undefined });
res.json({ signedOut: true });
}

View file

@ -0,0 +1,121 @@
import type { Request, Response } from "express";
import type { ZodError } from "zod";
import {
createUserSchema,
resetPasswordSchema,
updateUserSchema,
} from "../../shared/validation/user.schemas.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.
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,
};
}
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));
}
export async function createUser(req: Request, res: Response) {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: firstIssue(parsed.error) });
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: firstIssue(parsed.error) });
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;
}
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) {
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: firstIssue(parsed.error) });
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);
// 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);
}

View file

@ -4,7 +4,16 @@ 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,
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";
import { closeDefaultDatabase } from "../db/client.js";
@ -14,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) => {
@ -50,6 +60,16 @@ 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", requireSameOriginForWrites());
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 +97,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);

View file

@ -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);

View file

@ -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);

View file

@ -0,0 +1,2 @@
export const WRITE_REQUEST_HEADER_NAME = "X-Leistungsbilanz-Request";
export const WRITE_REQUEST_HEADER_VALUE = "1";

View file

@ -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<UserRole, string> = {
admin: "Administrator",
user: "Benutzer",
};

View file

@ -0,0 +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).max(MAX_USERNAME_LENGTH),
password: z.string().min(1).max(MAX_PASSWORD_LENGTH),
})
.strict();
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1).max(MAX_PASSWORD_LENGTH),
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
})
.strict();

View file

@ -0,0 +1,48 @@
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";
// 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()
.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(MAX_PASSWORD_LENGTH),
})
.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(MAX_PASSWORD_LENGTH),
})
.strict();
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;

View file

@ -0,0 +1,320 @@
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";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../src/shared/constants/request-security.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,
{ bootstrap: config.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
),
};
}
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);
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
);
});
});
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, includeApplicationHeader = true) {
let status: number | undefined;
let passed = false;
const req = {
method,
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) {
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", false).passed, true);
assert.equal(run("POST", "same-origin").passed, true);
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
});
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 });
});
});
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("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({
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);
});
});

View file

@ -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);
});
});

View file

@ -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
);
}
});
});