LDAP
应用可以用目录服务(OpenLDAP、Active Directory、389 Directory Server……)验证用户登录、读写目录,也可以自己提供一个目录,给其他软件使用(SSSD、Keycloak、Gitea、Jenkins、Grafana、NAS 设备……)。由两个包完成:
@lakutata/ldap-engine,引擎:协议、客户端,以及由后端存放条目的服务器。它不需要应用,LDAP Engine 指南介绍了它。@lakutata/ldap,组件:LDAPClient、LDAPServer,以及把服务器的条目存进应用数据库的DirectoryBackend。它重新导出了引擎。
这篇指南讲的是组件:怎样注册、怎样注入,以及它们在引擎之上增加了什么。客户端和服务器本身的行为(分页、schema 检查、访问规则、密码策略、插件)在 LDAP Engine 指南里,下面各节会指出对应的章节。lakutata 包不重新导出这两个包:应用用到哪个就安装哪个。
npm install @lakutata/ldap # 会一起安装引擎
npm install @lakutata/netif # 按网卡监听时需要1. 用目录验证用户登录
LDAPClient 组件维护一个连接池,连接以服务账号绑定。控制器或组件用 @Inject 拿到它;authenticate() 先找到用户的条目,再在一个单独的连接上以该用户绑定。
import {Application, DTO, Exception} from 'lakutata'
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
import {POST} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {Expect} from 'lakutata/decorator/dto'
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
import {buildLDAPClientOptions, type LDAPClient} from '@lakutata/ldap'
class SignInDTO extends DTO {
@Expect(DTO.String().required())
public username: string
@Expect(DTO.String().required())
public password: string
}
class SignInRefusedException extends Exception {
public errno: string = 'E_SIGN_IN_REFUSED'
public statusCode: number = 401
}
class SessionController extends Controller {
@Inject('ldap')
protected readonly ldap: LDAPClient
@POST('/sign-in', SignInDTO)
public async signIn(input: SignInDTO): Promise<{dn: string, name?: string, mustChangePassword: boolean}> {
const result = await this.ldap.authenticate({
username: input.username,
password: input.password,
base: 'DC=example,DC=com',
attribute: 'sAMAccountName',
attributes: ['displayName']
})
if (!result.ok) {
//原因只写进日志:告诉对方是账号被锁定还是密码错误,会帮到攻击者
console.warn('sign-in refused', input.username, result.reason)
throw new SignInRefusedException('Wrong user name or password')
}
return {dn: result.dn!, name: result.entry?.first('displayName'), mustChangePassword: result.mustChangePassword ?? false}
}
}
Application.run(() => ({
id: 'portal.app',
name: 'Portal',
components: {
ldap: buildLDAPClientOptions({
url: ['ldaps://dc1.example.com', 'ldaps://dc2.example.com'],
bindDN: 'CN=svc-portal,OU=Service,DC=example,DC=com',
password: process.env.LDAP_PASSWORD
}),
entrypoint: buildEntrypoints({
controllers: [SessionController],
http: expressHTTPEntrypoint({port: 8080})
})
},
bootstrap: ['entrypoint']
}))- 连接在第一次操作时打开,随应用一起关闭。
url里的服务器按顺序尝试,直到连上其中一个,也可以通过 DNS SRV 记录查找服务器(srv: '_ldap._tcp.dc._msdcs.example.com')。 - 空密码在发送之前就会被拒绝:很多服务器会把带名字、没有密码的绑定当作匿名绑定,返回成功。
reason说明绑定失败的原因:invalidCredentials、accountLocked、accountDisabled、passwordExpired、mustChangePassword等,取自 OpenLDAP 的密码策略控制或 Active Directory 的数据代码。timeBeforeExpiration和graceAuthNsRemaining会在密码过期之前给出提醒。- 密码只通过 TLS(
ldapsURL 或startTLS: true)或本机的ldapi套接字发送,除非给出requireTLS: false。 bindName不先搜索条目,直接用由用户名构造的名字绑定(Active Directory 可以用alice@example.com)。authenticate()的其他内容见 LDAP Engine 第 4 节。
| 选项 | 默认值 | 说明 |
|---|---|---|
url | 服务器的 LDAP URL,或者按顺序尝试的多个 URL | |
srv | 给出服务器的 DNS SRV 记录,代替 url | |
bindDN、password | 匿名 | 连接池的身份 |
tls、startTLS | TLS 选项(ca、cert、key……),以及是否对 ldap:// URL 的每个连接执行 StartTLS | |
poolSize | 4 | 连接池最多的连接数 |
connectTimeout、timeout、idleTimeout | 5 秒、30 秒、10 分钟 | 建立连接、单个操作、空闲连接的时间 |
maxMessageSize、binaryAttributes | 64 MiB | 服务器能发来的最大消息;更多值为字节的属性 |
2. 读写目录
组件拥有 LDAP 连接的全部操作:search()、searchIterator()、compare()、add()、modify()、delete()、modifyDN()、extended()、passwordModify()、whoAmI(),以及 readSchema()。
import {Component} from 'lakutata'
import {Inject} from 'lakutata/decorator/di'
import {type Entry, filter, type LDAPClient} from '@lakutata/ldap'
export class Staff extends Component {
@Inject('ldap')
protected readonly ldap: LDAPClient
public async inDepartment(department: string): Promise<string[]> {
const {entries} = await this.ldap.search('OU=Staff,DC=example,DC=com', {
filter: filter`(&(objectClass=user)(department=${department}))`,
attributes: ['sAMAccountName', 'mail']
})
return entries.map((entry: Entry) => entry.first('sAMAccountName')!)
}
public async rename(uid: string, mail: string): Promise<void> {
const dn: string = `uid=${uid},ou=people,dc=example,dc=com`
await this.ldap.modify(dn, [
{operation: 'replace', attribute: 'mail', values: mail},
{operation: 'add', attribute: 'telephoneNumber', values: '+86 10 1234 5678'}
])
await this.ldap.passwordModify({userIdentity: dn, newPassword: 'new secret'})
}
}- 模板
filter会转义写进去的值,值改变不了过滤器的结构。 - 搜索默认分页(每页 500 个条目);
searchIterator()逐个给出条目;sort和window读取排序后条目中的一段。 - 结果不是成功时抛出对应的异常(
LDAPNoSuchObjectException、LDAPInsufficientAccessException……)。 - 超过
timeout的操作会在服务器上放弃。连接池不再把新的操作分给这个连接,等它上面的操作结束后就关闭它,之后的操作使用新的连接。 connect()返回一个不属于连接池的连接,它有自己的会话(用于以别的用户绑定,或者执行 StartTLS)。- 条目(文本和字节)、过滤器、DN、Active Directory 的值以及其他操作,见 LDAP Engine 第 2 到第 6 节。
3. 提供目录服务
LDAPServer 组件提供它的后端里的条目。用 DirectoryBackend 时,条目存在应用的数据库里(PostgreSQL、MySQL 或 SQLite)。
import {readFileSync} from 'node:fs'
import {Application} from 'lakutata'
import {buildDatabaseOptions} from 'lakutata/com/database'
import {buildLDAPServerOptions, DirectoryBackend, directoryEntities} from '@lakutata/ldap'
Application.run(() => ({
id: 'directory.app',
name: 'Directory',
components: {
database: buildDatabaseOptions({type: 'postgres', url: process.env.DATABASE_URL, entities: directoryEntities('postgres')}),
ldap: buildLDAPServerOptions({
port: 389,
ldapsPort: 636,
tls: {cert: readFileSync('/etc/directory/server.crt'), key: readFileSync('/etc/directory/server.key')},
backends: [{
class: DirectoryBackend,
database: 'database',
suffix: 'dc=example,dc=com',
initialize: {admin: {dn: 'cn=admin,dc=example,dc=com', password: process.env.LDAP_ADMIN_PASSWORD!}}
}],
access: {admins: ['cn=admin,dc=example,dc=com']}
})
},
bootstrap: ['database', 'ldap']
}))initialize在第一次启动时创建后缀对应的条目(根据后缀生成:dc=example,dc=com对应dcObject和organization)和管理员,只创建一次,多个实例同时启动也一样。- 服务器在
port上监听 LDAP(默认 389;0表示任选一个空闲端口,false表示不监听),配置了tls时支持 StartTLS,并在ldapsPort上监听 LDAPS。host指定监听的地址;安装了@lakutata/netif时,interface: 'eth1'按网卡监听,不管它现在是什么地址。ldap.ports给出实际监听的端口。 ldapiPath让服务器同时监听一个 Unix 套接字(ldapi),供本机的客户端使用,它们可以不经过 TLS 在上面发送密码;ldapiMode决定谁可以连接(0o666:本机的所有用户;默认按通常的 umask,只有运行服务器进程的用户)。设置port: false、只留套接字时,只有本机的客户端能访问服务器。- 目录后端的表的迁移属于应用,和应用的其他实体一样:把
directoryEntities(type)加进数据库的实体,再生成迁移。 - 其他选项就是服务器引擎的选项(LDAP Engine 第 9、11 和 12 节):
access、limits、bindThrottle、plaintextBind、passwords、passwordPolicy、sasl、extendedOperations,以及根 DSE 的内容。
4. 在应用内使用目录
应用不经过网络、直接通过组件读写自己的目录:connect() 返回一个拥有客户端全部操作的连接,身份由你指定,不需要绑定;directory() 在这样的连接上管理用户、组和组织单元。它们写入的内容,经过的 schema 检查、密码哈希和访问控制与网络请求完全一样。
import {Component} from 'lakutata'
import {Inject} from 'lakutata/decorator/di'
import type {LDAPServer} from '@lakutata/ldap'
export class Accounts extends Component {
@Inject('ldap')
protected readonly ldap: LDAPServer
public async hire(name: string, fullName: string, password: string): Promise<string> {
const directory = this.ldap.directory('cn=admin,dc=example,dc=com')
const dn: string = await directory.createUser('ou=people,dc=example,dc=com', {uid: name, cn: fullName, sn: fullName.split(' ').pop()!}, password)
await directory.addMember('cn=staff,ou=groups,dc=example,dc=com', dn)
return dn
}
public async checkPassword(dn: string, password: string): Promise<boolean> {
return this.ldap.directory('cn=admin,dc=example,dc=com').verifyPassword(dn, password)
}
public async departmentOf(dn: string): Promise<string | undefined> {
const {entries} = await this.ldap.connect('cn=admin,dc=example,dc=com').search(dn, {scope: 'base', attributes: ['departmentNumber']})
return entries[0]?.first('departmentNumber')
}
}目录助手和它的方法见 LDAP Engine 第 10 节。它由 LDAP 操作组成,所以 createDirectory(client) 可以用同样的方式管理远程的 OpenLDAP,只要它的配置和那台服务器的 schema 相符。ldap.engine 是服务器引擎本身:可以拿到它的 events(bind、operation、error……)和 schema。
5. Schema
系统 schema 总是会加载;组件的 schema 选项指定其他的 schema:
import {buildLDAPServerOptions, MemoryBackend} from '@lakutata/ldap'
const options = buildLDAPServerOptions({
backends: [new MemoryBackend('dc=example,dc=com')],
schema: {
//除了 core、cosine 和 inetorgperson,再加上 POSIX 账号和组,以及 SSH 公钥
bundled: ['core', 'cosine', 'inetorgperson', 'rfc2307bis', 'openssh-lpk'],
//按文件名顺序加载一个目录里的 .ldif 和 .schema 文件
directory: '/etc/directory/schema',
files: ['/etc/directory/devices.ldif']
}
})bundled按名字指定自带的 schema:默认是 core、cosine 和 inetorgperson;rfc2307bis(POSIX 账号和组,供 SSSD 和 nslcd 使用)、openssh-lpk(SSH 公钥)和 ppolicy 写上名字才加载,[]表示只留系统 schema。密码策略会自己带上 ppolicy。files添加自己的 schema:可以是路径、文档(parseSchemaFile()的结果,或从@lakutata/ldap/schemasimport 的文档),也可以用{name, text}直接给文件的文本。文件可以是 OpenLDAPcn=config的 LDIF、子模式条目,或者.schema格式。directory在它们之前加载一个目录里的文件,文件名里的数字按数值排序(cn={2}nis.ldif在cn={10}ppolicy.ldif之前,和 OpenLDAP 给文件编号的方式一致)。- 名字或 OID 重复定义时,服务器在启动时就会报错,并指出两处定义所在的文件。如果目录里也有部分标准 schema 文件,就把它们从
bundled里去掉(全部都有时设为[]);使用 OpenLDAP 的 nis.schema(RFC 2307)时,去掉 rfc2307bis,两者的 OID 相同。 schemaConfigView: true会像 OpenLDAP 那样,在cn={N}<name>,cn=schema,cn=config下以只读方式显示这些文件;客户端从cn=Subschema读取 schema。- 把 schema 文件当作文档来编辑,或者在构建时转好:见 LDAP Engine 第 7 节。
6. 访问和密码
access、plaintextBind、bindThrottle 和 limits 就是引擎的规则(LDAP Engine 第 11 节):默认情况下,匿名会话只能读取根 DSE 和子模式条目,已绑定的会话可以读取 userPassword 以外的目录内容,管理员可以写入,密码管理员可以修改其他人的密码。passwords 和 passwordPolicy 见引擎指南第 12 节:
import {buildLDAPServerOptions, DirectoryBackend} from '@lakutata/ldap'
const options = buildLDAPServerOptions({
backends: [{class: DirectoryBackend, database: 'database', suffix: 'dc=example,dc=com'}],
access: {
admins: ['cn=admin,dc=example,dc=com'],
//Keycloak 的服务账号负责重置密码
passwordAdmins: ['cn=keycloak,ou=services,dc=example,dc=com']
},
plaintextBind: 'loopback',
passwordPolicy: {
default: {lockout: true, maxFailure: 5, lockoutDuration: 900, maxAge: 7776000, expireWarning: 1209600, checkQuality: 2, minLength: 10, inHistory: 5},
quality: (password: Buffer) => /\d/.test(password.toString()) || 'A password needs a digit'
}
})- 密码写成
{PBKDF2-SHA512};绑定时也能校验 OpenLDAP 的其他格式,并重新哈希,所以从 OpenLDAP 导入的账号可以继续使用。 - 使用目录后端时,失败的绑定和密码策略的状态都存在数据库里:应用的多个实例共享锁定和过期状态。
7. 目录后端
目录后端把一个后缀下的条目存进应用的数据库,不管它们是什么对象类:一张条目表,每个值一行。
- 搜索在数据库里执行:过滤器会转换成 SQL,作用在匹配规则用来比较的值的形式上(相等、子串、整数和时间);SQL 无法精确判断的部分由服务器检查。
- 排序搜索由后端完成,它只读取窗口(
window)内的条目:按偏移量取窗口时,数据库根据随每个值保存的排序键完成排序、计数和截取;否则后端读取排序键的值,按服务器的方式给条目排序。schema 修改了某个属性的排序规则后,之前写入的值仍保留旧的排序键,直到它们被重新写入。 - 搜索返回的条目有固定的顺序:一个条目排在它下面的条目之前,兄弟条目按添加顺序排列(entryUUID 是第 7 版 UUID;带着自己 entryUUID 导入的条目,按这个值排位置)。排序键相同的条目保持这个顺序。
- DN 类型的值(member、manager、seeAlso……)指向它们的条目:条目改名时跟着变,条目删除时被删掉;必需的属性被删空时,保留一个占位值(默认为空 DN)。
memberOf在读取时计算,用它过滤可以找到一个组的成员(nestedGroups会展开嵌套的组)。schema 自己定义了 memberOf 时,使用memberOf: {name, oid}或memberOf: false。- POSIX:新增时没有 uidNumber 和 gidNumber 的条目会被分配这两个值(uidNumber 唯一,posixGroup 的 gidNumber 在组之间唯一);posixGroup 条目的 memberUid 会被计算出来。POSIX 的对象类来自 rfc2307bis,需要在
schema.bundled里写上。 unique让属性的值在后缀内唯一;authTimestamp给出最后一次绑定的时间;mailAlias把 mail 的值作为 email 给出(Grafana 读取 email)。- 每次写入都在一个事务里执行,拦截器的
beforeCommit()也在其中。
| 选项 | 默认值 | 说明 |
|---|---|---|
suffix | 顶层条目的 DN | |
database | 应用的 Database 组件的名字(它在 components 里的键);必填 | |
groups | groupOfNames、groupOfUniqueNames、groupOfMembers | 组的对象类及其成员属性 |
memberOf、nestedGroups、memberUid | true、0、true | 虚拟属性 |
unique | 值必须唯一的属性 | |
idAllocation | 从 10000 开始 | 分配的第一个 uidNumber 和 gidNumber,或 false |
lastBind、mailAlias | true、false | authTimestamp、email |
referencePlaceholder | 空 DN | 必需的 DN 属性的最后一个值指向的条目被删除后,留下的占位值 |
sharedThrottle | true | 把失败的绑定存进数据库 |
initialize | root、ldif、admin:第一次启动时创建的内容 |
8. 插件、拦截器和后端
插件和拦截器就是引擎的插件和拦截器(LDAP Engine 第 13 节:memberOf、引用完整性、唯一值、密码策略、审计、约束……)。组件接受对象形式的插件,也接受带选项的 lakutata 类,由组件的容器创建:
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
import {AuditPlugin, buildLDAPServerOptions, ConstraintPlugin, DirectoryBackend, type InterceptorPlacement, type LDAPPlugin} from '@lakutata/ldap'
//给新增时没有部门的人员加上部门,部门来自插件的选项
export class DepartmentPlugin extends Component implements LDAPPlugin {
@Configurable(DTO.String().required())
protected readonly department: string
public readonly name: string = 'department'
public get interceptors(): InterceptorPlacement[] {
return [{
//放在 schema 步骤之前,加上的值也会经过检查
before: 'schema',
interceptor: {name: 'department', operations: ['add'], before: (context, request) => {
if (request.type !== 'add') return
const attributes = request.entry.attributes
const named = (type: string) => attributes.find((attribute) => attribute.type.toLowerCase() === type.toLowerCase())
const person: boolean = named('objectClass')?.values.some((value) => value.toString().toLowerCase() === 'inetorgperson') ?? false
if (person && !named('departmentNumber')) attributes.push({type: 'departmentNumber', values: [Buffer.from(this.department)]})
}}
}]
}
}
const options = buildLDAPServerOptions({
backends: [{class: DirectoryBackend, database: 'database', suffix: 'dc=example,dc=com'}],
access: {admins: ['cn=admin,dc=example,dc=com']},
plugins: [
new ConstraintPlugin({rules: [{attribute: 'mail', pattern: /@example\.com$/}]}),
new AuditPlugin({write: (record) => console.log(record.operation, record.dn, record.resultCode)}),
{class: DepartmentPlugin, department: 'R&D'}
]
})- 插件类继承
Component并实现LDAPPlugin:它的选项用@Configurable声明,还可以注入应用的组件(数据库、日志……)。 - 目录后端在每次写入的事务里自己完成
MemberOfPlugin、ReferentialIntegrityPlugin、IdAllocationPlugin和LastBindPlugin的工作:这些插件会退出它的链,其他插件照常为它运行。 interceptors只接受拦截器:对象、放置方式,或者带选项的 lakutata 类。- 自己的后端(LDAP Engine 第 14 节)可以是对象,也可以是带选项的 lakutata 类,比如
{class: DevicesBackend, suffix: 'ou=devices,dc=example,dc=com'},由组件的容器创建:它可以注入应用的组件,包括数据库。多个后端可以提供多个后缀。
9. 接入其他软件
SSSD 和 nslcd:服务器在
schema.bundled里写上 rfc2307bis(需要 SSH 公钥时再加上 openssh-lpk),它们使用ldap_schema = rfc2307bis;组按member查找,memberUid 供rfc2307模式使用。SSSD 每次绑定都会发送密码策略控制,服务器会回应它。在同一台机器上时,它们连接ldapiPath的 Unix 套接字(SSSD 2.9 及以上),SSSD 会不经过 TLS 在上面发送密码。SSSD 2.10 及以上默认会启动 TLS,而套接字不需要 TLS:ini[domain/example] id_provider = ldap auth_provider = ldap ldap_uri = ldapi://%2Frun%2Fdirectory%2Fldapi ldap_search_base = dc=example,dc=com ldap_default_bind_dn = cn=sssd,dc=example,dc=com ldap_default_authtok = … ldap_schema = rfc2307bis ldap_id_use_start_tls = falseldapiMode必须允许运行 SSSD 的用户连接。经过网络时,给服务器配置tls,并把 CA 交给 SSSD(ldap_tls_cacert)。Keycloak:用户联合选择厂商 "Other",用户名和 RDN 用
uid,UUID 属性用entryUUID,打开 "Use password modify extended operation";组映射器使用groupOfNames和member。它的服务账号要放进access.passwordAdmins,才能设置密码。Gitea:添加一个 LDAP 认证源(via BindDN),用户过滤器为
(&(objectClass=inetOrgPerson)(uid=%s));管理员过滤器按 memberOf;SSH 公钥在sshPublicKey里(服务器需要加载 openssh-lpk)。Grafana:
mailAlias: true提供它默认读取的email。
10. 单独使用引擎
在 lakutata 应用之外,@lakutata/ldap-engine 以函数的形式提供同样的客户端和服务器:createLDAPClient()、connectLDAP() 和 createLDAPServer()。LDAP Engine 指南介绍了它们。
11. 运行平台
Node.js >=18.10.0、Bun、Electron 和 NW.js。引擎没有原生代码。目录后端运行在 lakutata/orm 的数据库上,在 Electron 和 Bun 里需要满足各个驱动的要求(DatabaseDrivers):Bun 加载不了 better-sqlite3,可以用 sqljs、PostgreSQL 或 MySQL。
异常
组件抛出的异常就是引擎的异常(LDAP Engine 第 17 节):结果不是成功时,抛出以结果码命名的 LDAPResultException 子类(LDAPInvalidCredentialsException、LDAPNoSuchObjectException……);连接断开时抛出 LDAPConnectionException;操作超时抛出 LDAPTimeoutException;schema 无法加载时,服务器在启动时就以 LDAPSchemaException 报错。