forked from jappel/leistungsbilanz-ts
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
8
src/server/composition/auth.ts
Normal file
8
src/server/composition/auth.ts
Normal 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);
|
||||
88
src/server/controllers/auth.controller.ts
Normal file
88
src/server/controllers/auth.controller.ts
Normal 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 });
|
||||
}
|
||||
103
src/server/controllers/user.controller.ts
Normal file
103
src/server/controllers/user.controller.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
16
src/server/routes/auth.routes.ts
Normal file
16
src/server/routes/auth.routes.ts
Normal 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);
|
||||
16
src/server/routes/user.routes.ts
Normal file
16
src/server/routes/user.routes.ts
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue