forked from jappel/leistungsbilanz-ts
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:
parent
8d2948cdb1
commit
5073d9342a
18 changed files with 683 additions and 81 deletions
265
tests/authentication.test.ts
Normal file
265
tests/authentication.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue