mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { startServer, type TestServer } from "./helpers.js";
|
|
|
|
const directory = vi.hoisted(() => ({
|
|
users: {} as Record<
|
|
string,
|
|
{ dn: string; password: string; attrs: Record<string, string> }
|
|
>,
|
|
adminDns: [] as string[],
|
|
servicePassword: "svc",
|
|
binds: [] as string[],
|
|
}));
|
|
|
|
vi.mock("ldapjs", async () => {
|
|
const { default: ldap } =
|
|
await vi.importActual<typeof import("ldapjs")>("ldapjs");
|
|
function createClient() {
|
|
return {
|
|
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
|
|
directory.binds.push(dn);
|
|
if (dn === "cn=service" && password === directory.servicePassword) {
|
|
return cb();
|
|
}
|
|
const user = Object.values(directory.users).find((u) => u.dn === dn);
|
|
if (user && user.password === password) return cb();
|
|
cb(new Error("invalid credentials"));
|
|
},
|
|
search: (
|
|
base: string,
|
|
options: { filter: string; attributes: string[] },
|
|
cb: (err: Error | null, res: unknown) => void,
|
|
) => {
|
|
const listeners: Record<string, (value?: unknown) => void> = {};
|
|
cb(null, {
|
|
on: (event: string, fn: (value?: unknown) => void) => {
|
|
listeners[event] = fn;
|
|
},
|
|
});
|
|
queueMicrotask(() => {
|
|
if (base === "ou=groups") {
|
|
for (const dn of directory.adminDns) {
|
|
if (options.filter.includes(dn)) {
|
|
listeners.searchEntry?.({
|
|
dn: { toString: () => "cn=admins,ou=groups" },
|
|
attributes: [{ type: "cn", values: ["admins"] }],
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
const match = /uid=([^)]+)/.exec(options.filter);
|
|
const user = match ? directory.users[match[1]] : undefined;
|
|
if (user) {
|
|
listeners.searchEntry?.({
|
|
dn: ldap.parseDN(user.dn),
|
|
attributes: Object.entries(user.attrs)
|
|
.filter(([type]) => options.attributes.includes(type))
|
|
.map(([type, value]) => ({
|
|
type,
|
|
values: [value],
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
listeners.end?.();
|
|
});
|
|
},
|
|
unbind: () => {},
|
|
};
|
|
}
|
|
return { default: { createClient }, createClient };
|
|
});
|
|
|
|
const CONFIG = {
|
|
host: "ldap.example",
|
|
port: 389,
|
|
bindDN: "cn=service",
|
|
bindPassword: "svc",
|
|
userSearchBase: "ou=people",
|
|
userSearchFilter: "(uid={{username}})",
|
|
usernameAttribute: "uid",
|
|
displayNameAttribute: "cn",
|
|
groupSearchBase: "ou=groups",
|
|
adminGroup: "admins",
|
|
allowedUsers: "@example.com",
|
|
};
|
|
|
|
let server: TestServer | null = null;
|
|
|
|
beforeEach(() => {
|
|
directory.users = {
|
|
bob: {
|
|
dn: "uid=bob,ou=people",
|
|
password: "hunter2",
|
|
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
|
|
},
|
|
};
|
|
directory.adminDns = [];
|
|
directory.binds = [];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await server?.close();
|
|
server = null;
|
|
});
|
|
|
|
async function withDirectory(options: Parameters<typeof startServer>[0] = {}) {
|
|
server = await startServer(options);
|
|
const created = await server.request("POST", "/providers", {
|
|
body: { name: "Corp LDAP", config: CONFIG },
|
|
});
|
|
expect(created.status).toBe(201);
|
|
return { s: server, id: created.body.id as number };
|
|
}
|
|
|
|
function verify(
|
|
s: TestServer,
|
|
instance: string,
|
|
body: Record<string, unknown>,
|
|
) {
|
|
const method = s.mock.auth.loginMethods[0];
|
|
return method.verify!(
|
|
{ body, query: {}, headers: {}, ip: "10.0.0.1" },
|
|
instance,
|
|
);
|
|
}
|
|
|
|
describe("LDAP sign-in", () => {
|
|
it.each([
|
|
"CN=Bob,OU=Первый Департамент,DC=example,DC=com",
|
|
String.raw`CN=Bob\, Builder,OU=Первый Департамент,DC=example,DC=com`,
|
|
String.raw`CN=Bob\5cBuilder,OU=People,DC=example,DC=com`,
|
|
])(
|
|
"binds with the directory's original distinguishedName: %s",
|
|
async (dn) => {
|
|
const { s, id } = await withDirectory();
|
|
directory.users.bob.dn = dn;
|
|
directory.users.bob.attrs.distinguishedName = dn;
|
|
const identity = await verify(s, String(id), {
|
|
username: "bob",
|
|
password: "hunter2",
|
|
});
|
|
expect(identity).toMatchObject({ subject: "bob" });
|
|
expect(directory.binds).toEqual(["cn=service", dn]);
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "wrong" }),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
},
|
|
);
|
|
|
|
it("binds as the service, then as the user, and returns the identity", async () => {
|
|
const { s, id } = await withDirectory();
|
|
directory.adminDns = ["uid=bob,ou=people"];
|
|
const identity = await verify(s, String(id), {
|
|
username: "bob",
|
|
password: "hunter2",
|
|
rememberMe: true,
|
|
});
|
|
expect(identity).toEqual({
|
|
kind: "external",
|
|
provider: `ldap:${id}`,
|
|
subject: "bob",
|
|
email: "bob@example.com",
|
|
name: "Bob Builder",
|
|
isAdmin: true,
|
|
allowedUsers: "@example.com",
|
|
legacy: { identifier: `ldap:${id}:bob`, providerRowId: id },
|
|
rememberMe: true,
|
|
rateLimitKey: `${id}:bob`,
|
|
});
|
|
expect(directory.binds).toEqual(["cn=service", "uid=bob,ou=people"]);
|
|
});
|
|
|
|
it("is not an admin when the directory says so", async () => {
|
|
const { s, id } = await withDirectory();
|
|
const identity = await verify(s, String(id), {
|
|
username: "bob",
|
|
password: "hunter2",
|
|
});
|
|
expect(identity).toMatchObject({ isAdmin: false });
|
|
});
|
|
|
|
it("refuses a wrong password and an unknown user the same way", async () => {
|
|
const { s, id } = await withDirectory();
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "nope" }),
|
|
).rejects.toMatchObject({
|
|
status: 401,
|
|
message: "Invalid username or password",
|
|
});
|
|
await expect(
|
|
verify(s, String(id), { username: "mallory", password: "x" }),
|
|
).rejects.toMatchObject({
|
|
status: 401,
|
|
message: "Invalid username or password",
|
|
});
|
|
expect(s.mock.auth.loginFailures.get(`10.0.0.1|${id}:bob`)).toBe(1);
|
|
});
|
|
|
|
it("locks out after too many failures, like password login", async () => {
|
|
const { s, id } = await withDirectory({ loginAttemptLimit: 2 });
|
|
for (let i = 0; i < 2; i++) {
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "nope" }),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
}
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
|
).rejects.toMatchObject({ status: 429 });
|
|
});
|
|
|
|
it("escapes the username in the search filter", async () => {
|
|
const { s, id } = await withDirectory();
|
|
await expect(
|
|
verify(s, String(id), { username: "bob)(uid=*", password: "hunter2" }),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
});
|
|
|
|
it("refuses a disabled or unknown directory", async () => {
|
|
const { s, id } = await withDirectory();
|
|
await s.request("PUT", `/providers/${id}`, { body: { enabled: false } });
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
await expect(
|
|
verify(s, "999", { username: "bob", password: "hunter2" }),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([
|
|
{ id: String(id), label: "Corp LDAP", enabled: false, type: "ldap" },
|
|
]);
|
|
});
|
|
|
|
it("needs a directory, a username and a password", async () => {
|
|
const { s } = await withDirectory();
|
|
await expect(
|
|
verify(s, null as never, { username: "bob" }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
});
|
|
|
|
describe("directory admin routes", () => {
|
|
it("seal the bind password and never send it back", async () => {
|
|
const { s, id } = await withDirectory();
|
|
const stored = s.db.sqlite
|
|
.prepare("SELECT config FROM p_ldap_providers WHERE id = ?")
|
|
.get(id) as { config: string };
|
|
expect(JSON.parse(stored.config).bindPassword).toMatch(/^sealed:/);
|
|
|
|
const listed = await s.request("GET", "/providers");
|
|
expect(listed.body.providers[0]).toMatchObject({
|
|
name: "Corp LDAP",
|
|
hasBindPassword: true,
|
|
});
|
|
expect(listed.body.providers[0].config.bindPassword).toBeUndefined();
|
|
});
|
|
|
|
it("keep the bind password when an edit leaves it empty", async () => {
|
|
const { s, id } = await withDirectory();
|
|
const updated = await s.request("PUT", `/providers/${id}`, {
|
|
body: { config: { host: "ldap2.example", bindPassword: "" } },
|
|
});
|
|
expect(updated.body).toMatchObject({
|
|
hasBindPassword: true,
|
|
config: { host: "ldap2.example" },
|
|
});
|
|
await expect(
|
|
verify(s, String(id), { username: "bob", password: "hunter2" }),
|
|
).resolves.toMatchObject({ subject: "bob" });
|
|
});
|
|
|
|
it("reject a directory with missing fields", async () => {
|
|
server = await startServer();
|
|
const response = await server.request("POST", "/providers", {
|
|
body: { name: "X", config: { host: "a" } },
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("will not delete a directory people still sign in with", async () => {
|
|
const { s, id } = await withDirectory({
|
|
linkedUsers: { "ldap:1": 1 },
|
|
});
|
|
expect(id).toBe(1);
|
|
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(409);
|
|
});
|
|
|
|
it("delete a directory nobody uses", async () => {
|
|
const { s, id } = await withDirectory();
|
|
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(200);
|
|
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([]);
|
|
});
|
|
|
|
it("need ldap.manage", async () => {
|
|
server = await startServer({ permissions: [] });
|
|
for (const [method, path] of [
|
|
["GET", "/providers"],
|
|
["POST", "/providers"],
|
|
["PUT", "/providers/1"],
|
|
["DELETE", "/providers/1"],
|
|
]) {
|
|
expect((await server.request(method, path, { body: {} })).status).toBe(
|
|
403,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("capabilities", () => {
|
|
it.each(["db:own", "auth:provide", "network:serve"])(
|
|
"activate fails closed without %s",
|
|
async (capability) => {
|
|
const { manifest } = await import("./helpers.js");
|
|
await expect(
|
|
startServer({
|
|
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
|
}),
|
|
).rejects.toThrow();
|
|
},
|
|
);
|
|
|
|
it("cannot read a bind password without secrets:own", async () => {
|
|
const { manifest } = await import("./helpers.js");
|
|
server = await startServer({
|
|
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
|
|
});
|
|
const response = await server.request("POST", "/providers", {
|
|
body: { name: "Corp LDAP", config: CONFIG },
|
|
});
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|