forked from jappel/leistungsbilanz-ts
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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue