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:
iamdoubz committed 2026-10-25 18:39:52 -05:00
1 parent 73963ee3a3
commit b06335586b
19 files changed
+1160 -64

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
"ldapts": "^9.2.0",
"nodemailer": "10.0.11",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
+36 -9
View File
@@ -26,6 +26,8 @@ import {
import type { Db } from '../db/client.ts';
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
import { directorySignIn } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
@@ -213,7 +215,7 @@ export async function authRoutes(app: FastifyInstance) {
const oidc = await getSetting(db, 'auth.oidc');
return {
mode,
ldap: false, // S4
ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
};
});
@@ -221,13 +223,32 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (req, reply) => {
const { login, ...body } = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, login)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, login));
let [user] = await db.select().from(users).where(eq(users.email, login));
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
const ok =
user?.isActive && user.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
if (!user || !ok) {
let ok = user?.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
let method: 'password' | 'ldap' = 'password';
// Not this password here: the directory's, when it's on (12 §4.2).
const ldap = ok ? null : await getSetting(db, 'auth.ldap');
if (ldap?.enabled) {
let entry;
try {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
throw new HttpError(
503,
'directory_unavailable',
'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
);
}
const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
if (entry && !found)
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
if (found) [user, ok, method] = [found, true, 'ldap'];
}
if (!user || !ok || !user.isActive) {
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
@@ -261,7 +282,13 @@ export async function authRoutes(app: FastifyInstance) {
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
const token = await createSession(
db,
user.id,
req.ip,
req.headers['user-agent'] ?? null,
method,
);
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
@@ -270,7 +297,7 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login',
entityType: 'user',
entityId: user.id,
summary: `${user.email} logged in`,
summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
});
return { user: await toAuthUser(db, user) };
});
+26 -13
View File
@@ -224,38 +224,51 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(pending.next);
});
/** Disconnect (12 §3.3): refused when it would leave them no way in under the current mode. */
async function disconnect(req: FastifyRequest, reply: FastifyReply, userId: string) {
/**
* Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
* them no way in under the current mode. Sessions made with it end.
*/
async function disconnect(
req: FastifyRequest,
reply: FastifyReply,
userId: string,
kind: 'oidc' | 'ldap',
) {
const [u] = await db.select().from(users).where(eq(users.id, userId));
if (!u) throw notFound('User not found');
const ids = await db
.select({ kind: userIdentities.kind })
.from(userIdentities)
.where(eq(userIdentities.userId, userId));
if (!ids.some((i) => i.kind === 'oidc')) throw notFound('No OpenID account is connected');
if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
const left = ids.filter((i) => i.kind !== kind);
const mode = await getSetting(db, 'auth.mode');
const passwordWay = u.passwordHash !== null || ids.some((i) => i.kind === 'ldap');
if (!passwordWay || (mode === 'oidc' && u.role !== 'admin'))
const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
await db
.delete(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, 'oidc')));
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
const what = kind === 'oidc' ? 'OpenID' : 'directory';
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'sso_disconnected',
entityType: 'user',
entityId: userId,
summary: `Disconnected the OpenID account of ${u.email}`,
summary: `Disconnected the ${what} account of ${u.email}`,
});
return reply.send({ ok: true });
}
app.delete('/api/me/identities/oidc', { preHandler: requireAuth }, (req, reply) =>
disconnect(req, reply, req.user!.id),
);
app.delete('/api/users/:id/identities/oidc', { preHandler: requireAdmin }, (req, reply) =>
disconnect(req, reply, z.object({ id: z.uuid() }).parse(req.params).id),
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) =>
disconnect(req, reply, req.user!.id, Kind.parse(req.params).kind),
);
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
return disconnect(req, reply, p.id, p.kind);
});
}
+81 -1
View File
@@ -7,12 +7,14 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
LdapInputSchema,
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
type LdapTestResult,
type OidcInput,
type OidcTestResult,
type SettingKey,
@@ -34,6 +36,8 @@ import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
@@ -52,9 +56,29 @@ const READ_ONLY = new Set<string>([
]);
async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, 'contacts.contrack': ctk, 'auth.oidc': oidc, ...rest } = await getAllSettings(db);
const {
smtp,
'contacts.contrack': ctk,
'auth.oidc': oidc,
'auth.ldap': ldap,
...rest
} = await getAllSettings(db);
return {
...rest,
'auth.ldap': ldap && {
enabled: ldap.enabled,
preset: ldap.preset,
url: ldap.url,
startTls: ldap.startTls,
bindDn: ldap.bindDn,
baseDn: ldap.baseDn,
userFilter: ldap.userFilter,
idAttribute: ldap.idAttribute,
emailAttribute: ldap.emailAttribute,
nameAttribute: ldap.nameAttribute,
adminFilter: ldap.adminFilter,
hasBindPassword: ldap.bindPasswordEnc !== null,
},
'auth.oidc': oidc && {
label: oidc.label,
issuer: oidc.issuer,
@@ -117,6 +141,36 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
/** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
async function ldapFromInput(raw: unknown): Promise<LdapSettings | null> {
if (raw === null) return null;
const v = LdapInputSchema.parse(raw);
try {
assertLdap(v);
} catch (err) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.ldap');
return {
enabled: v.enabled,
preset: v.preset,
url: v.url.replace(/\/$/, ''),
startTls: v.startTls,
bindDn: v.bindDn,
bindPasswordEnc: v.bindPassword
? encrypt(v.bindPassword)
: v.clearBindPassword
? null
: (old?.bindPasswordEnc ?? null),
baseDn: v.baseDn,
userFilter: v.userFilter,
idAttribute: v.idAttribute,
emailAttribute: v.emailAttribute,
nameAttribute: v.nameAttribute,
adminFilter: v.adminFilter,
};
}
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
async function oidcFromInput(input: OidcInput | null): Promise<OidcSettings | null> {
if (!input) return null;
@@ -184,6 +238,8 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
} else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -291,6 +347,30 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
/**
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
* is used once and never stored or logged.
*/
app.post('/api/settings/auth/ldap/test', async (req): Promise<LdapTestResult> => {
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
testLogin: z.string().trim().max(254).optional(),
testPassword: z.string().max(200).optional(),
}).parse(req.body);
const s = (await ldapFromInput(form))!;
try {
const r = await testLdap(s, testLogin || undefined, testPassword);
if (!r.user) return { ok: true, matching: r.matching };
const matches = await directoryUserFor(db, r.user);
return {
ok: true,
matching: r.matching,
user: { ...r.user, matches: matches?.email ?? null },
};
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
app.post('/api/settings/auth/oidc/test', async (req): Promise<OidcTestResult> => {
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
import { audit } from '../services/audit.ts';
import type { DirectoryEntry } from './ldap.ts';
import { applyRole } from './role.ts';
type UserRow = typeof users.$inferSelect;
/** Who this entry is here, without changing anything (Settings → Test says it too). */
export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserRow | null> {
const [byId] = await db
.select({ u: users })
.from(userIdentities)
.innerJoin(users, eq(users.id, userIdentities.userId))
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
return byEmail ?? null;
}
/**
* The user this directory sign-in is for, connected now if it was matched by email (the
* directory is the company's own and the person just proved its password), with the role from
* adminFilter. Null: nobody here has this directory account or email.
*/
export async function directorySignIn(
db: Db,
log: { info(o: object, m: string): void },
e: DirectoryEntry,
ip: string,
): Promise<UserRow | null> {
const u = await directoryUserFor(db, e);
if (!u) return null;
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
if (!mine) {
await db.insert(userIdentities).values({
userId: u.id,
kind: 'ldap',
subject: e.id,
email: e.email,
lastUsedAt: new Date(),
});
await audit(db, {
actorUserId: u.id,
actorIp: ip,
action: 'ldap_connected',
entityType: 'user',
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
await applyRole(db, log, u.id, e.admin, 'the directory', ip);
const [after] = await db.select().from(users).where(eq(users.id, u.id));
return after!;
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
describe('LDAP (12 §4)', () => {
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
'(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
);
expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
});
it('{login} → * keeps the other conditions, for the leaver check', () => {
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
});
it('never sends a password in clear text to another host', () => {
const ok = { startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
expect(() =>
assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
});
it('refuses filters that do not parse', () => {
const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
});
it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
// A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
});
});
+216
View File
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
// bind as them with their password, read whether the admin filter matches them.
import {
Client,
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
SizeLimitExceededError,
escapeFilter,
type Entry,
} from 'ldapts';
import type { SettingValue } from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
export type LdapSettings = NonNullable<SettingValue<'auth.ldap'>>;
/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
export class DirectoryUnavailable extends Error {}
const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
/** 12 §4.1: no password in clear text over the network, and filters that parse. */
export function assertLdap(s: {
url: string;
startTls: boolean;
userFilter: string;
adminFilter: string | null;
}): void {
const u = new URL(s.url);
if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
for (const [name, f] of [
['User filter', s.userFilter],
['Admin filter', s.adminFilter],
] as const)
try {
if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
} catch (err) {
throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
}
}
/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
export function withLogin(filter: string, login: string): string {
const parts = filter.split('{login}');
const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
return escapeFilter(strings, ...parts.slice(1).map(() => login));
}
/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
const attr = (e: Entry, name: string): string | null => {
const v = first(e[name]);
if (v === undefined) return null;
return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
};
const newClient = (s: LdapSettings) =>
new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
async function startTls(c: Client, s: LdapSettings) {
if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
}
/** A connection bound as the service account (or anonymous). */
async function connect(s: LdapSettings): Promise<Client> {
const c = newClient(s);
try {
await startTls(c, s);
if (s.bindDn) {
// A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
if (!s.bindPasswordEnc) throw new Error('The service account has no password');
await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
}
return c;
} catch (err) {
await c.unbind().catch(() => {});
throw new DirectoryUnavailable(
err instanceof InvalidCredentialsError
? 'The directory refused the service account'
: (err as Error).message,
);
}
}
const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
/** The entries `login` matches (at most 2: more than one is as bad as none). */
async function findByLogin(c: Client, s: LdapSettings, login: string): Promise<Entry[]> {
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: withLogin(s.userFilter, login),
attributes: attributes(s),
explicitBufferAttributes: buffers(s),
sizeLimit: 2,
});
return r.searchEntries;
} catch (err) {
if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
throw err;
}
}
export type DirectoryEntry = {
dn: string;
/** The stable id attribute's value (12 §4.4). */
id: string;
email: string | null;
name: string | null;
/** adminFilter's answer; null when there is none (the role is managed here). */
admin: boolean | null;
};
async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise<DirectoryEntry> {
const id = attr(e, s.idAttribute);
if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
let admin: boolean | null = null;
if (s.adminFilter)
try {
const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
admin = r.searchEntries.length > 0;
} catch (err) {
// lldap answers "no such object" when the entry doesn't match a base search's filter.
if (!(err instanceof NoSuchObjectError)) throw err;
admin = false;
}
return {
dn: e.dn,
id,
email: attr(e, s.emailAttribute),
name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
admin,
};
}
/** Is this the password of that entry? Its own connection, so the service one stays bound. */
async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise<boolean> {
const u = newClient(s);
try {
await startTls(u, s);
await u.bind(dn, password);
return true;
} catch (err) {
if (err instanceof InvalidCredentialsError) return false;
throw new DirectoryUnavailable((err as Error).message);
} finally {
await u.unbind().catch(() => {});
}
}
const unavailable = (err: unknown) =>
err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
/**
* 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
* match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
*/
export async function ldapAuthenticate(
s: LdapSettings,
login: string,
password: string,
): Promise<DirectoryEntry | null> {
// Never bind with an empty password: many servers take it as anonymous success.
if (!password) return null;
const c = await connect(s);
try {
const found = await findByLogin(c, s, login);
if (found.length !== 1) return null;
if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
return await toEntry(c, s, found[0]!);
} catch (err) {
throw unavailable(err);
} finally {
await c.unbind().catch(() => {});
}
}
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
export async function testLdap(
s: LdapSettings,
login?: string,
password?: string,
): Promise<{ matching: number; user?: DirectoryEntry }> {
const c = await connect(s);
try {
let matching: number;
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: anyLogin(s.userFilter),
attributes: ['1.1'],
sizeLimit: 1000,
});
matching = r.searchEntries.length;
} catch (err) {
if (!(err instanceof SizeLimitExceededError)) throw err;
matching = 1000;
}
if (!login) return { matching };
const found = await findByLogin(c, s, login);
if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
throw new Error(`The directory refused the password for ${found[0]!.dn}`);
return { matching, user: await toEntry(c, s, found[0]!) };
} finally {
await c.unbind().catch(() => {});
}
}
+3 -20
View File
@@ -6,15 +6,15 @@ import * as oidc from 'openid-client';
import type { Db } from '../db/client.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, type ClaimsDecision } from './claims.ts';
import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
/** The role from adminGroups (never demoting the last admin with a password), groups, email. */
/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
export async function applyClaims(
db: Db,
log: Log,
@@ -24,24 +24,7 @@ export async function applyClaims(
email: string | null,
actorIp: string | null,
): Promise<void> {
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) return;
const role = d.admin === null ? user.role : d.admin ? 'admin' : 'user';
if (role !== user.role) {
if (role === 'user' && (await isLastAdmin(db, userId)))
log.info({ userId }, `kept the last admin with a password, though ${s.label} says user`);
else {
await db.update(users).set({ role }).where(eq(users.id, userId));
await audit(db, {
actorIp: actorIp ?? undefined,
action: 'update',
entityType: 'user',
entityId: userId,
summary: `${user.email} is now ${role}, from ${s.label}'s groups`,
diff: { before: { role: user.role }, after: { role } },
});
}
}
await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
await db
.update(userIdentities)
.set({ email, groups: d.groups })
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
import { eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
/** admin null: the role is managed here. Never demotes the last admin with a password. */
export async function applyRole(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
admin: boolean | null,
source: string,
actorIp: string | null,
): Promise<void> {
if (admin === null) return;
const [user] = await db.select().from(users).where(eq(users.id, userId));
const role = admin ? 'admin' : 'user';
if (!user || role === user.role) return;
if (role === 'user' && (await isLastAdmin(db, userId))) {
log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
return;
}
await db.update(users).set({ role }).where(eq(users.id, userId));
await audit(db, {
actorIp: actorIp ?? undefined,
action: 'update',
entityType: 'user',
entityId: userId,
summary: `${user.email} is now ${role}, from ${source}`,
diff: { before: { role: user.role }, after: { role } },
});
}
+205
View File
@@ -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());
}
});
});