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>
441 lines
16 KiB
TypeScript
441 lines
16 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 {
|
||
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);
|
||
});
|
||
});
|