forked from jappel/leistungsbilanz-ts
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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
import type { Request, Response } from "express";
|
|
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.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";
|
|
|
|
export async function getAuthStatus(req: Request, res: Response) {
|
|
const identity = await identityFromRequest(req, sessionStore, userRepository);
|
|
const sessionId = sessionIdFromRequest(req);
|
|
if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId);
|
|
res.json({
|
|
signedIn: Boolean(identity),
|
|
mustChangePassword: identity?.mustChangePassword ?? false,
|
|
});
|
|
}
|
|
|
|
export async function login(req: Request, res: Response) {
|
|
const parsed = loginSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
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(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);
|
|
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;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function getMe(req: Request, res: Response) {
|
|
const identity = req.authUser;
|
|
if (!identity) {
|
|
res.status(401).json({ error: "Nicht angemeldet." });
|
|
return;
|
|
}
|
|
res.json(identity);
|
|
}
|
|
|
|
export async function changePassword(req: Request, res: Response) {
|
|
const identity = req.authUser;
|
|
if (!identity) {
|
|
res.status(401).json({ error: "Nicht angemeldet." });
|
|
return;
|
|
}
|
|
const parsed = changePasswordSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." });
|
|
return;
|
|
}
|
|
try {
|
|
await userService.changeOwnPassword(
|
|
identity.id,
|
|
parsed.data.currentPassword,
|
|
parsed.data.newPassword
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) {
|
|
res.status(400).json({ error: error.message });
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
// 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, 0), maxAge: undefined });
|
|
res.json({ signedOut: true });
|
|
}
|