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:
1 parent
2c96902fbd
commit
13fb3aceb3
6 files changed
+487
-266
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user