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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
|
||||
import type {
|
||||
LdapInput,
|
||||
LdapTestResult,
|
||||
AuthMethods,
|
||||
OidcInput,
|
||||
OidcTestResult,
|
||||
@@ -184,7 +186,8 @@ export const usersApi = {
|
||||
resetPassword: (id: string) =>
|
||||
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
|
||||
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
|
||||
disconnectOidc: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}/identities/oidc`),
|
||||
disconnect: (id: string, kind: 'oidc' | 'ldap') =>
|
||||
api<{ ok: true }>('DELETE', `/api/users/${id}/identities/${kind}`),
|
||||
};
|
||||
|
||||
export const extensionsApi = {
|
||||
@@ -246,6 +249,9 @@ export const settingsApi = {
|
||||
),
|
||||
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
|
||||
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
|
||||
/** 12 §4.1 */
|
||||
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
|
||||
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
|
||||
};
|
||||
|
||||
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
|
||||
|
||||
@@ -59,6 +59,11 @@ export function SignInCard() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ways.ldap && (
|
||||
<p className="text-sm">
|
||||
Your company directory account is connected: its password works here too.
|
||||
</p>
|
||||
)}
|
||||
{oidc && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<h3 className="font-medium">{oidc.label}</h3>
|
||||
|
||||
@@ -99,12 +99,15 @@ export function LoginPage() {
|
||||
noValidate
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<Field label="Email" error={errors.login?.message}>
|
||||
<Field
|
||||
label={methods.data?.ldap ? 'Email or username' : 'Email'}
|
||||
error={errors.login?.message}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
{...form.register('login')}
|
||||
type="email"
|
||||
type={methods.data?.ldap ? 'text' : 'email'}
|
||||
autoComplete="username"
|
||||
className={inputClass}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
WEBHOOK_EVENTS,
|
||||
formatNumber,
|
||||
type AiTestResult,
|
||||
LDAP_PRESETS,
|
||||
type AuthMode,
|
||||
type LdapInput,
|
||||
type OidcInput,
|
||||
type SettingsDto,
|
||||
type WebhookDto,
|
||||
@@ -77,7 +79,12 @@ export function SettingsPage() {
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
|
||||
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
|
||||
{settings.data && tab === 'sign-in' && <SignInTab s={settings.data} />}
|
||||
{settings.data && tab === 'sign-in' && (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SignInTab s={settings.data} />
|
||||
<DirectoryForm s={settings.data} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1570,3 +1577,210 @@ function SignInTab({ s }: { s: SettingsDto }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sign-in → Directory (spec 12 §4.1): LDAP / LDAPS, checked when a password isn't ours. */
|
||||
function DirectoryForm({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const d = s['auth.ldap'];
|
||||
const [v, setV] = useState({
|
||||
enabled: d?.enabled ?? false,
|
||||
preset: d?.preset ?? ('ad' as LdapInput['preset']),
|
||||
url: d?.url ?? '',
|
||||
startTls: d?.startTls ?? false,
|
||||
bindDn: d?.bindDn ?? '',
|
||||
bindPassword: '',
|
||||
baseDn: d?.baseDn ?? '',
|
||||
userFilter: d?.userFilter ?? LDAP_PRESETS.ad.userFilter,
|
||||
idAttribute: d?.idAttribute ?? LDAP_PRESETS.ad.idAttribute,
|
||||
emailAttribute: d?.emailAttribute ?? 'mail',
|
||||
nameAttribute: d?.nameAttribute ?? 'displayName',
|
||||
adminFilter: d?.adminFilter ?? '',
|
||||
});
|
||||
const [probe, setProbe] = useState({ testLogin: '', testPassword: '' });
|
||||
const input = (): LdapInput => ({ ...v, bindPassword: v.bindPassword || undefined });
|
||||
const test = useMutation({ mutationFn: () => settingsApi.testLdap({ ...input(), ...probe }) });
|
||||
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
|
||||
setV({ ...v, [k]: e.target.value });
|
||||
const preset = (p: LdapInput['preset']) => {
|
||||
const x = LDAP_PRESETS[p];
|
||||
setV({
|
||||
...v,
|
||||
preset: p,
|
||||
userFilter: x.userFilter,
|
||||
idAttribute: x.idAttribute,
|
||||
adminFilter: x.adminFilter,
|
||||
});
|
||||
};
|
||||
const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
className={inputClass}
|
||||
placeholder={placeholder}
|
||||
value={v[k] as string}
|
||||
onChange={set(k)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({ 'auth.ldap': v.url.trim() ? input() : null });
|
||||
setV({ ...v, bindPassword: '' });
|
||||
}}
|
||||
>
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
|
||||
<p className="text-sm text-zinc-500">
|
||||
When a password isn't the one set here, SelfPhoneNG asks the directory. People sign
|
||||
in with their directory username or email; the first time, they're matched to their
|
||||
user here by email. Nobody gets a user just for being in the directory.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4"
|
||||
checked={v.enabled}
|
||||
onChange={(e) => setV({ ...v, enabled: e.target.checked })}
|
||||
/>
|
||||
Check passwords against the directory
|
||||
</label>
|
||||
<Field label="Directory type" hint="Fills in the filters below; change them as needed">
|
||||
{(a) => (
|
||||
<select
|
||||
{...a}
|
||||
className={inputClass}
|
||||
value={v.preset}
|
||||
onChange={(e) => preset(e.target.value as LdapInput['preset'])}
|
||||
>
|
||||
{Object.entries(LDAP_PRESETS).map(([id, p]) => (
|
||||
<option key={id} value={id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{text(
|
||||
'url',
|
||||
'Address',
|
||||
'ldaps:// (port 636), or ldap:// with StartTLS. Plain ldap:// only to this server itself.',
|
||||
'ldaps://dc1.example.com',
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4"
|
||||
checked={v.startTls}
|
||||
onChange={(e) => setV({ ...v, startTls: e.target.checked })}
|
||||
/>
|
||||
StartTLS (for ldap:// addresses)
|
||||
</label>
|
||||
{text(
|
||||
'bindDn',
|
||||
'Service account DN',
|
||||
'An account that may search people. Empty: search anonymously.',
|
||||
'CN=pbx-search,OU=Service,DC=example,DC=com',
|
||||
)}
|
||||
<Field
|
||||
label="Service account password"
|
||||
hint={d?.hasBindPassword ? 'Saved. Type a new one to replace it.' : undefined}
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={d?.hasBindPassword ? '••••••••' : ''}
|
||||
className={inputClass}
|
||||
value={v.bindPassword}
|
||||
onChange={set('bindPassword')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{text(
|
||||
'baseDn',
|
||||
'Where people are',
|
||||
'Searched with everything under it',
|
||||
'DC=example,DC=com',
|
||||
)}
|
||||
{text(
|
||||
'userFilter',
|
||||
'User filter',
|
||||
'{login} is what they type. To allow one group only, add (memberOf=…) here.',
|
||||
)}
|
||||
{text(
|
||||
'adminFilter',
|
||||
'Admin filter',
|
||||
'People it matches are admins, the others users. Empty: you set roles on Users.',
|
||||
)}
|
||||
{text(
|
||||
'idAttribute',
|
||||
'ID attribute',
|
||||
'Never changes for a person: objectGUID, entryUUID, ipaUniqueID',
|
||||
)}
|
||||
{text('emailAttribute', 'Email attribute')}
|
||||
{text('nameAttribute', 'Name attribute')}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Test login (optional)" hint="Username or email">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
value={probe.testLogin}
|
||||
onChange={(e) => setProbe({ ...probe, testLogin: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Its password" hint="Only used for this test, never stored">
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className={inputClass}
|
||||
value={probe.testPassword}
|
||||
onChange={(e) => setProbe({ ...probe, testPassword: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending || !v.url.trim()}>
|
||||
Test
|
||||
</Button>
|
||||
{test.data && (
|
||||
<span
|
||||
role="status"
|
||||
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
|
||||
>
|
||||
{!test.data.ok
|
||||
? test.data.error
|
||||
: `Works: ${test.data.matching >= 1000 ? '1000+' : test.data.matching} people match the user filter.` +
|
||||
(test.data.user
|
||||
? ` ${test.data.user.dn}: ${test.data.user.email ?? 'no email'}, ${test.data.user.admin === null ? 'role set here' : test.data.user.admin ? 'admin' : 'user'}, ${test.data.user.matches ? `signs in as ${test.data.user.matches}` : 'no user here has that email'}.`
|
||||
: '')}
|
||||
</span>
|
||||
)}
|
||||
{test.error && <Alert>{test.error.message}</Alert>}
|
||||
</div>
|
||||
</fieldset>
|
||||
{save.isError && <Alert>{save.error.message}</Alert>}
|
||||
{save.isSuccess && (
|
||||
<p role="status" className="text-sm text-green-700 dark:text-green-400">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -240,7 +240,8 @@ function GeneralTab({
|
||||
Creates a new temporary password and signs them out everywhere.
|
||||
</p>
|
||||
{user.twoStep && <ResetTwoStep user={user} />}
|
||||
{user.signIn.oidc && <ConnectedOidc user={user} />}
|
||||
{user.signIn.oidc && <ConnectedAccount user={user} kind="oidc" />}
|
||||
{user.signIn.ldap && <ConnectedAccount user={user} kind="ldap" />}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
@@ -587,26 +588,36 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
|
||||
);
|
||||
}
|
||||
|
||||
/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
|
||||
function ConnectedOidc({ user }: { user: UserDto }) {
|
||||
/** A connected OpenID or directory account (12 §5): what it sent, and Disconnect. */
|
||||
function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap' }) {
|
||||
const qc = useQueryClient();
|
||||
const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
|
||||
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
|
||||
const label = kind === 'oidc' ? oidcLabel : 'Directory';
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => usersApi.disconnectOidc(user.id),
|
||||
mutationFn: () => usersApi.disconnect(user.id, kind),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
const oidc = user.signIn.oidc!;
|
||||
const oidc = user.signIn.oidc;
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-1 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
|
||||
</p>
|
||||
{kind === 'oidc' && oidc ? (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<span className="font-medium">{label}</span>: connected, their directory password works
|
||||
here
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
Disconnect {label}
|
||||
Disconnect {kind === 'oidc' ? label : 'the directory'}
|
||||
</Button>
|
||||
</div>
|
||||
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
|
||||
|
||||
Reference in new issue
Block a user