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:
ogrechkoandClaude Sonnet 5 committed 2026-07-27 08:56:08 +00:00
1 parent 8121d8aa51
commit cb12711e91
23 files changed
+2379 -13

No files matched your search

+137
View File
@@ -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);
}
}