feat(ldap): ad/openldap dual compat and config gating

- multi-round user filters (config filter first, then
  objectClass=inetOrgPerson, then AD sAMAccountName/userPrincipalName
  fallback) applied to login search, user listing and batch import;
  previously pure-AD directories found no users
- attribute fallback chains (uid/sAMAccountName/userPrincipalName,
  cn/displayName, telephoneNumber/mobile) via ldapEntryAttr
- import errors explain likely cause instead of empty result
- AES-encrypted admin password kept (aiops stores plaintext)
- frontend gating per aiops pattern: LDAP account management panel
  hidden with hint until a config is actually saved (was gated on
  unsaved form values); batch-import modal hides to/from-LDAP tabs
  when unconfigured, local CSV always available
This commit is contained in:
Logan.Li committed 2026-10-26 20:08:46 +08:00
1 parent 2c96902fbd
commit 13fb3aceb3
6 files changed
+487 -266

No files matched your search

+10 -41
View File
@@ -227,35 +227,11 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
return nil, nil, fmt.Errorf("LDAP admin bind failed: %w", err)
}
// 4. Build search filter
userFilter := ""
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(username))
} else {
baseFilter = sf
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
}
} else {
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
// 4-5. 多轮过滤器搜索用户(OpenLDAP + AD 兜底,借鉴 aiops)
entry, err := ldapSearchFirstUser(conn, &cfg, username)
if err != nil {
return nil, nil, err
}
// 5. Search user
searchReq := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, nil, fmt.Errorf("user not found in LDAP")
}
entry := sr.Entries[0]
userDN := entry.DN
// 6. Verify password by binding as user
@@ -273,22 +249,15 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
getAttr := func(key string) string {
if v := entry.GetAttributeValue(key); v != "" {
return v
}
return ""
}
// 7. 属性回退链提取身份(OpenLDAP 与 AD 字段差异由 ldapEntryAttr 抹平)
identUser, identName, identEmail := ldapIdentityFromEntry(entry, username)
info := &LDAPAuthInfo{
Username: getAttr(userMapping["username"]),
DisplayName: getAttr(userMapping["display_name"]),
Username: identUser,
DisplayName: identName,
}
if email := getAttr(userMapping["email"]); email != "" {
info.Email = &email
}
if info.Username == "" {
info.Username = username
if identEmail != "" {
info.Email = &identEmail
}
return nil, info, nil // conn already closed by defer
+51 -50
View File
@@ -333,41 +333,33 @@ func ListLDAPUsers(c *gin.Context) {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
baseFilter = strings.ReplaceAll(sf, "%s", "*")
} else {
baseFilter = sf
}
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, baseFilter,
[]string{"uid", "cn", "mail", "dn"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
// 多轮过滤器(配置优先 → OpenLDAP → AD 兜底),命中后去重合并
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
}
seen := map[string]bool{}
var users []LdapUser
for _, e := range sr.Entries {
users = append(users, LdapUser{
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"),
})
for _, filter := range ldapUserListFilters(&cfg) {
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail", "dn"},
nil,
))
if err != nil {
continue
}
for _, e := range sr.Entries {
if seen[e.DN] {
continue
}
seen[e.DN] = true
uid, cn, mail := ldapIdentityFromEntry(e, "")
users = append(users, LdapUser{DN: e.DN, UID: uid, CN: cn, Mail: mail})
}
}
c.JSON(http.StatusOK, users)
}
@@ -531,23 +523,35 @@ func BatchImportLDAP(c *gin.Context) {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
baseFilter = strings.ReplaceAll(sf, "%s", "*")
} else {
baseFilter = sf
// 多轮过滤器搜索(配置优先 → OpenLDAP → AD 兜底),按 DN 去重合并
type ldapEntryUser struct {
uid, cn, mail string
}
var candidates []ldapEntryUser
seen := map[string]bool{}
for _, filter := range ldapUserListFilters(&cfg) {
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
500, 0, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail"}, nil,
))
if err != nil {
continue
}
for _, entry := range sr.Entries {
if seen[entry.DN] {
continue
}
seen[entry.DN] = true
uid, cn, mail := ldapIdentityFromEntry(entry, "")
if uid == "" {
continue
}
candidates = append(candidates, ldapEntryUser{uid: uid, cn: cn, mail: mail})
}
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
500, 0, false, baseFilter,
[]string{"uid", "cn", "mail"}, nil,
))
if err != nil {
resp.BadRequest(c, "LDAP 搜索失败: "+err.Error())
if len(candidates) == 0 {
resp.BadRequest(c, "LDAP 搜索无结果:请检查 Base DN/过滤器是否匹配目录结构")
return
}
@@ -556,11 +560,8 @@ func BatchImportLDAP(c *gin.Context) {
skipped := 0
var results []map[string]string
for _, entry := range sr.Entries {
uid := entry.GetAttributeValue("uid")
if uid == "" {
continue
}
for _, entry := range candidates {
uid := entry.uid
if len(req.UserIDs) > 0 {
found := false
for _, id := range req.UserIDs {
@@ -574,8 +575,8 @@ func BatchImportLDAP(c *gin.Context) {
}
}
cn := entry.GetAttributeValue("cn")
mail := entry.GetAttributeValue("mail")
cn := entry.cn
mail := entry.mail
var existing model.User
err := model.DB.Where("username = ?", uid).First(&existing).Error
+111
View File
@@ -0,0 +1,111 @@
package handler
// LDAP OpenLDAP/ActiveDirectory 双兼容助手(移植自 aiops controller/ldap.go):
// - 多轮过滤器:配置过滤器优先,再 OpenLDAP,再 AD 兜底
// - 属性回退链:uid→sAMAccountName→userPrincipalName / cn→displayName / mobile
// 密码仍走本项目的 AES 加密存储(aiops 为明文,不倒退)。
import (
"fmt"
"strings"
"sql-platform/internal/model"
"github.com/go-ldap/ldap/v3"
)
// 用户属性回退集:OpenLDAP 一套、AD 一套
var ldapUserAttrSets = [][]string{
{"uid", "sAMAccountName", "userPrincipalName", "cn"}, // username 回退链
{"displayName", "cn"}, // display_name 回退链
{"mail"},
{"telephoneNumber", "mobile"},
}
// ldapEntryAttr 按属性名回退读取,兼容 OpenLDAP 与 AD 常见字段
func ldapEntryAttr(entry *ldap.Entry, names ...string) string {
for _, name := range names {
if v := entry.GetAttributeValue(name); v != "" {
return v
}
}
return ""
}
// ldapUserFilter 构造兼容 OpenLDAP 与 AD 的用户搜索过滤器
func ldapUserFilter(cfg *model.LDAPConfig, username string) string {
escaped := ldap.EscapeFilter(username)
if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" {
sf := strings.TrimSpace(*cfg.SearchFilter)
if strings.Contains(sf, "%s") {
return strings.ReplaceAll(sf, "%s", escaped)
}
return fmt.Sprintf("(&%s(|(uid=%s)(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s)))", sf, escaped, escaped, escaped, escaped)
}
return fmt.Sprintf("(|(&(objectClass=inetOrgPerson)(uid=%s))(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))))",
escaped, escaped, escaped, escaped)
}
// ldapADUserFilter AD 兜底过滤器
func ldapADUserFilter(username string) string {
escaped := ldap.EscapeFilter(username)
return fmt.Sprintf("(|(sAMAccountName=%s)(userPrincipalName=%s)(cn=%s))", escaped, escaped, escaped)
}
// ldapUserListFilters 目录用户列表过滤器:先尊重配置,再补 OpenLDAP/AD 兜底
func ldapUserListFilters(cfg *model.LDAPConfig) []string {
filters := []string{}
if cfg.SearchFilter != nil && strings.TrimSpace(*cfg.SearchFilter) != "" {
sf := strings.TrimSpace(*cfg.SearchFilter)
if strings.Contains(sf, "%s") {
filters = append(filters, strings.ReplaceAll(sf, "%s", "*"))
} else {
filters = append(filters, sf)
}
}
return append(filters,
"(objectClass=inetOrgPerson)",
"(&(objectClass=user)(|(sAMAccountName=*)(userPrincipalName=*)))",
)
}
// ldapSearchFirstUser 多轮过滤器搜索第一个命中的用户(登录认证用)
func ldapSearchFirstUser(conn *ldap.Conn, cfg *model.LDAPConfig, username string) (*ldap.Entry, error) {
for _, filter := range []string{ldapUserFilter(cfg, username), ldapADUserFilter(username)} {
sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false, filter,
[]string{"uid", "sAMAccountName", "userPrincipalName", "cn", "displayName", "mail", "telephoneNumber", "mobile"},
nil,
))
if err != nil {
continue // 单轮失败(如 AD 过滤器在纯 OpenLDAP 上报错)继续兜底
}
if len(sr.Entries) > 0 {
return sr.Entries[0], nil
}
}
return nil, fmt.Errorf("user not found in LDAP")
}
// ldapIdentityFromEntry 按属性回退链提取用户身份信息
func ldapIdentityFromEntry(entry *ldap.Entry, fallbackUsername string) (username, displayName, email string) {
username = ldapEntryAttr(entry, ldapUserAttrSets[0]...)
if username == "" {
username = fallbackUsername
}
displayName = ldapEntryAttr(entry, ldapUserAttrSets[1]...)
if displayName == "" {
displayName = username
}
email = ldapEntryAttr(entry, ldapUserAttrSets[2]...)
return
}
// ldapIdentityMailPtr 取邮箱指针(无则 nil)
func ldapEmailPtr(entry *ldap.Entry) *string {
if v := ldapEntryAttr(entry, "mail"); v != "" {
return &v
}
return nil
}