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 }