Add account management and LDAP login
Admins can now create local agent/admin accounts and configure LDAP directly from the UI (Настройки → Аккаунты / LDAP), with login trying LDAP first and falling back to the local password. This is the first place users.role is actually enforced (requireAdminSession). Fixed a bug in the generated 0005 migration: the INSERT into __new_users selected auth_source from the old users table, which doesn't have that column yet — caused drizzle-kit migrate to fail silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
8121d8aa51
commit
cb12711e91
23 files changed
+2379
-13
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import ldap from "ldapjs";
|
||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||
|
||||
export interface LdapUserInfo {
|
||||
dn: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ConnectionSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
}
|
||||
|
||||
function createLdapClient(settings: ConnectionSettings): ldap.Client {
|
||||
const protocol = settings.useTls ? "ldaps" : "ldap";
|
||||
const client = ldap.createClient({
|
||||
url: `${protocol}://${settings.host}:${settings.port}`,
|
||||
timeout: 5_000,
|
||||
connectTimeout: 5_000,
|
||||
});
|
||||
// A socket-level error (e.g. host unreachable) with no listener would
|
||||
// throw and crash the process — swallow it here, every call site already
|
||||
// handles failure via the bind/search callback's error argument.
|
||||
client.on("error", () => {});
|
||||
return client;
|
||||
}
|
||||
|
||||
function bind(client: ldap.Client, dn: string, password: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function search(
|
||||
client: ldap.Client,
|
||||
baseDn: string,
|
||||
filter: string,
|
||||
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const results: { dn: string; attributes: Record<string, string> }[] = [];
|
||||
client.search(baseDn, { filter, scope: "sub" }, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
res.on("searchEntry", (entry) => {
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const attr of entry.pojo.attributes) {
|
||||
attributes[attr.type] = attr.values[0] ?? "";
|
||||
}
|
||||
results.push({ dn: entry.objectName ?? entry.pojo.objectName, attributes });
|
||||
});
|
||||
res.on("error", (searchErr) => reject(searchErr));
|
||||
res.on("end", () => resolve(results));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unbindQuietly(client: ldap.Client): void {
|
||||
client.unbind(() => {});
|
||||
}
|
||||
|
||||
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
|
||||
function escapeFilterValue(value: string): string {
|
||||
return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
/** Binds with the service account only — used to validate settings before saving. Throws on failure. */
|
||||
export async function testLdapBind(settings: ConnectionSettings & { bindDn: string; bindPassword: string }): Promise<void> {
|
||||
const client = createLdapClient(settings);
|
||||
try {
|
||||
await bind(client, settings.bindDn, settings.bindPassword);
|
||||
} finally {
|
||||
unbindQuietly(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-then-bind: finds the user by userFilter, then re-binds as their
|
||||
* own DN with the supplied password to actually verify it. Every failure
|
||||
* mode (not configured, disabled, unreachable, no match, wrong password)
|
||||
* normalizes to null — a directory outage must never break local login.
|
||||
*/
|
||||
export async function authenticateLdapUser(email: string, password: string): Promise<LdapUserInfo | null> {
|
||||
const settings = await getLdapSettings();
|
||||
if (!settings) return null;
|
||||
|
||||
const serviceClient = createLdapClient(settings);
|
||||
try {
|
||||
await bind(serviceClient, settings.bindDn, settings.bindPassword);
|
||||
const filter = settings.userFilter.replace("{{email}}", escapeFilterValue(email));
|
||||
const entries = await search(serviceClient, settings.baseDn, filter);
|
||||
|
||||
const match = entries[0];
|
||||
if (!match) return null;
|
||||
|
||||
const userClient = createLdapClient(settings);
|
||||
try {
|
||||
await bind(userClient, match.dn, password);
|
||||
} finally {
|
||||
unbindQuietly(userClient);
|
||||
}
|
||||
|
||||
return {
|
||||
dn: match.dn,
|
||||
email: match.attributes.mail || email,
|
||||
name: match.attributes.cn || match.attributes.displayName || email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
unbindQuietly(serviceClient);
|
||||
}
|
||||
}
|
||||
|
||||
/** Broad directory browse (listFilter) for the "import accounts from LDAP" admin UI. */
|
||||
export async function searchLdapDirectory(): Promise<LdapUserInfo[]> {
|
||||
const settings = await getLdapSettings();
|
||||
if (!settings) return [];
|
||||
|
||||
const client = createLdapClient(settings);
|
||||
try {
|
||||
await bind(client, settings.bindDn, settings.bindPassword);
|
||||
const entries = await search(client, settings.baseDn, settings.listFilter);
|
||||
return entries
|
||||
.filter((e) => e.attributes.mail)
|
||||
.map((e) => ({
|
||||
dn: e.dn,
|
||||
email: e.attributes.mail,
|
||||
name: e.attributes.cn || e.attributes.displayName || e.attributes.mail,
|
||||
}));
|
||||
} finally {
|
||||
unbindQuietly(client);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user