chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
package ldaplib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// 用户与分组的属性清单集中在此,避免散落各处导致不一致。
|
||||
const (
|
||||
AttrObjectClass = "objectClass"
|
||||
AttrUID = "uid"
|
||||
AttrCN = "cn"
|
||||
AttrSN = "sn"
|
||||
AttrGivenName = "givenName"
|
||||
AttrDisplayName = "displayName"
|
||||
AttrMail = "mail"
|
||||
AttrMobile = "mobile"
|
||||
AttrEmployeeNumber = "employeeNumber"
|
||||
AttrDepartmentNumber = "departmentNumber"
|
||||
AttrPostalAddress = "postalAddress"
|
||||
AttrBusinessCategory = "businessCategory"
|
||||
AttrEmployeeType = "employeeType"
|
||||
AttrDescription = "description"
|
||||
AttrUserPassword = "userPassword"
|
||||
AttrUniqueMember = "uniqueMember"
|
||||
AttrMember = "member"
|
||||
)
|
||||
|
||||
// memberAttrs 列出可能承载组成员关系的属性名。
|
||||
//
|
||||
// 读取时两者都查:用哪一种取决于目录的既有约定(groupOfNames 用 member,
|
||||
// groupOfUniqueNames 用 uniqueMember)。只认一种会漏掉另一种的成员关系,
|
||||
// 且不会报错,只会表现为「成员比对结果不对」。
|
||||
var memberAttrs = []string{AttrMember, AttrUniqueMember}
|
||||
|
||||
// ObjectClassUser 等是写入条目时使用的 objectClass。
|
||||
// 分组的 objectClass 由 GroupSchema 在运行期决定,见下。
|
||||
var (
|
||||
ObjectClassUser = []string{"inetOrgPerson"}
|
||||
ObjectClassOU = []string{"organizationalUnit", "top"}
|
||||
)
|
||||
|
||||
// GroupSchema 描述分组条目的形态,必须与 Authelia 的 groups_filter 一致。
|
||||
type GroupSchema struct {
|
||||
// ObjectClass 形如 groupOfUniqueNames 或 groupOfNames
|
||||
ObjectClass string
|
||||
// MemberAttr 形如 uniqueMember 或 member
|
||||
MemberAttr string
|
||||
}
|
||||
|
||||
// ObjectClasses 返回写入时使用的 objectClass 列表。
|
||||
func (g GroupSchema) ObjectClasses() []string {
|
||||
return []string{g.ObjectClass, "top"}
|
||||
}
|
||||
|
||||
// mutableUserAttrs 是更新用户时允许改写的属性。
|
||||
//
|
||||
// 这里刻意不包含 userPassword:Authelia 重置密码时直接写 LDAP 的 userPassword,
|
||||
// 若同步的更新路径也写该属性,就会把用户刚设好的密码打回初始值。
|
||||
// 密码只在创建时写入一次,此后由 Authelia 全权负责。
|
||||
var mutableUserAttrs = []string{
|
||||
AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, AttrMobile,
|
||||
AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress,
|
||||
AttrBusinessCategory, AttrEmployeeType, AttrDescription,
|
||||
}
|
||||
|
||||
// escapeDN 转义 DN 中的特殊字符,避免 uid 含 . 、+ 、_ 等字符时构造出非法 DN。
|
||||
func escapeDN(s string) string { return ldap.EscapeDN(s) }
|
||||
|
||||
// EscapeDN 导出 DN 转义,供调用方拼装 RDN 时使用。
|
||||
func EscapeDN(s string) string { return ldap.EscapeDN(s) }
|
||||
|
||||
// EscapeFilter 导出过滤器转义。
|
||||
func EscapeFilter(s string) string { return ldap.EscapeFilter(s) }
|
||||
|
||||
// UserDN 按 uid 与所属 OU 拼接用户 DN。
|
||||
func UserDN(uid, ouDN string) string {
|
||||
return "uid=" + escapeDN(uid) + "," + ouDN
|
||||
}
|
||||
|
||||
// GroupDN 拼接部门组 DN。
|
||||
func GroupDN(cn, parentDN string) string {
|
||||
return "cn=" + escapeDN(cn) + "," + parentDN
|
||||
}
|
||||
|
||||
// User 是写入 LDAP 的用户条目。
|
||||
type User struct {
|
||||
UID string
|
||||
CN string
|
||||
SN string
|
||||
GivenName string
|
||||
DisplayName string
|
||||
Mail string
|
||||
Mobile string
|
||||
EmployeeNumber string
|
||||
DepartmentNumber string
|
||||
PostalAddress string
|
||||
BusinessCategory string
|
||||
EmployeeType string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Attributes 返回该用户对应的 LDAP 属性集合(不含 userPassword)。
|
||||
func (u *User) Attributes() map[string][]string {
|
||||
return map[string][]string{
|
||||
AttrCN: {u.CN},
|
||||
AttrSN: {u.SN},
|
||||
AttrGivenName: {u.GivenName},
|
||||
AttrDisplayName: {u.DisplayName},
|
||||
AttrMail: {u.Mail},
|
||||
AttrMobile: {u.Mobile},
|
||||
AttrEmployeeNumber: {u.EmployeeNumber},
|
||||
AttrDepartmentNumber: {u.DepartmentNumber},
|
||||
AttrPostalAddress: {u.PostalAddress},
|
||||
AttrBusinessCategory: {u.BusinessCategory},
|
||||
AttrEmployeeType: {u.EmployeeType},
|
||||
AttrDescription: {u.Description},
|
||||
}
|
||||
}
|
||||
|
||||
// AddUser 创建用户条目。password 仅在此时写入。
|
||||
func (c *Conn) AddUser(u *User, ouDN, password string) error {
|
||||
add := ldap.NewAddRequest(UserDN(u.UID, ouDN), nil)
|
||||
add.Attribute(AttrObjectClass, ObjectClassUser)
|
||||
add.Attribute(AttrUID, []string{u.UID})
|
||||
|
||||
for attr, values := range u.Attributes() {
|
||||
if len(values) == 1 && values[0] == "" {
|
||||
continue // 空值不写,避免产生空属性
|
||||
}
|
||||
add.Attribute(attr, values)
|
||||
}
|
||||
|
||||
add.Attribute(AttrUserPassword, []string{password})
|
||||
|
||||
if err := c.conn.Add(add); err != nil {
|
||||
return fmt.Errorf("创建用户 %s 失败: %w", u.UID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户属性。绝不触碰 userPassword。
|
||||
//
|
||||
// existing 是条目当前的内容,用于判断哪些属性需要删除。
|
||||
// 这个参数不是可有可无的:对**不存在**的属性执行 Delete 会让整个 Modify
|
||||
// 请求以 LDAPResultNoSuchAttribute 失败,从而使该用户的全部属性更新回滚。
|
||||
// 早期版本没有传 existing,任何缺少可空字段(如工位为空)的用户都会更新失败。
|
||||
func (c *Conn) UpdateUser(dn string, u *User, existing *Entry) error {
|
||||
modify := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
attrs := u.Attributes()
|
||||
for _, attr := range mutableUserAttrs {
|
||||
values := attrs[attr]
|
||||
if len(values) == 0 || values[0] == "" {
|
||||
// 上游无此值:仅当条目当前确实存在该属性时才删除,
|
||||
// 否则跳过,避免整个请求失败。
|
||||
if existing != nil && existing.Get(attr) != "" {
|
||||
modify.Delete(attr, nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if existing != nil && existing.Get(attr) == values[0] {
|
||||
continue // 值未变化,不产生多余的 LDAP 写操作
|
||||
}
|
||||
modify.Replace(attr, values)
|
||||
}
|
||||
|
||||
if len(modify.Changes) == 0 {
|
||||
return nil // 没有实际变化,避免发出空 Modify
|
||||
}
|
||||
|
||||
// 结构上的保险:即使将来有人误把 userPassword 加进 mutableUserAttrs,这里也会拦下。
|
||||
for _, ch := range modify.Changes {
|
||||
if strings.EqualFold(ch.Modification.Type, AttrUserPassword) {
|
||||
return fmt.Errorf("内部错误: 更新路径不允许修改 %s", AttrUserPassword)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.conn.Modify(modify); err != nil {
|
||||
return fmt.Errorf("更新用户 %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEntry 读取单个条目的属性,供更新前的差异判断使用。
|
||||
func (c *Conn) GetEntry(dn string, attrs []string) (*Entry, error) {
|
||||
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||
"(objectClass=*)", attrs, nil,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取条目 %s 失败: %w", dn, err)
|
||||
}
|
||||
if len(sr.Entries) == 0 {
|
||||
return nil, fmt.Errorf("条目不存在: %s", dn)
|
||||
}
|
||||
return entryFrom(sr.Entries[0]), nil
|
||||
}
|
||||
|
||||
// UserReadAttrs 返回读取用户条目所需的属性列表。
|
||||
func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) }
|
||||
|
||||
// DeleteUser 删除用户条目。
|
||||
func (c *Conn) DeleteUser(dn string) error {
|
||||
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
|
||||
return fmt.Errorf("删除用户 %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveUser 把用户条目移动到新的父 OU,保留其全部属性(含 userPassword)。
|
||||
// deleteOldRDN 置 true 表示移除旧 RDN 属性值。
|
||||
func (c *Conn) MoveUser(oldDN, newRDN, newParentDN string) error {
|
||||
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, newParentDN)
|
||||
if err := c.conn.ModifyDN(modDN); err != nil {
|
||||
return fmt.Errorf("移动用户 %s → %s,%s 失败: %w", oldDN, newRDN, newParentDN, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddGroup 创建部门分组。
|
||||
//
|
||||
// 无论哪种 objectClass,成员属性都是必填(groupOfNames 的 member 与
|
||||
// groupOfUniqueNames 的 uniqueMember 都是 MUST),因此这里默认把 admin
|
||||
// 加入其中,否则创建会因缺少必填属性而失败。
|
||||
func (c *Conn) AddGroup(dn, name, description, adminDN string) error {
|
||||
if _, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||
"(objectClass=*)", []string{"dn"}, nil,
|
||||
)); err == nil {
|
||||
return nil // 已存在,幂等
|
||||
} else if !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return fmt.Errorf("检查分组 %s 是否存在失败: %w", dn, err)
|
||||
}
|
||||
|
||||
add := ldap.NewAddRequest(dn, nil)
|
||||
add.Attribute(AttrObjectClass, c.group.ObjectClasses())
|
||||
add.Attribute(AttrCN, []string{name})
|
||||
add.Attribute(AttrDescription, []string{description})
|
||||
add.Attribute(c.group.MemberAttr, []string{adminDN})
|
||||
|
||||
if err := c.conn.Add(add); err != nil {
|
||||
return fmt.Errorf("创建分组 %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGroup 更新分组的描述。
|
||||
func (c *Conn) UpdateGroup(dn, description string) error {
|
||||
modify := ldap.NewModifyRequest(dn, nil)
|
||||
modify.Replace(AttrDescription, []string{description})
|
||||
if err := c.conn.Modify(modify); err != nil {
|
||||
return fmt.Errorf("更新分组 %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameGroup 重命名分组,用于部门改名。
|
||||
func (c *Conn) RenameGroup(oldDN, newRDN string) error {
|
||||
modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, "")
|
||||
if err := c.conn.ModifyDN(modDN); err != nil {
|
||||
return fmt.Errorf("重命名分组 %s → %s 失败: %w", oldDN, newRDN, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroup 删除分组条目。
|
||||
func (c *Conn) DeleteGroup(dn string) error {
|
||||
if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil {
|
||||
return fmt.Errorf("删除分组 %s 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEmployeeType 更新条目的在职状态标记(Active / Resigned)。
|
||||
func (c *Conn) SetEmployeeType(dn, employeeType string) error {
|
||||
modify := ldap.NewModifyRequest(dn, nil)
|
||||
modify.Replace(AttrEmployeeType, []string{employeeType})
|
||||
if err := c.conn.Modify(modify); err != nil {
|
||||
return fmt.Errorf("更新 %s 的 employeeType 失败: %w", dn, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// memberExists 判断用户是否已是分组成员。
|
||||
func (c *Conn) memberExists(groupDN, userDN string) (bool, error) {
|
||||
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
|
||||
c.group.ObjectClass, c.group.MemberAttr, EscapeFilter(userDN))
|
||||
|
||||
sr, err := c.conn.Search(ldap.NewSearchRequest(
|
||||
groupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||
filter, []string{"dn"}, nil,
|
||||
))
|
||||
if err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
|
||||
return false, fmt.Errorf("分组不存在: %s", groupDN)
|
||||
}
|
||||
return false, fmt.Errorf("查询分组 %s 成员失败: %w", groupDN, err)
|
||||
}
|
||||
return len(sr.Entries) > 0, nil
|
||||
}
|
||||
|
||||
// AddMember 把用户加入分组。已存在时是幂等的。
|
||||
func (c *Conn) AddMember(groupDN, userDN string) error {
|
||||
found, err := c.memberExists(groupDN, userDN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
|
||||
modify := ldap.NewModifyRequest(groupDN, nil)
|
||||
modify.Add(c.group.MemberAttr, []string{userDN})
|
||||
if err := c.conn.Modify(modify); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultAttributeOrValueExists) {
|
||||
return nil // 并发下可能已被加入
|
||||
}
|
||||
return fmt.Errorf("将 %s 加入分组 %s 失败: %w", userDN, groupDN, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMember 把用户移出分组。不存在时是幂等的。
|
||||
func (c *Conn) RemoveMember(groupDN, userDN string) error {
|
||||
found, err := c.memberExists(groupDN, userDN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
modify := ldap.NewModifyRequest(groupDN, nil)
|
||||
modify.Delete(c.group.MemberAttr, []string{userDN})
|
||||
if err := c.conn.Modify(modify); err != nil {
|
||||
return fmt.Errorf("将 %s 移出分组 %s 失败: %w", userDN, groupDN, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user