feat(auth): verify local ThothII users in the backend
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { verifyPassword } from "../src/auth/password.js";
|
||||
|
||||
interface Argon2Vector {
|
||||
password: string;
|
||||
phc: string;
|
||||
}
|
||||
|
||||
const vectors = JSON.parse(readFileSync(
|
||||
resolve(import.meta.dirname, "fixtures/argon2id-vectors.json"),
|
||||
"utf8",
|
||||
)) as Argon2Vector[];
|
||||
|
||||
describe("local Argon2id password verification", () => {
|
||||
test("accepts every committed Go-generated vector", () => {
|
||||
expect(vectors.length).toBeGreaterThan(0);
|
||||
for (const vector of vectors) {
|
||||
expect(verifyPassword(vector.password, vector.phc)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a one-byte password change", () => {
|
||||
for (const vector of vectors) {
|
||||
expect(verifyPassword(`${vector.password}!`, vector.phc)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects malformed and oversized PHC parameters before Argon2 allocation", () => {
|
||||
const password = vectors[0].password;
|
||||
const digest = vectors[0].phc.split("$")[5];
|
||||
const salt = vectors[0].phc.split("$")[4];
|
||||
const cases = [
|
||||
`$argon2id$v=19$m=262145,t=1,p=1$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=11,p=1$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`,
|
||||
];
|
||||
|
||||
for (const phc of cases) {
|
||||
expect(() => verifyPassword(password, phc)).not.toThrow();
|
||||
expect(verifyPassword(password, phc)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
linkSync,
|
||||
} from "node:fs";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
const userId = "7ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
|
||||
const createdRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of createdRoots.splice(0)) {
|
||||
for (const name of ["users.yaml", "users-link.yaml", "users-target.yaml", "replacement.yaml"]) {
|
||||
const path = join(root, name);
|
||||
if (existsSync(path) || lstatMaybe(path)) unlinkSync(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function lstatMaybe(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function root(): string {
|
||||
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-"));
|
||||
chmodSync(path, 0o700);
|
||||
createdRoots.push(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
function userYaml(options: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
role?: "user" | "admin";
|
||||
} = {}): string {
|
||||
return [
|
||||
` - id: ${options.id ?? adminId}`,
|
||||
` username: ${options.username ?? "Admin"}`,
|
||||
` displayName: ${options.displayName ?? "Admin"}`,
|
||||
` passwordHash: ${passwordHash}`,
|
||||
` roles:`,
|
||||
` - ${options.role ?? "admin"}`,
|
||||
` enabled: ${options.enabled ?? true}`,
|
||||
` authRevision: 1`,
|
||||
].join("\n") + "\n";
|
||||
}
|
||||
|
||||
function registryYaml(users: string): string {
|
||||
return `version: 1\nusers:\n${users}`;
|
||||
}
|
||||
|
||||
function writeRegistry(contents: string, file = "users.yaml"): { root: string; path: string } {
|
||||
const directory = root();
|
||||
const path = join(directory, file);
|
||||
writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
return { root: directory, path };
|
||||
}
|
||||
|
||||
async function expectInvalid(operation: Promise<unknown>, secrets: string[] = []): Promise<void> {
|
||||
try {
|
||||
await operation;
|
||||
throw new Error("operation unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).toBe("local_user_registry_invalid");
|
||||
for (const secret of secrets) expect(message).not.toContain(secret);
|
||||
}
|
||||
}
|
||||
|
||||
describe("local user registry", () => {
|
||||
test("reads known fields, performs case-insensitive lookup, and verifies passwords", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Local administrator" })));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
|
||||
await expect(registry.findByUsername("aDmIn")).resolves.toMatchObject({
|
||||
id: adminId,
|
||||
username: "Admin",
|
||||
normalizedUsername: "admin",
|
||||
displayName: "Local administrator",
|
||||
passwordHash,
|
||||
roles: ["admin"],
|
||||
enabled: true,
|
||||
authRevision: 1,
|
||||
});
|
||||
await expect(registry.findBySubject(adminId)).resolves.toMatchObject({ username: "Admin" });
|
||||
await expect(registry.verify(await registry.findByUsername("admin"), password)).resolves.toBe(true);
|
||||
await expect(registry.verify(await registry.findByUsername("admin"), `${password}!`)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("uses a dummy verification path for unknown and disabled users", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml() + userYaml({
|
||||
id: userId,
|
||||
username: "operator",
|
||||
role: "user",
|
||||
enabled: false,
|
||||
})));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
|
||||
await expect(registry.verify(undefined, password)).resolves.toBe(false);
|
||||
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
||||
])("rejects %s", async (_name, contents) => {
|
||||
const fixture = writeRegistry(contents);
|
||||
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||
});
|
||||
|
||||
test.each(["symlink", "hard link", "mode wider than 0600", "file larger than 1 MiB"])(
|
||||
"rejects unsafe %s registry metadata",
|
||||
async (kind) => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml()));
|
||||
if (kind === "symlink") {
|
||||
const target = join(fixture.root, "users-target.yaml");
|
||||
renameSync(fixture.path, target);
|
||||
symlinkSync(target, fixture.path);
|
||||
} else if (kind === "hard link") {
|
||||
linkSync(fixture.path, join(fixture.root, "users-link.yaml"));
|
||||
} else if (kind === "mode wider than 0600") {
|
||||
chmodSync(fixture.path, 0o640);
|
||||
} else {
|
||||
writeFileSync(fixture.path, "#".repeat((1 << 20) + 1), { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||
},
|
||||
);
|
||||
|
||||
test("reloads a same-size atomic replacement with changed metadata", async () => {
|
||||
const fixture = writeRegistry(registryYaml(userYaml({ displayName: "Admin" })));
|
||||
const registry = createLocalUserRegistry(fixture.path);
|
||||
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Admin" });
|
||||
|
||||
const replacement = join(fixture.root, "replacement.yaml");
|
||||
writeFileSync(replacement, registryYaml(userYaml({ displayName: "Owner" })), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
utimesSync(replacement, new Date("2035-01-01T00:00:00Z"), new Date("2035-01-01T00:00:00Z"));
|
||||
expect(lstatSync(replacement).size).toBe(lstatSync(fixture.path).size);
|
||||
renameSync(replacement, fixture.path);
|
||||
|
||||
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Owner" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user