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.
253 lines
10 KiB
TypeScript
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());
|
|
}
|
|
});
|
|
});
|