Harden authentication and put filter toggle and fields in one row
Security: per-username login throttling, constant-time unknown-user check, Sec-Fetch-Site CSRF guard for writes, session rotation on login and password change, absolute session lifetime, sessions ended on reset and deactivation, last-admin protection, input length caps, ASCII usernames, clickjacking headers. Covered by tests/authentication.test.ts. Filter panel: toggle, fields and count share one row to save space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d2948cdb1
commit
5073d9342a
18 changed files with 683 additions and 81 deletions
|
|
@ -1,9 +1,13 @@
|
|||
export const SESSION_COOKIE = "lb_session";
|
||||
|
||||
const DEFAULT_SESSION_HOURS = 12;
|
||||
const DEFAULT_SESSION_MAX_DAYS = 7;
|
||||
|
||||
export interface AuthConfig {
|
||||
/** Idle timeout; slides forward on activity. */
|
||||
sessionTtlMs: number;
|
||||
/** Hard upper bound for a session, regardless of activity. */
|
||||
sessionMaxAgeMs: number;
|
||||
cookieSecure: boolean;
|
||||
/**
|
||||
* Only used to create the very first admin when the users table is still
|
||||
|
|
@ -15,11 +19,21 @@ export interface AuthConfig {
|
|||
|
||||
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.");
|
||||
function positiveNumber(raw: string | undefined, fallback: number, name: string): number {
|
||||
const value = Number(raw ?? fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new AuthConfigError(`${name} must be a positive number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
||||
const hours = positiveNumber(env.AUTH_SESSION_HOURS, DEFAULT_SESSION_HOURS, "AUTH_SESSION_HOURS");
|
||||
const maxDays = positiveNumber(
|
||||
env.AUTH_SESSION_MAX_DAYS,
|
||||
DEFAULT_SESSION_MAX_DAYS,
|
||||
"AUTH_SESSION_MAX_DAYS"
|
||||
);
|
||||
|
||||
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
|
||||
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
|
||||
|
|
@ -36,6 +50,7 @@ export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig
|
|||
|
||||
return {
|
||||
sessionTtlMs: hours * 60 * 60 * 1000,
|
||||
sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000,
|
||||
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
|
||||
bootstrap: bootstrapUsername
|
||||
? { username: bootstrapUsername, password: bootstrapPassword }
|
||||
|
|
|
|||
|
|
@ -46,17 +46,55 @@ export async function identityFromRequest(
|
|||
};
|
||||
}
|
||||
|
||||
export function cookieOptions(authConfig: AuthConfig) {
|
||||
export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: authConfig.cookieSecure,
|
||||
path: "/",
|
||||
maxAge: authConfig.sessionTtlMs,
|
||||
maxAge: maxAgeMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Protects /api. Only the login route and the status check stay reachable without a session. */
|
||||
/** Sets the session cookie with the same remaining lifetime the server uses. */
|
||||
export function setSessionCookie(
|
||||
res: Response,
|
||||
authConfig: AuthConfig,
|
||||
sessions: SessionStore,
|
||||
sessionId: string
|
||||
): void {
|
||||
const session = sessions.get(sessionId, false);
|
||||
if (!session) return;
|
||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session)));
|
||||
}
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* CSRF defense for cookie-authenticated writes.
|
||||
*
|
||||
* SameSite=Lax alone is not enough here: every other service on the same
|
||||
* host (other ports of the same IP) counts as "same-site" and would get the
|
||||
* cookie attached. Browsers send Sec-Fetch-Site on every request, so a write
|
||||
* is only accepted from the app's own origin. Clients without the header
|
||||
* (curl, scripts) carry no ambient browser cookie and are not a CSRF vector.
|
||||
*/
|
||||
export function requireSameOriginForWrites(): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const fetchSite = req.headers["sec-fetch-site"];
|
||||
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." });
|
||||
};
|
||||
}
|
||||
|
||||
/** Protects /api. Only login, status and logout stay reachable without a session. */
|
||||
export function createAuthGuard(
|
||||
authConfig: AuthConfig,
|
||||
sessions: SessionStore,
|
||||
|
|
@ -76,9 +114,7 @@ export function createAuthGuard(
|
|||
}
|
||||
req.authUser = identity;
|
||||
const sessionId = sessionIdFromRequest(req);
|
||||
if (sessionId) {
|
||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
||||
}
|
||||
if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId);
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ export function readCookie(
|
|||
if (separatorIndex === -1) continue;
|
||||
const key = part.slice(0, separatorIndex).trim();
|
||||
if (key !== name) continue;
|
||||
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
||||
// A malformed value must read as "no session", not throw into a 500.
|
||||
try {
|
||||
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
53
src/server/auth/login-throttle.ts
Normal file
53
src/server/auth/login-throttle.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Slows down online password guessing per username.
|
||||
*
|
||||
* Keyed by username, not by client IP: every request reaches the API through
|
||||
* the Next.js rewrite, so the socket address is always the web container.
|
||||
* The trade-off is that someone who knows a username can keep that account
|
||||
* temporarily throttled; the block lifts by itself after the window, so it is
|
||||
* a nuisance, not a lockout.
|
||||
*/
|
||||
export class LoginThrottle {
|
||||
private readonly failures = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly maxFailures = 5,
|
||||
private readonly windowMs = 15 * 60 * 1000,
|
||||
private readonly maxTrackedKeys = 10_000,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
/** Milliseconds until another attempt is allowed; 0 = allowed now. */
|
||||
retryAfterMs(username: string): number {
|
||||
const recent = this.recentFailures(this.key(username));
|
||||
if (recent.length < this.maxFailures) return 0;
|
||||
return Math.max(0, recent[0] + this.windowMs - this.now());
|
||||
}
|
||||
|
||||
recordFailure(username: string): void {
|
||||
const key = this.key(username);
|
||||
const recent = this.recentFailures(key);
|
||||
recent.push(this.now());
|
||||
if (!this.failures.has(key) && this.failures.size >= this.maxTrackedKeys) {
|
||||
// Bound memory under a flood of random usernames: drop the oldest key.
|
||||
const oldestKey = this.failures.keys().next().value;
|
||||
if (oldestKey !== undefined) this.failures.delete(oldestKey);
|
||||
}
|
||||
this.failures.set(key, recent);
|
||||
}
|
||||
|
||||
recordSuccess(username: string): void {
|
||||
this.failures.delete(this.key(username));
|
||||
}
|
||||
|
||||
private recentFailures(key: string): number[] {
|
||||
const cutoff = this.now() - this.windowMs;
|
||||
const recent = (this.failures.get(key) ?? []).filter((time) => time > cutoff);
|
||||
if (recent.length === 0) this.failures.delete(key);
|
||||
return recent;
|
||||
}
|
||||
|
||||
private key(username: string): string {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,24 +3,50 @@ import { randomUUID } from "node:crypto";
|
|||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_SESSIONS_PER_USER = 20;
|
||||
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Sessions slide forward on activity (`idleTtlMs`) but never outlive
|
||||
* `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever.
|
||||
*/
|
||||
export class SessionStore {
|
||||
private readonly sessions = new Map<string, Session>();
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
constructor(
|
||||
private readonly idleTtlMs: number,
|
||||
private readonly absoluteTtlMs: number,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
/** Removes expired sessions; unreferenced ones would otherwise pile up. */
|
||||
startSweeping(): void {
|
||||
setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref();
|
||||
}
|
||||
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (this.isExpired(session, now)) this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
create(userId: string): Session {
|
||||
this.enforcePerUserLimit(userId);
|
||||
const now = this.now();
|
||||
const session: Session = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
createdAt: now,
|
||||
expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs),
|
||||
};
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
|
|
@ -31,20 +57,28 @@ export class SessionStore {
|
|||
if (!sessionId) return undefined;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return undefined;
|
||||
if (session.expiresAt < Date.now()) {
|
||||
const now = this.now();
|
||||
if (this.isExpired(session, now)) {
|
||||
this.sessions.delete(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs;
|
||||
if (slideExpiry) {
|
||||
session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Remaining lifetime, used as cookie max-age so browser and server agree. */
|
||||
remainingMs(session: Session): number {
|
||||
return Math.max(0, session.expiresAt - this.now());
|
||||
}
|
||||
|
||||
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. */
|
||||
/** Ends every session of a user, optionally except one (e.g. the caller's own). */
|
||||
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (session.userId === userId && id !== exceptSessionId) {
|
||||
|
|
@ -52,4 +86,20 @@ export class SessionStore {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isExpired(session: Session, now: number): boolean {
|
||||
return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now;
|
||||
}
|
||||
|
||||
// Oldest sessions go first, so a legitimate user logging in on a new
|
||||
// device is never refused - only memory growth is bounded.
|
||||
private enforcePerUserLimit(userId: string): void {
|
||||
const own = [...this.sessions.values()]
|
||||
.filter((session) => session.userId === userId)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
while (own.length >= MAX_SESSIONS_PER_USER) {
|
||||
const oldest = own.shift();
|
||||
if (oldest) this.sessions.delete(oldest.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { readAuthConfig } from "../auth/auth-config.js";
|
||||
import { LoginThrottle } from "../auth/login-throttle.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 sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
|
||||
sessionStore.startSweeping();
|
||||
export const loginThrottle = new LoginThrottle();
|
||||
export const userService = new UserService(userRepository, authConfig);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
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 {
|
||||
InvalidCredentialsError,
|
||||
PasswordUnchangedError,
|
||||
WrongPasswordError,
|
||||
} from "../../domain/services/user.service.js";
|
||||
import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js";
|
||||
import {
|
||||
cookieOptions,
|
||||
identityFromRequest,
|
||||
sessionIdFromRequest,
|
||||
setSessionCookie,
|
||||
} from "../auth/auth.middleware.js";
|
||||
import { SESSION_COOKIE } from "../auth/auth-config.js";
|
||||
import { userRepository } from "../composition/application-repositories.js";
|
||||
|
|
@ -13,9 +18,7 @@ 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));
|
||||
}
|
||||
if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId);
|
||||
res.json({
|
||||
signedIn: Boolean(identity),
|
||||
mustChangePassword: identity?.mustChangePassword ?? false,
|
||||
|
|
@ -28,13 +31,29 @@ export async function login(req: Request, res: Response) {
|
|||
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
|
||||
return;
|
||||
}
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
const retryAfterMs = loginThrottle.retryAfterMs(username);
|
||||
if (retryAfterMs > 0) {
|
||||
res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString());
|
||||
res.status(429).json({
|
||||
error: "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userService.authenticate(parsed.data.username, parsed.data.password);
|
||||
const user = await userService.authenticate(username, password);
|
||||
loginThrottle.recordSuccess(username);
|
||||
// A fresh session id on every login: an id planted before login
|
||||
// (session fixation) never becomes authenticated.
|
||||
sessionStore.destroy(sessionIdFromRequest(req));
|
||||
const session = sessionStore.create(user.id);
|
||||
res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig));
|
||||
setSessionCookie(res, authConfig, sessionStore, session.id);
|
||||
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
loginThrottle.recordFailure(username);
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
|
@ -59,7 +78,7 @@ export async function changePassword(req: Request, res: Response) {
|
|||
}
|
||||
const parsed = changePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -69,20 +88,22 @@ export async function changePassword(req: Request, res: Response) {
|
|||
parsed.data.newPassword
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WrongPasswordError) {
|
||||
if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) {
|
||||
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));
|
||||
// Every session of this account ends - including the current one, which is
|
||||
// replaced by a fresh id so the user stays signed in on this device only.
|
||||
sessionStore.destroyAllForUser(identity.id);
|
||||
const session = sessionStore.create(identity.id);
|
||||
setSessionCookie(res, authConfig, sessionStore, session.id);
|
||||
res.json({ changed: true });
|
||||
}
|
||||
|
||||
export function logout(req: Request, res: Response) {
|
||||
sessionStore.destroy(sessionIdFromRequest(req));
|
||||
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined });
|
||||
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig, 0), maxAge: undefined });
|
||||
res.json({ signedOut: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type { Request, Response } from "express";
|
||||
import type { ZodError } from "zod";
|
||||
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 { LastAdminError, UsernameTakenError } from "../../domain/services/user.service.js";
|
||||
import { sessionStore, userService } from "../composition/auth.js";
|
||||
import { userRepository } from "../composition/application-repositories.js";
|
||||
|
||||
// The password hash never leaves this layer.
|
||||
|
|
@ -29,6 +30,10 @@ function toDto(user: {
|
|||
};
|
||||
}
|
||||
|
||||
function firstIssue(error: ZodError): string {
|
||||
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
}
|
||||
|
||||
export async function listUsers(_req: Request, res: Response) {
|
||||
const rows = await userRepository.list();
|
||||
res.json(rows.map(toDto));
|
||||
|
|
@ -37,7 +42,7 @@ export async function listUsers(_req: Request, res: Response) {
|
|||
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() });
|
||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -64,7 +69,7 @@ export async function updateUser(req: Request, res: Response) {
|
|||
}
|
||||
const parsed = updateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||
return;
|
||||
}
|
||||
// The signed-in admin can't lock themselves out by deactivating or
|
||||
|
|
@ -78,8 +83,18 @@ export async function updateUser(req: Request, res: Response) {
|
|||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
return;
|
||||
}
|
||||
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
||||
res.json(updated ? toDto(updated) : null);
|
||||
try {
|
||||
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
||||
// Deactivation takes effect immediately, not just at the next login.
|
||||
if (parsed.data.active === false) sessionStore.destroyAllForUser(userId);
|
||||
res.json(updated ? toDto(updated) : null);
|
||||
} catch (error) {
|
||||
if (error instanceof LastAdminError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetUserPassword(req: Request, res: Response) {
|
||||
|
|
@ -90,7 +105,7 @@ export async function resetUserPassword(req: Request, res: Response) {
|
|||
}
|
||||
const parsed = resetPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||
return;
|
||||
}
|
||||
const existing = await userRepository.findById(userId);
|
||||
|
|
@ -99,5 +114,8 @@ export async function resetUserPassword(req: Request, res: Response) {
|
|||
return;
|
||||
}
|
||||
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
|
||||
// A reset is usually a response to a suspected compromise: every open
|
||||
// session of that account ends now.
|
||||
sessionStore.destroyAllForUser(userId);
|
||||
res.json(updated ? toDto(updated) : null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ 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 {
|
||||
createAuthGuard,
|
||||
requirePasswordChanged,
|
||||
requireSameOriginForWrites,
|
||||
} 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";
|
||||
|
|
@ -19,6 +23,7 @@ const port = Number(process.env.PORT || 3000);
|
|||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||
const shutdownTimeoutMs = 15 * 1000;
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
|
|
@ -59,6 +64,7 @@ app.get("/health", (_req, res) => {
|
|||
// /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", requireSameOriginForWrites());
|
||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
||||
app.use("/api", requirePasswordChanged());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue