forked from jappel/leistungsbilanz-ts
With AUTH_DEV_AUTOLOGIN=<username>, every /api request without a valid session runs as that existing, active user and skips the forced password change. Unset keeps the normal login; unknown or deactivated users still get 401, real sessions take precedence, the CSRF application header stays mandatory and the API logs a warning at startup. The switch is not limited to localhost because the development instance is reached from other machines and through a reverse proxy (THREATMODEL D2/R15). It must never be set on an instance with real project data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
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 { UserStore } from "../../domain/ports/user.store.js";
|
|
import type { UserRole } from "../../shared/constants/user-roles.js";
|
|
import {
|
|
WRITE_REQUEST_HEADER_NAME,
|
|
WRITE_REQUEST_HEADER_VALUE,
|
|
} from "../../shared/constants/request-security.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: Pick<UserStore, "findById">,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Session identity, or on a development instance with AUTH_DEV_AUTOLOGIN the
|
|
* configured active user. The auto-login user skips the forced password
|
|
* change because nobody signs in interactively.
|
|
*/
|
|
export async function resolveRequestIdentity(
|
|
req: { headers: { cookie?: string | undefined } },
|
|
authConfig: Pick<AuthConfig, "devAutoLogin">,
|
|
sessions: SessionStore,
|
|
users: Pick<UserStore, "findById" | "findByUsername">,
|
|
slideExpiry = true
|
|
): Promise<AuthUser | undefined> {
|
|
const identity = await identityFromRequest(req, sessions, users, slideExpiry);
|
|
if (identity || !authConfig.devAutoLogin) return identity;
|
|
const user = await users.findByUsername(authConfig.devAutoLogin);
|
|
if (!user || !user.active) return undefined;
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
name: user.name,
|
|
role: user.role as UserRole,
|
|
mustChangePassword: false,
|
|
};
|
|
}
|
|
|
|
export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
secure: authConfig.cookieSecure,
|
|
path: "/",
|
|
maxAge: maxAgeMs,
|
|
};
|
|
}
|
|
|
|
/** 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 may receive the
|
|
* cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN.
|
|
* Every write therefore needs an application header that HTML forms cannot
|
|
* set; a cross-origin fetch would require a CORS preflight, which the API does
|
|
* not permit. Sec-Fetch-Site remains an additional browser-side check.
|
|
*/
|
|
export function requireSameOriginForWrites(): RequestHandler {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (SAFE_METHODS.has(req.method)) {
|
|
next();
|
|
return;
|
|
}
|
|
if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) {
|
|
res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." });
|
|
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,
|
|
users: Pick<UserStore, "findById" | "findByUsername">,
|
|
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
|
): RequestHandler {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (openPaths.includes(req.path)) {
|
|
next();
|
|
return;
|
|
}
|
|
resolveRequestIdentity(req, authConfig, sessions, users)
|
|
.then((identity) => {
|
|
if (!identity) {
|
|
res.status(401).json({ error: "Nicht angemeldet." });
|
|
return;
|
|
}
|
|
req.authUser = identity;
|
|
const sessionId = sessionIdFromRequest(req);
|
|
if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId);
|
|
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();
|
|
};
|
|
}
|