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:
parent
b50c00c0ac
commit
c56cfb30ad
36 changed files with 4928 additions and 38 deletions
44
src/server/auth/auth-config.ts
Normal file
44
src/server/auth/auth-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
111
src/server/auth/auth.middleware.ts
Normal file
111
src/server/auth/auth.middleware.ts
Normal 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
15
src/server/auth/cookie.ts
Normal 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;
|
||||
}
|
||||
63
src/server/auth/password.ts
Normal file
63
src/server/auth/password.ts
Normal 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);
|
||||
}
|
||||
55
src/server/auth/sessions.ts
Normal file
55
src/server/auth/sessions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue