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