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