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>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
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);
|
||
});
|
||
});
|