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