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

@ -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 { 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,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 { projectRouter } from "./routes/project.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { authRouter } from "./routes/auth.routes.js";
import { userRouter } from "./routes/user.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js";
import { authConfig, sessionStore, userService } from "./composition/auth.js";
import { userRepository } from "./composition/application-repositories.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
import { closeDefaultDatabase } from "../db/client.js";
@ -50,6 +55,15 @@ app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Everything below /api requires a signed-in, active user, except
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
// openPaths). authRouter is mounted AFTER the guard (not before) so that
// /auth/me and /auth/change-password still get req.authUser populated by it.
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
app.use("/api", requirePasswordChanged());
app.use("/api/auth", authRouter);
app.use("/api/users", userRouter);
app.use("/api/cable-sizing", cableSizingRouter);
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
@ -77,40 +91,48 @@ setInterval(() => {
});
}, heartbeatIntervalMs).unref();
const server = app.listen(port, () => {
logger.info("server started", { port });
});
let shuttingDown = false;
function shutdown(signal: NodeJS.Signals): void {
if (shuttingDown) return;
shuttingDown = true;
logger.info("shutting down", { signal });
// Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der
// Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten.
const forceExit = setTimeout(() => {
logger.error("shutdown timed out, forcing exit");
userService
.ensureBootstrapAdmin()
.catch((error) => {
logger.error("bootstrap admin setup failed", toErrorMeta(error));
process.exit(1);
}, shutdownTimeoutMs);
forceExit.unref();
})
.then(() => {
const server = app.listen(port, () => {
logger.info("server started", { port });
});
server.close((error) => {
if (error) logger.error("http server close failed", toErrorMeta(error));
try {
closeDefaultDatabase();
} catch (closeError) {
logger.error("database close failed", toErrorMeta(closeError));
let shuttingDown = false;
function shutdown(signal: NodeJS.Signals): void {
if (shuttingDown) return;
shuttingDown = true;
logger.info("shutting down", { signal });
// Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der
// Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten.
const forceExit = setTimeout(() => {
logger.error("shutdown timed out, forcing exit");
process.exit(1);
}, shutdownTimeoutMs);
forceExit.unref();
server.close((error) => {
if (error) logger.error("http server close failed", toErrorMeta(error));
try {
closeDefaultDatabase();
} catch (closeError) {
logger.error("database close failed", toErrorMeta(closeError));
}
logger.info("shutdown complete");
process.exit(error ? 1 : 0);
});
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
server.closeIdleConnections();
}
logger.info("shutdown complete");
process.exit(error ? 1 : 0);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
});
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
server.closeIdleConnections();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

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