Add an admin-only LDAP configuration UI with an enable toggle and full sign-in integration. Backend: - ldapSettings model + migration (single-row config) - GET/PUT/test routes under /ldap (admin-gated; bind password masked) - shared ldapClient with RFC 4515 filter escaping and empty-password guard - signin tries local auth first, then LDAP when enabled (find-or-create local user) so the bootstrap admin is never locked out Frontend: - LDAP settings page (Switch + form + test connection) under /admin/ldap - AdminNav tabs between user management and LDAP - ldapControl util, types, and Ldap i18n namespace for all 6 locales Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
2.8 KiB
JavaScript
98 lines
2.8 KiB
JavaScript
import { Client } from 'ldapts';
|
|
|
|
/**
|
|
* Escape a value before putting it into an LDAP search filter (RFC 4515).
|
|
* Prevents LDAP filter injection through the typed username.
|
|
*/
|
|
function escapeFilterValue(value) {
|
|
return String(value).replace(/[\\*()]/g, (char) => {
|
|
switch (char) {
|
|
case '\\':
|
|
return '\\5c';
|
|
case '*':
|
|
return '\\2a';
|
|
case '(':
|
|
return '\\28';
|
|
case ')':
|
|
return '\\29';
|
|
default:
|
|
return char;
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildFilter(template, username) {
|
|
return template.replace(/\{\{username\}\}/g, escapeFilterValue(username));
|
|
}
|
|
|
|
function firstValue(attributeValue) {
|
|
if (Array.isArray(attributeValue)) {
|
|
return attributeValue.length > 0 ? attributeValue[0].toString() : '';
|
|
}
|
|
return attributeValue !== undefined && attributeValue !== null ? attributeValue.toString() : '';
|
|
}
|
|
|
|
/**
|
|
* Get the single-row LDAP settings, creating defaults on first access.
|
|
*/
|
|
async function getLdapSettings(LdapSetting) {
|
|
let settings = await LdapSetting.findOne({ order: [['id', 'ASC']] });
|
|
if (!settings) {
|
|
settings = await LdapSetting.create({});
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
/**
|
|
* Authenticate a user against LDAP.
|
|
* Binds with the service account, searches for the user, then binds as
|
|
* the user to verify the password. Returns { email, username } on success.
|
|
* Throws an Error with a human-readable message on any failure.
|
|
*/
|
|
async function authenticateLdap(settings, username, password) {
|
|
// Reject empty password: many directories treat an empty password as an
|
|
// anonymous (unauthenticated) bind, which would be an auth bypass.
|
|
if (!password) {
|
|
throw new Error('Password is required');
|
|
}
|
|
if (!settings.url || !settings.searchBase) {
|
|
throw new Error('LDAP is not configured');
|
|
}
|
|
|
|
const client = new Client({ url: settings.url });
|
|
try {
|
|
await client.bind(settings.bindDn, settings.bindCredentials);
|
|
|
|
const filter = buildFilter(settings.searchFilter, username);
|
|
const { searchEntries } = await client.search(settings.searchBase, {
|
|
scope: 'sub',
|
|
filter,
|
|
attributes: [settings.emailAttribute, settings.usernameAttribute],
|
|
});
|
|
|
|
if (searchEntries.length === 0) {
|
|
throw new Error('User not found in directory');
|
|
}
|
|
const entry = searchEntries[0];
|
|
|
|
// Verify the password by binding as the found user.
|
|
await client.bind(entry.dn, password);
|
|
|
|
const email = firstValue(entry[settings.emailAttribute]);
|
|
if (!email) {
|
|
throw new Error(`LDAP entry has no "${settings.emailAttribute}" attribute`);
|
|
}
|
|
const displayName = firstValue(entry[settings.usernameAttribute]) || email;
|
|
|
|
return { email, username: displayName };
|
|
} finally {
|
|
try {
|
|
await client.unbind();
|
|
} catch {
|
|
// ignore unbind errors
|
|
}
|
|
}
|
|
}
|
|
|
|
export { authenticateLdap, getLdapSettings, buildFilter, escapeFilterValue };
|