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>
This commit is contained in:
Grovy311 2026-09-28 19:51:47 +02:00
parent b50c00c0ac
commit c56cfb30ad
36 changed files with 4928 additions and 38 deletions

View file

@ -36,6 +36,13 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`. repositories; direct integration fixtures belong under `tests/support`.
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
middleware), `src/domain/services/user.service.ts`,
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
(`src/app/layout.tsx`). See `docs/authentication.md` for the full picture.
The supported runtime model is Circuit-First. The former Consumer UI, API, The supported runtime model is Circuit-First. The former Consumer UI, API,
tables and upgrade tooling are removed; do not reintroduce them. tables and upgrade tooling are removed; do not reintroduce them.
@ -704,6 +711,10 @@ for the early-phase power balance by DIN 276 cost group (see
- logical feed link between distribution boards (`fedFrom` stays free text - logical feed link between distribution boards (`fedFrom` stays free text
until the project owner decides whether it references a board, a feeding until the project owner decides whether it references a board, a feeding
circuit or both) and the resulting hierarchical power summation circuit or both) and the resulting hierarchical power summation
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
- account lockout after failed login attempts
- email/one-time-link password provisioning, OIDC/Entra login
- linking user accounts to a personnel/employee master table
Do not implement these while working on an unrelated phase. Do not implement these while working on an unrelated phase.

View file

@ -35,8 +35,10 @@ services:
PORT: "3000" PORT: "3000"
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-debug}" LOG_LEVEL: "${LOG_LEVEL:-debug}"
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
# nicht aus dem LAN erreichbar sein. AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
# Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein.
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als # Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als

View file

@ -45,8 +45,15 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: "3000" PORT: "3000"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung # AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht. # users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
# .env - siehe docs/AUTH.md.
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
# Next.js-Rewrite im Web-Dienst erreicht.
expose: expose:
- "3000" - "3000"
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit # 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 bewusst noch nicht implementierte oder begrenzte Funktionen
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul, - [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
Rechenweg, Datenbasis und Prüfnachweis der Tabellen Rechenweg, Datenbasis und Prüfnachweis der Tabellen
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
## Entwicklung und Betrieb ## Entwicklung und Betrieb

61
docs/authentication.md Normal file
View file

@ -0,0 +1,61 @@
# Anmeldung und Benutzerverwaltung
Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder
Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
## Rollen
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich
(Benutzerverwaltung, eigenes Konto)
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
Grobbilanz, …), kein Zugriff auf Config
Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
## Ablauf beim Anlegen eines Zugangs
1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit
Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst
eintippt.
2. Der Zugang startet mit `mustChangePassword = true`.
3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das
ein eigenes Passwort verlangt, bevor es weitergeht.
4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues
Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn
jemand sein Passwort vergessen hat.
## Technischer Aufbau
- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) –
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
`role`, `active`, `mustChangePassword`.
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
(außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und
erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist.
`/api/users/*` verlangt zusätzlich die Rolle `admin`.
- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
## Bootstrap-Admin
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die
`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen
ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte
gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
in `compose.yaml` selbst.
## Bewusst nicht umgesetzt (Stand jetzt)
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
- Konten-Sperre nach Fehlversuchen
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle

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

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

@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuth } from "../../frontend/components/AuthProvider";
const CONFIG_TABS = [
{ href: "/config/benutzer", label: "Benutzer" },
{ href: "/config/konto", label: "Konto" },
] as const;
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { status, isAdmin } = useAuth();
useEffect(() => {
// LoginGate already guarantees a signed-in user with a set password by
// the time this layout renders; only the role still needs checking.
if (status === "signed-in" && !isAdmin) {
router.replace("/");
}
}, [status, isAdmin, router]);
if (status !== "signed-in" || !isAdmin) {
return null;
}
return (
<div className="container-fluid py-4">
<div className="page-header">
<h1>Config</h1>
</div>
<nav className="config-tabs">
{CONFIG_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>
);
}

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

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ConfigPage() {
redirect("/config/benutzer");
}

View file

@ -2716,3 +2716,116 @@ button.structure-entry-main:focus-visible {
justify-content: center; justify-content: center;
} }
} }
/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */
.sidebar-nav-bottom {
flex: 0 0 auto;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.sidebar-identity {
display: flex;
flex-direction: column;
gap: 1px;
padding: 8px 14px 10px;
}
.sidebar-identity-label {
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(199, 214, 224, 0.7);
}
.sidebar-identity-name {
font-size: 0.88rem;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-logout {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
padding: 8px 14px;
border-radius: 8px;
color: #c7d6e0;
font-size: 0.85rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.16);
background: transparent;
width: 100%;
cursor: pointer;
}
.sidebar-logout:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.sidebar-collapsed .sidebar-identity {
display: none;
}
.login-gate-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
background: linear-gradient(
160deg,
var(--color-primary-deep) 0%,
var(--color-primary-dark) 45%,
var(--color-primary) 100%
);
}
.login-gate-card {
width: 100%;
max-width: 360px;
padding: 28px 26px;
}
.config-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
margin-bottom: var(--space-md);
}
.config-tab {
padding: 8px 16px;
border-radius: 6px 6px 0 0;
color: inherit;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
opacity: 0.7;
}
.config-tab:hover {
opacity: 1;
text-decoration: none;
}
.config-tab.active {
opacity: 1;
box-shadow: inset 0 -2px 0 var(--color-primary);
}
.modal-backdrop-inline {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}

