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

@ -0,0 +1,265 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Request, Response } from "express";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { UserRepository } from "../src/db/repositories/user.repository.js";
import {
InvalidCredentialsError,
LastAdminError,
PasswordUnchangedError,
UserService,
UsernameTakenError,
WrongPasswordError,
} from "../src/domain/services/user.service.js";
import { hashPassword, verifyPassword } from "../src/server/auth/password.js";
import { SessionStore } from "../src/server/auth/sessions.js";
import { LoginThrottle } from "../src/server/auth/login-throttle.js";
import { readCookie } from "../src/server/auth/cookie.js";
import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js";
import type { AuthConfig } from "../src/server/auth/auth-config.js";
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
import { loginSchema } from "../src/shared/validation/auth.schemas.js";
const HOUR = 60 * 60 * 1000;
function createUserService() {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
const repository = new UserRepository(context.db);
const config: AuthConfig = {
sessionTtlMs: 12 * HOUR,
sessionMaxAgeMs: 7 * 24 * HOUR,
cookieSecure: false,
bootstrap: { username: "admin", password: "bootstrap-password-123" },
};
return { repository, service: new UserService(repository, config) };
}
describe("password hashing", () => {
it("verifies the right password and rejects a wrong one", async () => {
const hash = await hashPassword("richtiges-passwort");
assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
assert.equal(await verifyPassword("richtiges-passwort", hash), true);
assert.equal(await verifyPassword("falsches-passwort", hash), false);
});
it("uses a fresh salt per hash", async () => {
assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich"));
});
it("rejects malformed stored hashes instead of throwing", async () => {
assert.equal(await verifyPassword("x", "not-a-hash"), false);
assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false);
});
});
describe("session store", () => {
it("expires idle sessions and slides on activity", () => {
let now = 0;
const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now);
const session = store.create("user-1");
now = 50 * 60 * 1000;
assert.ok(store.get(session.id));
now += 50 * 60 * 1000;
assert.ok(store.get(session.id), "activity must have extended the idle window");
now += 2 * HOUR;
assert.equal(store.get(session.id), undefined);
});
it("never outlives the absolute lifetime, even when constantly used", () => {
let now = 0;
const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now);
const session = store.create("user-1");
for (let step = 0; step < 5; step += 1) {
now += 30 * 60 * 1000;
store.get(session.id);
}
now = 3 * HOUR;
assert.equal(store.get(session.id), undefined);
});
it("ends all sessions of a user except an optional one", () => {
const store = new SessionStore(HOUR, 24 * HOUR);
const first = store.create("user-1");
const second = store.create("user-1");
const other = store.create("user-2");
store.destroyAllForUser("user-1", second.id);
assert.equal(store.get(first.id), undefined);
assert.ok(store.get(second.id));
assert.ok(store.get(other.id));
});
it("bounds the number of sessions per user by dropping the oldest", () => {
let now = 0;
const store = new SessionStore(HOUR, 24 * HOUR, () => now);
const oldest = store.create("user-1");
for (let index = 0; index < 20; index += 1) {
now += 1;
store.create("user-1");
}
assert.equal(store.get(oldest.id), undefined);
});
});
describe("login throttle", () => {
it("blocks a username after repeated failures and releases it after the window", () => {
let now = 0;
const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now);
for (let attempt = 0; attempt < 3; attempt += 1) {
assert.equal(throttle.retryAfterMs("Admin"), 0);
throttle.recordFailure("admin ");
}
assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter");
assert.equal(throttle.retryAfterMs("someone-else"), 0);
now = 15 * 60 * 1000 + 1;
assert.equal(throttle.retryAfterMs("admin"), 0);
});
it("resets the counter after a successful login", () => {
const throttle = new LoginThrottle(2);
throttle.recordFailure("admin");
throttle.recordSuccess("admin");
throttle.recordFailure("admin");
assert.equal(throttle.retryAfterMs("admin"), 0);
});
});
describe("cookie parsing", () => {
it("reads the session cookie and ignores malformed encodings", () => {
assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc");
assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined);
assert.equal(readCookie(undefined, "lb_session"), undefined);
});
});
describe("same-origin guard for writes", () => {
function run(method: string, fetchSite?: string) {
let status: number | undefined;
let passed = false;
const req = {
method,
headers: fetchSite ? { "sec-fetch-site": fetchSite } : {},
} as unknown as Request;
const res = {
status(code: number) {
status = code;
return this;
},
json() {
return this;
},
} as unknown as Response;
requireSameOriginForWrites()(req, res, () => {
passed = true;
});
return { passed, status };
}
it("lets reads and same-origin writes through", () => {
assert.equal(run("GET", "cross-site").passed, true);
assert.equal(run("POST", "same-origin").passed, true);
assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie");
});
it("rejects writes from other sites and other ports of the same host", () => {
assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 });
assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 });
assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 });
});
});
describe("input validation", () => {
it("rejects look-alike and whitespace usernames and normalizes case", () => {
const base = { name: "Test", role: "user", initialPassword: "start-passwort" };
assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false);
assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false);
const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " });
assert.equal(parsed.username, "max.muster@firma.de");
});
it("caps login input length so huge bodies never reach scrypt", () => {
assert.equal(
loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success,
false
);
});
});
describe("user service", () => {
it("creates the bootstrap admin only while the table is empty", async () => {
const { repository, service } = createUserService();
await service.ensureBootstrapAdmin();
await service.ensureBootstrapAdmin();
const users = await repository.list();
assert.equal(users.length, 1);
assert.equal(users[0].role, "admin");
assert.equal(users[0].mustChangePassword, true);
});
it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => {
const { service } = createUserService();
const user = await service.createUser({
username: "max",
name: "Max",
role: "user",
initialPassword: "start-passwort",
});
assert.ok(user);
await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError);
await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError);
assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id);
await service.updateRoleAndActive(user.id, { active: false });
await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError);
});
it("refuses duplicate usernames", async () => {
const { service } = createUserService();
const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" };
await service.createUser(input);
await assert.rejects(service.createUser(input), UsernameTakenError);
});
it("never removes the last active admin", async () => {
const { repository, service } = createUserService();
await service.ensureBootstrapAdmin();
const [admin] = await repository.list();
await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError);
await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError);
const second = await service.createUser({
username: "zweiter",
name: "Zweiter",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(second);
await service.updateRoleAndActive(admin.id, { role: "user" });
await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError);
});
it("requires the current password and a different new one", async () => {
const { service } = createUserService();
const user = await service.createUser({
username: "max",
name: "Max",
role: "user",
initialPassword: "start-passwort",
});
assert.ok(user);
await assert.rejects(
service.changeOwnPassword(user.id, "falsch", "neues-passwort"),
WrongPasswordError
);
await assert.rejects(
service.changeOwnPassword(user.id, "start-passwort", "start-passwort"),
PasswordUnchangedError
);
const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort");
assert.equal(changed?.mustChangePassword, false);
const reset = await service.resetPassword(user.id, "admin-vergeben");
assert.equal(reset?.mustChangePassword, true);
});
});