auth: directory leavers check, dashboard warning, Samba AD check (S5)
ci / check (push) Successful in 1m22s
ci / images (push) Successful in 22s

Spec 12 S5: job directoryCheck (hourly) looks up every active user with a directory account;
one deleted, disabled there or no longer matched by the user filter is disabled here, sessions
ended. Nothing changes when the directory is unreachable or a run would disable most people;
the last admin with a password is kept; nobody is re-enabled. Dashboard warning
directory_check (system.directoryWarning). The by-id search is a filter object: ldapts string
filters send bytes >= 0x80 as UTF-8, so AD's objectGUID never matched. Live test against a
Samba 4 AD DC: nested-group admin, the disabled bit, objectGUID, a deleted user disabled.
This commit is contained in:
iamdoubz committed 2026-10-26 07:47:12 -05:00
1 parent b06335586b
commit 19610777a4
14 files changed
+540 -17

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
// diegogslomp/samba-ad-dc
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Attribute, Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { LDAP_PRESETS } from '@selfphoneng/shared';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { H, createTestApp } from './app.ts';
const URL_ = process.env.AD_LIVE_URL;
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
const DC = 'DC=example,DC=com';
const BASE = `CN=Users,${DC}`;
const ADMIN_DN = `CN=Administrator,${BASE}`;
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
const tag = randomBytes(3).toString('hex');
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
string,
string,
string,
string,
];
const team = `CN=team-${tag},${BASE}`;
const admins = `CN=pbx-admins-${tag},${BASE}`;
const dn = (sam: string) => `CN=${sam},${BASE}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let ad: Client;
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
const row = async (sam: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${sam}@example.com`))
)[0]!;
beforeAll(async () => {
ad = new Client({ url: URL_! });
await ad.bind(ADMIN_DN, ADMIN_PASS);
// userAccountControl 512: a normal account; 514: the same, disabled.
for (const [sam, uac] of [
[ann, '512'],
[bob, '512'],
[dan, '514'],
[cat, '512'],
] as const)
await ad.add(dn(sam), [
...Object.entries({
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
sAMAccountName: [sam],
userPrincipalName: [`${sam}@example.com`],
mail: [`${sam}@example.com`],
displayName: [sam],
userAccountControl: [uac],
}).map(([type, values]) => new Attribute({ type, values })),
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
new Attribute({
type: 'unicodePwd',
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
}),
]);
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
await ad.add(team, {
objectClass: ['top', 'group'],
sAMAccountName: `team-${tag}`,
member: dn(ann),
});
await ad.add(admins, {
objectClass: ['top', 'group'],
sAMAccountName: `pbx-admins-${tag}`,
member: team,
});
t = await createTestApp();
for (const sam of [ann, bob, dan, cat])
await t.addUser(`${sam}@example.com`, 'local-password-1');
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
url: URL_!,
startTls: false,
bindDn: ADMIN_DN,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: BASE,
userFilter: LDAP_PRESETS.ad.userFilter,
idAttribute: LDAP_PRESETS.ad.idAttribute,
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
});
}, 60_000);
afterAll(async () => {
await t?.close();
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
await ad?.del(d).catch(() => {});
await ad?.unbind();
});
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-Directory-Pw1`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, (await row(ann)).id));
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
});
it('signs in by UPN and by mail; outside the group is a user', async () => {
resetLoginLimiter();
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
'user',
);
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
});
it('a disabled account (userAccountControl bit 2) is refused', async () => {
resetLoginLimiter();
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
});
it('directoryCheck disables a deleted user and keeps the rest', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
await ad.del(dn(cat));
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
expect((await row(cat)).isActive).toBe(false);
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
expect((await row(bob)).isActive).toBe(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
import { eq, ne } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {}, warn: () => {} };
const LDAP: LdapSettings = {
enabled: true,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'ou=people,dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '(memberOf=cn=pbx-admins)',
};
describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
/** A user here with a directory account `id`. */
const member = async (role: 'admin' | 'user' = 'user') => {
const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
const dirId = `uuid-${n}`;
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
return { ...u, dirId };
};
const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
[id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
const run = (found: [string, DirectoryEntry][] | Error) =>
checkDirectory({
db: t.db,
log,
lookup: () =>
found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
});
const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
const warning = () => getSetting(t.db, 'system.directoryWarning');
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
beforeEach(async () => {
await t.db.delete(userIdentities);
await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
await setSetting(t.db, 'auth.ldap', LDAP);
});
it('updates who is still there, and disables who is gone', async () => {
const [stays, promoted, leaves] = [await member(), await member(), await member()];
await t.login(leaves.email, 'local-password-1');
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, stays.id));
expect(ident!.email).toBe('new@dir.example.com');
expect((await user(promoted.id)).role).toBe('admin');
expect((await user(leaves.id)).isActive).toBe(false);
expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
expect(audits.map((a) => a.action)).toContain('directory_disabled');
expect(await warning()).toBeNull();
});
it('changes nothing and warns on the dashboard when the directory is down', async () => {
const a = await member();
await run(new Error('connect ECONNREFUSED'));
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
const admin = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
url: '/api/system/warnings',
headers: { ...H, cookie: admin },
});
expect(r.json().items).toContainEqual(
expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
);
});
it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null)]);
for (const u of all) expect((await user(u.id)).isActive).toBe(true);
expect(await warning()).toMatch(/would disable 3 of 4 people/);
});
it('two of four gone is not "most": they are disabled', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
expect((await user(all[2]!.id)).isActive).toBe(false);
expect((await user(all[3]!.id)).isActive).toBe(false);
});
it('keeps the last active admin with a password', async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
await run([]);
expect((await user(me!.id)).isActive).toBe(true);
await run([entry('admin-id', false)]);
expect((await user(me!.id)).role).toBe('admin');
});
it('does nothing with the directory off', async () => {
const a = await member();
await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([]);
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toBeNull();
});
});
+27 -6
View File
@@ -1,8 +1,9 @@
// 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.
// 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 \
@@ -16,6 +17,7 @@ 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';
@@ -35,6 +37,7 @@ 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 group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
@@ -82,6 +85,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -92,7 +96,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
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])
for (const id of [ann, bob, cat])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -103,12 +107,13 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob])
for (const id of [ann, bob, cat])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -193,6 +198,22 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
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));