View file

@ -3,6 +3,8 @@ import Script from "next/script";
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css"; import "./globals.css";
import { AppShell } from "../frontend/components/AppShell"; import { AppShell } from "../frontend/components/AppShell";
import { AuthProvider } from "../frontend/components/AuthProvider";
import { LoginGate } from "../frontend/components/LoginGate";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Leistungsbilanz", title: "Leistungsbilanz",
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<Script id="theme-init" strategy="beforeInteractive"> <Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT} {THEME_INIT_SCRIPT}
</Script> </Script>
<AuthProvider>
<LoginGate>
<AppShell>{children}</AppShell> <AppShell>{children}</AppShell>
</LoginGate>
</AuthProvider>
</body> </body>
</html> </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

@ -85,6 +85,13 @@
"when": 1790531351492, "when": 1790531351492,
"tag": "0011_glossy_norman_osborn", "tag": "0011_glossy_norman_osborn",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1790617581434,
"tag": "0012_lively_turbo",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,87 @@
import crypto from "node:crypto";
import { asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { users } from "../schema/users.js";
import type { UserRole } from "../../shared/constants/user-roles.js";
export interface CreateUserRow {
username: string;
name: string;
role: UserRole;
passwordHash: string;
}
export class UserRepository {
constructor(private readonly database: AppDatabase) {}
async list() {
const db = this.database;
return db.select().from(users).orderBy(asc(users.name));
}
async count() {
const db = this.database;
const rows = await db.select({ id: users.id }).from(users).limit(1);
return rows.length;
}
async findById(userId: string) {
const db = this.database;
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
return row ?? null;
}
async findByUsername(username: string) {
const db = this.database;
const [row] = await db
.select()
.from(users)
.where(eq(users.username, username.trim().toLowerCase()))
.limit(1);
return row ?? null;
}
async create(input: CreateUserRow) {
const db = this.database;
const id = crypto.randomUUID();
await db.insert(users).values({
id,
username: input.username.trim().toLowerCase(),
name: input.name,
role: input.role,
passwordHash: input.passwordHash,
active: true,
mustChangePassword: true,
});
return this.findById(id);
}
async updateRoleAndActive(
userId: string,
patch: { role?: UserRole; active?: boolean }
) {
const db = this.database;
await db
.update(users)
.set({
...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.active !== undefined ? { active: patch.active } : {}),
updatedAt: new Date(),
})
.where(eq(users.id, userId));
return this.findById(userId);
}
async setPassword(
userId: string,
passwordHash: string,
mustChangePassword: boolean
) {
const db = this.database;
await db
.update(users)
.set({ passwordHash, mustChangePassword, updatedAt: new Date() })
.where(eq(users.id, userId));
return this.findById(userId);
}
}

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

