leistungsbilanz-ts/tests/authentication.test.ts
Grovy311 5073d9342a 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>
2026-09-28 21:30:30 +02:00

265 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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