leistungsbilanz-ts/tests/authentication.test.ts

320 lines
12 KiB
TypeScript
Raw Permalink 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";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../src/shared/constants/request-security.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,
{ bootstrap: config.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
),
};
}
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);
assert.equal(
await verifyPassword(
"x",
`scrypt$1073741824$8$1$${"00".repeat(16)}$${"00".repeat(64)}`
),
false
);
assert.equal(
await verifyPassword("x", `scrypt$16384$8$1$00$${"00".repeat(64)}`),
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, includeApplicationHeader = true) {
let status: number | undefined;
let passed = false;
const req = {
method,
headers: {
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
...(includeApplicationHeader
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
: {}),
},
get(name: string) {
return this.headers[name.toLowerCase() as keyof typeof this.headers];
},
} 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", false).passed, true);
assert.equal(run("POST", "same-origin").passed, true);
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
});
it("rejects writes without the application header or from another origin", () => {
assert.deepEqual(run("POST", undefined, false), { passed: false, status: 403 });
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("preserves one active admin across concurrent updates", async () => {
const { repository, service } = createUserService();
await service.ensureBootstrapAdmin();
const second = await service.createUser({
username: "zweiter-admin",
name: "Zweiter Admin",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(second);
const [first] = (await repository.list()).filter((user) => user.username === "admin");
const results = await Promise.allSettled([
service.updateRoleAndActive(first.id, { role: "user" }),
service.updateRoleAndActive(second.id, { role: "user" }),
]);
assert.equal(results.filter((result) => result.status === "fulfilled").length, 1);
assert.equal(results.filter((result) => result.status === "rejected").length, 1);
const activeAdmins = (await repository.list()).filter(
(user) => user.role === "admin" && user.active
);
assert.equal(activeAdmins.length, 1);
});
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);
});
});