@ -0,0 +1,27 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: text("id").primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
role: text("role").notNull().default("user"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
mustChangePassword: integer("must_change_password", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [uniqueIndex("users_username_unique").on(table.username)]
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

View file

@ -0,0 +1,104 @@
import type { UserRepository } from "../../db/repositories/user.repository.js";
import { hashPassword, verifyPassword } from "../../server/auth/password.js";
import type { UserRole } from "../../shared/constants/user-roles.js";
import type { AuthConfig } from "../../server/auth/auth-config.js";
import { createLogger } from "../../shared/logging/logger.js";
const logger = createLogger("user-service");
export class InvalidCredentialsError extends Error {
constructor() {
super("Anmeldung fehlgeschlagen.");
}
}
export class UsernameTakenError extends Error {
constructor() {
super("Dieser Anmeldename ist bereits vergeben.");
}
}
export class WrongPasswordError extends Error {
constructor() {
super("Das aktuelle Passwort ist falsch.");
}
}
export class UserService {
constructor(
private readonly repository: UserRepository,
private readonly authConfig: AuthConfig
) {}
/**
* Creates the bootstrap admin from env vars, but only while the users
* table is still completely empty. After the first account exists, every
* further account is created through Config -> Benutzer, and the bootstrap
* env vars are ignored - that's the whole point of them being a one-time
* escape hatch instead of a standing backdoor.
*/
async ensureBootstrapAdmin(): Promise<void> {
if (!this.authConfig.bootstrap) return;
const existingCount = await this.repository.count();
if (existingCount > 0) return;
const passwordHash = await hashPassword(this.authConfig.bootstrap.password);
await this.repository.create({
username: this.authConfig.bootstrap.username,
name: "Administrator",
role: "admin",
passwordHash,
});
logger.info("bootstrap admin created", {
username: this.authConfig.bootstrap.username,
});
}
async authenticate(username: string, password: string) {
const user = await this.repository.findByUsername(username);
// Always hash something, even for an unknown user: otherwise the
// response time would leak which usernames exist.
const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder"));
const passwordMatches = await verifyPassword(password, passwordHash);
if (!user || !user.active || !passwordMatches) {
throw new InvalidCredentialsError();
}
return user;
}
async createUser(input: {
username: string;
name: string;
role: UserRole;
initialPassword: string;
}) {
const existing = await this.repository.findByUsername(input.username);
if (existing) throw new UsernameTakenError();
const passwordHash = await hashPassword(input.initialPassword);
return this.repository.create({
username: input.username,
name: input.name,
role: input.role,
passwordHash,
});
}
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
return this.repository.updateRoleAndActive(userId, patch);
}
/** Admin-triggered reset with a new start password; forces a change on next login. */
async resetPassword(userId: string, newPassword: string) {
const passwordHash = await hashPassword(newPassword);
return this.repository.setPassword(userId, passwordHash, true);
}
/** Self-service change by the signed-in user; clears mustChangePassword. */
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.repository.findById(userId);
if (!user) throw new WrongPasswordError();
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
if (!currentMatches) throw new WrongPasswordError();
const passwordHash = await hashPassword(newPassword);
return this.repository.setPassword(userId, passwordHash, false);
}
}

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

View file

@ -0,0 +1,86 @@
// Small, self-contained fetch helper for the auth/user endpoints - kept
// separate from the large domain api.ts so this feature doesn't have to
// touch that file's shared `request()` internals.
export type UserRole = "admin" | "user";
export interface AuthStatusDto {
signedIn: boolean;
mustChangePassword: boolean;
}
export interface AuthUserDto {
id: string;
username: string;
name: string;
role: UserRole;
mustChangePassword: boolean;
}
export interface UserDto {
id: string;
username: string;
name: string;
role: UserRole;
active: boolean;
mustChangePassword: boolean;
createdAt: string;
}
export class AuthApiError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message);
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
cache: "no-store",
});
if (!response.ok) {
let message = `Anfrage fehlgeschlagen (Status ${response.status})`;
try {
const body = (await response.json()) as { error?: unknown };
if (typeof body.error === "string") message = body.error;
} catch {
// Keep the generic message for non-JSON error responses.
}
throw new AuthApiError(message, response.status);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<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,44 @@
export const SESSION_COOKIE = "lb_session";
const DEFAULT_SESSION_HOURS = 12;
export interface AuthConfig {
sessionTtlMs: number;
cookieSecure: boolean;
/**
* Only used to create the very first admin when the users table is still
* empty - after that, every account is created through Config -> Benutzer.
* Unset means: no bootstrap account, the table must already have an admin.
*/
bootstrap: { username: string; password: string } | null;
}
export class AuthConfigError extends Error {}
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS);
if (!Number.isFinite(hours) || hours <= 0) {
throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number.");
}
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) {
throw new AuthConfigError(
"AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither."
);
}
if (bootstrapPassword && bootstrapPassword.length < 12) {
throw new AuthConfigError(
"AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long."
);
}
return {
sessionTtlMs: hours * 60 * 60 * 1000,
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
bootstrap: bootstrapUsername
? { username: bootstrapUsername, password: bootstrapPassword }
: null,
};
}

