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

@ -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 }

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

View file

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

View 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();
}
}

View file

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