auth: sign in with LDAP/LDAPS, directory settings and Test (S4)
Spec 12 S4: the login form checks the SelfPhoneNG password first, then the directory (ldapts, service-account search, the user's own bind on a second connection). Presets for lldap, AD, FreeIPA and OpenLDAP; Test with an optional test login. A directory account connects by email, never makes a user; adminFilter sets the role (applyRole shared with OpenID). One Disconnect route per kind (oidc, ldap). Live test against lldap 0.6.3 over LDAPS.
This commit is contained in:
1 parent
73963ee3a3
commit
b06335586b
19 files changed
+1160
-64
No files matched your search
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S4 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. 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 { 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 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'],
|
||||
])
|
||||
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])
|
||||
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);
|
||||
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
|
||||
await setSetting(t.db, 'auth.ldap', settings());
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
for (const id of [ann, bob])
|
||||
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.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());
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user