View file

@ -0,0 +1,111 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
import { readCookie } from "./cookie.js";
import type { SessionStore } from "./sessions.js";
import type { UserRepository } from "../../db/repositories/user.repository.js";
import type { UserRole } from "../../shared/constants/user-roles.js";
export interface AuthUser {
id: string;
username: string;
name: string;
role: UserRole;
mustChangePassword: boolean;
}
declare global {
namespace Express {
interface Request {
authUser?: AuthUser;
}
}
}
export function sessionIdFromRequest(req: {
headers: { cookie?: string | undefined };
}): string | undefined {
return readCookie(req.headers.cookie, SESSION_COOKIE);
}
export async function identityFromRequest(
req: { headers: { cookie?: string | undefined } },
sessions: SessionStore,
users: UserRepository,
slideExpiry = true
): Promise<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) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: authConfig.cookieSecure,
path: "/",
maxAge: authConfig.sessionTtlMs,
};
}
/** Protects /api. Only the login route and the status check stay reachable without a session. */
export function createAuthGuard(
authConfig: AuthConfig,
sessions: SessionStore,
users: UserRepository,
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (openPaths.includes(req.path)) {
next();
return;
}
identityFromRequest(req, sessions, users)
.then((identity) => {
if (!identity) {
res.status(401).json({ error: "Nicht angemeldet." });
return;
}
req.authUser = identity;
const sessionId = sessionIdFromRequest(req);
if (sessionId) {
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
}
next();
})
.catch(next);
};
}
/** Blocks everything except logout and the password change while a start password is still in use. */
export function requirePasswordChanged(): RequestHandler {
const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]);
return (req: Request, res: Response, next: NextFunction) => {
if (!req.authUser?.mustChangePassword || allowed.has(req.path)) {
next();
return;
}
res.status(403).json({
error: "Bitte zuerst ein eigenes Passwort setzen.",
mustChangePassword: true,
});
};
}
export function requireRole(role: UserRole): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (req.authUser?.role !== role) {
res.status(403).json({ error: "Keine Berechtigung für diese Aktion." });
return;
}
next();
};
}

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

@ -0,0 +1,15 @@
/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */
export function readCookie(
header: string | undefined,
name: string
): string | undefined {
if (!header) return undefined;
for (const part of header.split(";")) {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) continue;
const key = part.slice(0, separatorIndex).trim();
if (key !== name) continue;
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
}
return undefined;
}

View file

@ -0,0 +1,63 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto";
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LENGTH = 64;
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
// verification (with explicit N/r/p) doesn't type-check through it.
function scryptAsync(
password: string,
salt: Buffer,
keyLength: number,
options?: ScryptOptions
): Promise<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(16);
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
return [
"scrypt",
SCRYPT_N,
SCRYPT_R,
SCRYPT_P,
salt.toString("hex"),
derivedKey.toString("hex"),
].join("$");
}
export async function verifyPassword(
password: string,
storedHash: string
): Promise<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.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
return false;
}
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p });
if (derivedKey.length !== expected.length) return false;
return timingSafeEqual(derivedKey, expected);
}

View file

@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
interface Session {
id: string;
userId: string;
expiresAt: number;
}
/**
* In-memory session store. The API runs as a single container/process, so a
* restart clearing every session is an acceptable trade-off for the
* simplicity of not needing a persisted session table.
*/
export class SessionStore {
private readonly sessions = new Map<string, Session>();
constructor(private readonly ttlMs: number) {}
create(userId: string): Session {
const session: Session = {
id: randomUUID(),
userId,
expiresAt: Date.now() + this.ttlMs,
};
this.sessions.set(session.id, session);
return session;
}
/** Returns the session and slides its expiry forward, or undefined if gone/expired. */
get(sessionId: string | undefined, slideExpiry = true): Session | undefined {
if (!sessionId) return undefined;
const session = this.sessions.get(sessionId);
if (!session) return undefined;
if (session.expiresAt < Date.now()) {
this.sessions.delete(sessionId);
return undefined;
}
if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs;
return session;
}
destroy(sessionId: string | undefined): void {
if (!sessionId) return;
this.sessions.delete(sessionId);
}
/** Ends every other session for a user, e.g. after a password change. */
destroyAllForUser(userId: string, exceptSessionId?: string): void {
for (const [id, session] of this.sessions) {
if (session.userId === userId && id !== exceptSessionId) {
this.sessions.delete(id);
}
}
}
}

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 { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js"; import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
import { UserRepository } from "../../db/repositories/user.repository.js";
export const circuitDeviceRowRepository = export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db); new CircuitDeviceRowRepository(db);
@ -39,3 +40,4 @@ export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db); new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db); export const documentBrandingRepository = new DocumentBrandingRepository(db);
export const userRepository = new UserRepository(db);

View file

@ -0,0 +1,8 @@
import { readAuthConfig } from "../auth/auth-config.js";
import { SessionStore } from "../auth/sessions.js";
import { UserService } from "../../domain/services/user.service.js";
import { userRepository } from "./application-repositories.js";
export const authConfig = readAuthConfig();
export const sessionStore = new SessionStore(authConfig.sessionTtlMs);
export const userService = new UserService(userRepository, authConfig);

View file

@ -0,0 +1,88 @@
import type { Request, Response } from "express";
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js";
import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js";
import { authConfig, sessionStore, userService } from "../composition/auth.js";
import {
cookieOptions,
identityFromRequest,
sessionIdFromRequest,
} from "../auth/auth.middleware.js";
import { SESSION_COOKIE } from "../auth/auth-config.js";
import { userRepository } from "../composition/application-repositories.js";
export async function getAuthStatus(req: Request, res: Response) {
const identity = await identityFromRequest(req, sessionStore, userRepository);
const sessionId = sessionIdFromRequest(req);
if (identity && sessionId) {
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
}
res.json({
signedIn: Boolean(identity),
mustChangePassword: identity?.mustChangePassword ?? false,
});
}
export async function login(req: Request, res: Response) {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
return;
}
try {
const user = await userService.authenticate(parsed.data.username, parsed.data.password);
const session = sessionStore.create(user.id);
res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig));
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
} catch (error) {
if (error instanceof InvalidCredentialsError) {
res.status(401).json({ error: error.message });
return;
}
throw error;
}
}
export function getMe(req: Request, res: Response) {
const identity = req.authUser;
if (!identity) {
res.status(401).json({ error: "Nicht angemeldet." });
return;
}
res.json(identity);
}
export async function changePassword(req: Request, res: Response) {
const identity = req.authUser;
if (!identity) {
res.status(401).json({ error: "Nicht angemeldet." });
return;
}
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
try {
await userService.changeOwnPassword(
identity.id,
parsed.data.currentPassword,
parsed.data.newPassword
);
} catch (error) {
if (error instanceof WrongPasswordError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
// A password change invalidates every other session of this account; the
// current one stays so the change doesn't log the user out themselves.
sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req));
res.json({ changed: true });
}
export function logout(req: Request, res: Response) {
sessionStore.destroy(sessionIdFromRequest(req));
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined });
res.json({ signedOut: true });
}

View file

