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:
Grovy311 2026-09-28 21:30:30 +02:00
parent 8d2948cdb1
commit 5073d9342a
18 changed files with 683 additions and 81 deletions

View file

@ -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);