leistungsbilanz-ts/tests/authentication.test.ts
Julian Appel 084002bea9 Add AUTH_DEV_AUTOLOGIN to switch off the login on development instances
With AUTH_DEV_AUTOLOGIN=<username>, every /api request without a valid
session runs as that existing, active user and skips the forced password
change. Unset keeps the normal login; unknown or deactivated users still
get 401, real sessions take precedence, the CSRF application header stays
mandatory and the API logs a warning at startup. The switch is not
limited to localhost because the development instance is reached from
other machines and through a reverse proxy (THREATMODEL D2/R15). It must
never be set on an instance with real project data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:03:29 +01:00

441 lines
16 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 {
createAuthGuard,
requireSameOriginForWrites,
} from "../src/server/auth/auth.middleware.js";
import { readAuthConfig, 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" },
devAutoLogin: null,
};
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 headers: Record<string, string> = {
...(fetchSite ? { "sec-fetch-site": fetchSite } : {}),
...(includeApplicationHeader
? { [WRITE_REQUEST_HEADER_NAME.toLowerCase()]: WRITE_REQUEST_HEADER_VALUE }
: {}),
};
const req = {
method,
headers,
get(name: string) {
return headers[name.toLowerCase()];
},
} 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("development auto-login", () => {
const baseConfig = {
sessionTtlMs: 12 * HOUR,
sessionMaxAgeMs: 7 * 24 * HOUR,
cookieSecure: false,
bootstrap: null,
};
// Runs the /api auth guard for one request without a session cookie.
async function guard(
users: UserRepository,
devAutoLogin: string | null,
sessions = new SessionStore(HOUR, 24 * HOUR),
cookie?: string
) {
const req = {
path: "/projects",
headers: cookie ? { cookie } : {},
} as unknown as Request;
let status: number | undefined;
const res = {
status(code: number) {
status = code;
return this;
},
json() {
return this;
},
cookie() {
return this;
},
} as unknown as Response;
const passed = await new Promise<boolean>((resolve, reject) => {
const middleware = createAuthGuard(
{ ...baseConfig, devAutoLogin },
sessions,
users
);
middleware(req, res, (error?: unknown) =>
error ? reject(error) : resolve(true)
);
setTimeout(() => resolve(false), 200);
});
return { passed, status, authUser: req.authUser };
}
it("is off unless AUTH_DEV_AUTOLOGIN names a user", () => {
assert.equal(readAuthConfig({}).devAutoLogin, null);
assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " " }).devAutoLogin, null);
assert.equal(readAuthConfig({ AUTH_DEV_AUTOLOGIN: " Julian " }).devAutoLogin, "julian");
});
it("signs every request without a session in as the configured active user", async () => {
const { repository, service } = createUserService();
const user = await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(user);
const result = await guard(repository, "julian");
assert.equal(result.passed, true);
assert.equal(result.authUser?.id, user.id);
assert.equal(result.authUser?.role, "admin");
// A development instance must not stop at the forced password change.
assert.equal(result.authUser?.mustChangePassword, false);
});
it("still requires a login without the switch, for unknown and for deactivated users", async () => {
const { repository, service } = createUserService();
const user = await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
assert.ok(user);
assert.deepEqual(
{ passed: (await guard(repository, null)).passed, status: (await guard(repository, null)).status },
{ passed: false, status: 401 }
);
assert.equal((await guard(repository, "niemand")).status, 401);
await service.createUser({
username: "zweiter",
name: "Zweiter",
role: "admin",
initialPassword: "start-passwort",
});
await service.updateRoleAndActive(user.id, { active: false });
assert.equal((await guard(repository, "julian")).status, 401);
});
it("keeps a real session in front of the auto-login user", async () => {
const { repository, service } = createUserService();
await service.createUser({
username: "julian",
name: "Julian",
role: "admin",
initialPassword: "start-passwort",
});
const other = await service.createUser({
username: "max",
name: "Max",
role: "user",
initialPassword: "start-passwort",
});
assert.ok(other);
const sessions = new SessionStore(HOUR, 24 * HOUR);
const sessionId = sessions.create(other.id).id;
const result = await guard(repository, "julian", sessions, `lb_session=${sessionId}`);
assert.equal(result.authUser?.username, "max");
});
});
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);
});
});