@ -0,0 +1,103 @@
import type { Request, Response } from "express";
import {
createUserSchema,
resetPasswordSchema,
updateUserSchema,
} from "../../shared/validation/user.schemas.js";
import { UsernameTakenError } from "../../domain/services/user.service.js";
import { userService } from "../composition/auth.js";
import { userRepository } from "../composition/application-repositories.js";
// The password hash never leaves this layer.
function toDto(user: {
id: string;
username: string;
name: string;
role: string;
active: boolean;
mustChangePassword: boolean;
createdAt: Date;
}) {
return {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
active: user.active,
mustChangePassword: user.mustChangePassword,
createdAt: user.createdAt,
};
}
export async function listUsers(_req: Request, res: Response) {
const rows = await userRepository.list();
res.json(rows.map(toDto));
}
export async function createUser(req: Request, res: Response) {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
try {
const created = await userService.createUser(parsed.data);
if (!created) {
res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." });
return;
}
res.status(201).json(toDto(created));
} catch (error) {
if (error instanceof UsernameTakenError) {
res.status(409).json({ error: error.message });
return;
}
throw error;
}
}
export async function updateUser(req: Request, res: Response) {
const { userId } = req.params;
if (typeof userId !== "string") {
res.status(400).json({ error: "Invalid userId" });
return;
}
const parsed = updateUserSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
// The signed-in admin can't lock themselves out by deactivating or
// demoting their own account through this endpoint.
if (userId === req.authUser?.id) {
res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." });
return;
}
const existing = await userRepository.findById(userId);
if (!existing) {
res.status(404).json({ error: "Benutzer nicht gefunden." });
return;
}
const updated = await userService.updateRoleAndActive(userId, parsed.data);
res.json(updated ? toDto(updated) : null);
}
export async function resetUserPassword(req: Request, res: Response) {
const { userId } = req.params;
if (typeof userId !== "string") {
res.status(400).json({ error: "Invalid userId" });
return;
}
const parsed = resetPasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const existing = await userRepository.findById(userId);
if (!existing) {
res.status(404).json({ error: "Benutzer nicht gefunden." });
return;
}
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
res.json(updated ? toDto(updated) : null);
}

View file

@ -4,7 +4,12 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { documentBrandingRouter } from "./routes/document-branding.routes.js"; import { documentBrandingRouter } from "./routes/document-branding.routes.js";
import { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.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 { errorMiddleware } from "./middleware/error.middleware.js";
import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js";
import { authConfig, sessionStore, userService } from "./composition/auth.js";
import { userRepository } from "./composition/application-repositories.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
import { closeDefaultDatabase } from "../db/client.js"; import { closeDefaultDatabase } from "../db/client.js";
@ -50,6 +55,15 @@ app.get("/health", (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// Everything below /api requires a signed-in, active user, except
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
// openPaths). authRouter is mounted AFTER the guard (not before) so that
// /auth/me and /auth/change-password still get req.authUser populated by it.
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
app.use("/api", requirePasswordChanged());
app.use("/api/auth", authRouter);
app.use("/api/users", userRouter);
app.use("/api/cable-sizing", cableSizingRouter); app.use("/api/cable-sizing", cableSizingRouter);
app.use("/api/projects", projectRouter); app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter); app.use("/api/global-devices", globalDeviceRouter);
@ -77,6 +91,13 @@ setInterval(() => {
}); });
}, heartbeatIntervalMs).unref(); }, heartbeatIntervalMs).unref();
userService
.ensureBootstrapAdmin()
.catch((error) => {
logger.error("bootstrap admin setup failed", toErrorMeta(error));
process.exit(1);
})
.then(() => {
const server = app.listen(port, () => { const server = app.listen(port, () => {
logger.info("server started", { port }); logger.info("server started", { port });
}); });
@ -114,3 +135,4 @@ function shutdown(signal: NodeJS.Signals): void {
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
process.on("SIGINT", 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,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,15 @@
import { z } from "zod";
export const loginSchema = z
.object({
username: z.string().trim().min(1),
password: z.string().min(1),
})
.strict();
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(200),
})
.strict();

View file

@ -0,0 +1,33 @@
import { z } from "zod";
import { USER_ROLES } from "../constants/user-roles.js";
const MIN_PASSWORD_LENGTH = 8;
export const createUserSchema = z
.object({
username: z.string().trim().min(1).max(200),
name: z.string().trim().min(1).max(200),
role: z.enum(USER_ROLES),
initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
})
.strict();
export const updateUserSchema = z
.object({
role: z.enum(USER_ROLES).optional(),
active: z.boolean().optional(),
})
.strict()
.refine((value) => value.role !== undefined || value.active !== undefined, {
message: "Nothing to update.",
});
export const resetPasswordSchema = z
.object({
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
})
.strict();
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;