Files
selfphoneng/apps/api/test/ldap-live.test.ts
T
iamdoubz 97e67e347d
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
auth: security audit fixes, rate limits on every route
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00

253 lines
10 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
// LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and LDAP_LIVE_HTTP (its web port, to make the users);
// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
// Passed against lldap 0.6.3:
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
// -e LLDAP_LDAP_BASE_DN=dc=example,dc=com [-e LLDAP_LDAPS_OPTIONS__ENABLED=true …] \
// lldap/lldap:v0.6.3-alpine
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const URL_ = process.env.LDAP_LIVE_URL;
const HTTP = process.env.LDAP_LIVE_HTTP;
const LDAPS = process.env.LDAP_LIVE_LDAPS_URL;
const ADMIN_PASS = process.env.LDAP_LIVE_ADMIN_PASS ?? 'admin-pass-123';
const BASE = 'dc=example,dc=com';
/** RFC 3062 password modify request: SEQUENCE { [0] user DN, [2] new password }. */
function passwordModify(dn: string, password: string): Buffer {
const tlv = (tag: number, v: Buffer) => Buffer.concat([Buffer.from([tag, v.length]), v]);
return tlv(0x30, Buffer.concat([tlv(0x80, Buffer.from(dn)), tlv(0x82, Buffer.from(password))]));
}
describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)', () => {
const tag = randomBytes(3).toString('hex');
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
const dee = `dee${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
let admin: string;
const gql = async (query: string) => {
const r = await fetch(`${HTTP}/api/graphql`, {
method: 'POST',
headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
body: JSON.stringify({ query }),
});
const j = (await r.json()) as { data: Record<string, unknown>; errors?: unknown };
if (j.errors) throw new Error(JSON.stringify(j.errors));
return j.data;
};
const settings = (url = URL_!) => ({
enabled: true,
preset: 'lldap' as const,
url,
startTls: false,
bindDn: `uid=admin,ou=people,${BASE}`,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: `ou=people,${BASE}`,
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf=cn=${group},ou=groups,${BASE})`,
});
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
beforeAll(async () => {
const r = await fetch(`${HTTP}/auth/simple/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: ADMIN_PASS }),
});
jwt = ((await r.json()) as { token: string }).token;
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
[dee, 'Dee Fox'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
);
const g = (await gql(`mutation { createGroup(name: "${group}") { id } }`)) as {
createGroup: { id: number };
};
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
for (const id of [ann, bob, cat, dee])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
);
await c.unbind();
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
// New users with the temporary password from Users: the directory email may connect them.
for (const who of [ann, cat]) {
await t.addUser(`${who}@example.com`, `${who}-local-pw-12`);
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${who}@example.com`));
}
await t.addUser(`${dee}@example.com`, 'dee-own-password-12'); // chose her own password
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob, cat, dee])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
it('methods say the directory is on', async () => {
expect((await t.app.inject({ url: '/api/auth/methods' })).json().ldap).toBe(true);
});
it('signs in by uid, connecting the user by email, and memberOf makes an admin', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({
email: `${ann}@example.com`,
role: 'admin',
signIn: { password: true, ldap: true },
});
const [u] = await t.db
.select()
.from(users)
.where(eq(users.email, `${ann}@example.com`));
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
expect(ids).toMatchObject([{ kind: 'ldap', email: `${ann}@example.com` }]);
expect(ids[0]!.subject).toMatch(/^[0-9a-f-]{36}$/); // entryUUID
});
it('signs in by email too, and the local password still works', async () => {
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
});
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
resetLoginLimiter();
for (const [who, pw] of [
[ann, 'wrong-password'],
[ann, ''],
[bob, `${bob}-directory-pw`], // in the directory, but nobody here: no user is made
['*', `${ann}-directory-pw`],
]) {
const r = await login(who!, pw!);
expect([400, 401]).toContain(r.statusCode);
}
const [b] = await t.db
.select()
.from(users)
.where(eq(users.email, `${bob}@example.com`));
expect(b).toBeUndefined();
});
it('a directory that is down is a 503, and local passwords still work', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings('ldap://127.0.0.1:1'));
try {
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(503);
expect(r.json().error.code).toBe('directory_unavailable');
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
it('Test: counts people, and a test login says who it would be', async () => {
const { bindPasswordEnc: _, ...form } = settings();
const r = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: `${ann}-directory-pw` },
});
expect(r.json()).toMatchObject({
ok: true,
user: { email: `${ann}@example.com`, admin: true, matches: `${ann}@example.com` },
});
expect(r.json().matching).toBeGreaterThanOrEqual(3);
const bad = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: 'nope' },
});
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it('someone with their own password connects on My settings, never by email alone', async () => {
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(401);
const mine = await t.login(`${dee}@example.com`, 'dee-own-password-12');
const connect = (password: string) =>
t.app.inject({
method: 'POST',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
payload: { login: dee, password },
});
expect((await connect('wrong-password')).statusCode).toBe(400);
expect((await connect(`${dee}-directory-pw`)).statusCode).toBe(200);
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(200);
});
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
const active = async (who: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${who}@example.com`))
)[0]!.isActive;
expect(await active(cat)).toBe(false);
expect(await active(ann)).toBe(true);
});
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
try {
expect((await login(ann, `${ann}-directory-pw`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
});