Harden user management integration

This commit is contained in:
Julian Appel 2026-09-28 22:42:03 +02:00
parent 73579d1e79
commit 847f4e9493
18 changed files with 394 additions and 111 deletions

View file

@ -21,6 +21,10 @@ import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.j
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;
@ -34,7 +38,14 @@ function createUserService() {
cookieSecure: false,
bootstrap: { username: "admin", password: "bootstrap-password-123" },
};
return { repository, service: new UserService(repository, config) };
return {
repository,
service: new UserService(
repository,
{ bootstrap: config.bootstrap },
{ hash: hashPassword, verify: verifyPassword }
),
};
}
describe("password hashing", () => {
@ -52,6 +63,17 @@ describe("password hashing", () => {
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
);
});
});
@ -135,12 +157,20 @@ describe("cookie parsing", () => {
});
describe("same-origin guard for writes", () => {
function run(method: string, fetchSite?: string) {
function run(method: string, fetchSite?: string, includeApplicationHeader = true) {
let status: number | undefined;
let passed = false;
const req = {
method,
headers: fetchSite ? { "sec-fetch-site": fetchSite } : {},
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) {
@ -158,12 +188,13 @@ describe("same-origin guard for writes", () => {
}
it("lets reads and same-origin writes through", () => {
assert.equal(run("GET", "cross-site").passed, true);
assert.equal(run("GET", "cross-site", false).passed, true);
assert.equal(run("POST", "same-origin").passed, true);
assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie");
assert.equal(run("POST").passed, true, "scripts can send the explicit application header");
});
it("rejects writes from other sites and other ports of the same host", () => {
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 });
@ -239,6 +270,30 @@ describe("user service", () => {
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({

View file

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getConfigEntry,
getConfigRedirect,
getConfigTabs,
} from "../src/frontend/utils/config-access.js";
describe("config access", () => {
it("offers account settings to every signed-in user", () => {
assert.deepEqual(getConfigEntry(false), { href: "/config/konto", label: "Konto" });
assert.deepEqual(getConfigTabs(false), [{ href: "/config/konto", label: "Konto" }]);
assert.equal(getConfigRedirect("/config", false), "/config/konto");
assert.equal(getConfigRedirect("/config/konto", false), null);
});
it("keeps user administration admin-only", () => {
assert.equal(getConfigRedirect("/config/benutzer", false), "/config/konto");
assert.deepEqual(getConfigTabs(true), [
{ href: "/config/benutzer", label: "Benutzer" },
{ href: "/config/konto", label: "Konto" },
]);
assert.deepEqual(getConfigEntry(true), { href: "/config/benutzer", label: "Config" });
assert.equal(getConfigRedirect("/config/benutzer", true), null);
});
});

View file

@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { authApi } from "../src/frontend/utils/auth-api.js";
import { createProject } from "../src/frontend/utils/api.js";
import { calculateCableSizing } from "../src/frontend/components/cable-sizing-api.js";
import type { CableSizingInput } from "../src/cable-sizing/domain/cable-sizing-calculation.js";
import {
WRITE_REQUEST_HEADER_NAME,
WRITE_REQUEST_HEADER_VALUE,
} from "../src/shared/constants/request-security.js";
describe("write request clients", () => {
it("send the application header from every frontend API client", async () => {
const originalFetch = globalThis.fetch;
const requests: RequestInit[] = [];
globalThis.fetch = async (_input, init) => {
requests.push(init ?? {});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await authApi.login("admin", "start-passwort");
await createProject("Testprojekt");
await calculateCableSizing({} as CableSizingInput);
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(requests.length, 3);
for (const request of requests) {
assert.equal(
new Headers(request.headers).get(WRITE_REQUEST_HEADER_NAME),
WRITE_REQUEST_HEADER_VALUE
);
}
});
});