Files
LokiStack/backend/services/ldap.go
T
logan 2d73661dd1 feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
2026-10-25 21:28:32 +08:00

331 lines
9.6 KiB
Go

// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
// 设计参照 opsx-web-lite(has_password 脱敏/空密码保留旧值/批量导入冲突策略)。
package services
import (
"encoding/json"
"fmt"
"net"
"strings"
"time"
"opsxloki/models"
"opsxloki/pkg/crypto"
"opsxloki/repository"
"opsxloki/utils"
ldap "github.com/go-ldap/ldap/v3"
)
const ldapSettingKey = "ldap_settings"
// netDialer LDAP 拨号器(5s 超时,参照 aiops 的超时处理,避免连接挂起)
func netDialer() *net.Dialer {
return &net.Dialer{Timeout: 5 * time.Second}
}
// LDAPConfig LDAP 配置(存储形态;BindPassword 以 ENC: 前缀 AES-GCM 加密落库)
type LDAPConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
UseSSL bool `json:"useSSL"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword"` // 存储为 ENC:密文;对外输出时剔除
BaseDN string `json:"baseDN"`
UserFilter string `json:"userFilter"`
UsernameAttr string `json:"usernameAttr"`
NameAttr string `json:"nameAttr"`
EmailAttr string `json:"emailAttr"`
}
// LDAPPublicConfig 对外输出形态(密码剔除,附 hasPassword)
type LDAPPublicConfig struct {
LDAPConfig
BindPassword string `json:"bindPassword"` // 恒为空
HasPassword bool `json:"hasPassword"`
Configured bool `json:"configured"` // host+bindDN+baseDN 齐备(控制用户页导入按钮显隐)
}
func defaultLDAPConfig() *LDAPConfig {
return &LDAPConfig{
Port: 389,
UserFilter: "(objectClass=inetOrgPerson)",
UsernameAttr: "uid",
NameAttr: "displayName",
EmailAttr: "mail",
}
}
// GetLDAPConfig 读取配置(解密密码);未配置时返回默认值
func GetLDAPConfig() *LDAPConfig {
db := repository.GetDB()
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == ldapSettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
cfg := defaultLDAPConfig()
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
}
if cfg.Port == 0 {
cfg.Port = 389
}
if cfg.BindPassword != "" {
if plain, err := DecryptCred(cfg.BindPassword); err == nil {
cfg.BindPassword = plain
}
}
return cfg
}
func GetLDAPPublicConfig() *LDAPPublicConfig {
cfg := GetLDAPConfig()
hasPwd := cfg.BindPassword != ""
return &LDAPPublicConfig{
LDAPConfig: LDAPConfig{
Enabled: cfg.Enabled,
Host: cfg.Host,
Port: cfg.Port,
UseSSL: cfg.UseSSL,
BindDN: cfg.BindDN,
BaseDN: cfg.BaseDN,
UserFilter: cfg.UserFilter,
UsernameAttr: cfg.UsernameAttr,
NameAttr: cfg.NameAttr,
EmailAttr: cfg.EmailAttr,
},
HasPassword: hasPwd,
Configured: cfg.Host != "" && cfg.BindDN != "" && cfg.BaseDN != "",
}
}
// SaveLDAPConfig 保存;bindPassword 为空且已有存量 → 保留旧密文
func SaveLDAPConfig(in *LDAPConfig) error {
old := GetLDAPConfig()
if in.Port == 0 {
in.Port = 389
}
if in.UserFilter == "" {
in.UserFilter = old.UserFilter
}
if in.UsernameAttr == "" {
in.UsernameAttr = "uid"
}
if in.NameAttr == "" {
in.NameAttr = "displayName"
}
if in.EmailAttr == "" {
in.EmailAttr = "mail"
}
if in.BindPassword == "" {
in.BindPassword = old.BindPassword // 已是明文(GetLDAPConfig 解密过)
} else if enc, err := crypto.EncryptField(in.BindPassword); err == nil {
in.BindPassword = enc
}
data, _ := json.Marshal(in)
db := repository.GetDB()
// NextID/CreateUser 均自拿锁,禁止在持有 db.Lock() 时调用(JsonDB 锁不可重入)
newID := db.NextID("settings")
db.Lock()
defer db.Unlock()
for i := range db.Settings {
if db.Settings[i].Key == ldapSettingKey {
db.Settings[i].Value = string(data)
db.RequestSave()
return nil
}
}
db.Settings = append(db.Settings, models.SystemSetting{
ID: newID, Key: ldapSettingKey, Value: string(data),
})
db.RequestSave()
return nil
}
// ==================== 连接与搜索 ====================
// dialLDAP 建立连接(5s 拨号超时,防参照项目无超时挂起的问题)
func dialLDAP(cfg *LDAPConfig) (*ldap.Conn, error) {
scheme := "ldap"
if cfg.UseSSL {
scheme = "ldaps"
}
url := fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port)
conn, err := ldap.DialURL(url, ldap.DialWithDialer(netDialer()))
if err != nil {
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %w", url, err)
}
conn.SetTimeout(10 * time.Second)
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
conn.Close()
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN / 密码): %w", err)
}
return conn, nil
}
// ldapUserEntry LDAP 用户条目(导入预览与登录共用)
type ldapUserEntry struct {
DN string
Username string
DisplayName string
Email string
}
// searchLDAPUsers 按 ouDN(空用 baseDN)WholeSubtree 搜索用户,sizeLimit 上限
func searchLDAPUsers(cfg *LDAPConfig, ouDN string, sizeLimit int) ([]ldapUserEntry, error) {
if ouDN == "" {
ouDN = cfg.BaseDN
}
conn, err := dialLDAP(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
filter := cfg.UserFilter
if !strings.HasPrefix(filter, "(") {
filter = "(" + filter + ")"
}
req := ldap.NewSearchRequest(ouDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
sizeLimit, 0, false, filter,
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
res, err := conn.Search(req)
if err != nil {
return nil, fmt.Errorf("搜索失败(BaseDN/过滤器可能有误): %w", err)
}
users := make([]ldapUserEntry, 0, len(res.Entries))
seen := map[string]bool{}
for _, e := range res.Entries {
u := ldapUserEntry{
DN: e.DN,
Username: e.GetAttributeValue(cfg.UsernameAttr),
DisplayName: e.GetAttributeValue(cfg.NameAttr),
Email: e.GetAttributeValue(cfg.EmailAttr),
}
if u.DisplayName == "" {
u.DisplayName = e.GetAttributeValue("cn")
}
if u.Username == "" || seen[u.DN] {
continue // 无用户名属性的条目(如 ou 本身)跳过
}
seen[u.DN] = true
users = append(users, u)
}
return users, nil
}
// searchLDAPUserByUsername 登录用:按用户名搜索单个条目
func searchLDAPUserByUsername(cfg *LDAPConfig, username string) (*ldapUserEntry, error) {
conn, err := dialLDAP(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(cfg.UserFilter),
cfg.UsernameAttr, ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, 0, false, filter,
[]string{"dn", cfg.UsernameAttr, cfg.NameAttr, "cn", cfg.EmailAttr}, nil)
res, err := conn.Search(req)
if err != nil {
return nil, err
}
if len(res.Entries) == 0 {
return nil, utils.ErrInvalidCredentials
}
if len(res.Entries) > 1 {
return nil, fmt.Errorf("LDAP 中存在多个匹配 %s 的用户", username)
}
e := res.Entries[0]
return &ldapUserEntry{
DN: e.DN,
Username: e.GetAttributeValue(cfg.UsernameAttr),
DisplayName: e.GetAttributeValue(cfg.NameAttr),
Email: e.GetAttributeValue(cfg.EmailAttr),
}, nil
}
// verifyLDAPPassword 用户级验证:bind 到用户 DN 校验密码
func verifyLDAPPassword(cfg *LDAPConfig, username, password string) (*ldapUserEntry, error) {
entry, err := searchLDAPUserByUsername(cfg, username)
if err != nil {
return nil, err
}
scheme := "ldap"
if cfg.UseSSL {
scheme = "ldaps"
}
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, cfg.Host, cfg.Port),
ldap.DialWithDialer(netDialer()))
if err != nil {
return nil, err
}
defer conn.Close()
if err := conn.Bind(entry.DN, password); err != nil {
return nil, utils.ErrInvalidCredentials
}
return entry, nil
}
func normalizeFilter(f string) string {
if !strings.HasPrefix(f, "(") {
return "(" + f + ")"
}
return f
}
// TestLDAPConnection 测试连接:管理 bind + baseDN 校验 + 可选用户级验证
func TestLDAPConnection(cfg *LDAPConfig, testUsername, testPassword string) map[string]interface{} {
if cfg.Host == "" || cfg.BindDN == "" || cfg.BaseDN == "" {
return map[string]interface{}{"success": false, "message": "请先填写服务器地址 / Bind DN / Base DN"}
}
// 表单密码为空回退已存密码
if cfg.BindPassword == "" {
cfg.BindPassword = GetLDAPConfig().BindPassword
}
conn, err := dialLDAP(cfg)
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
defer conn.Close()
// baseDN 可读性校验
req := ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases,
1, 0, false, "(objectClass=*)", []string{"dn"}, nil)
if _, err := conn.Search(req); err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连通,但 Base DN 搜索失败: %v", err)}
}
result := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("连接成功:服务器可达,管理员绑定与 Base DN 校验通过 (%s)", cfg.BaseDN),
}
if testUsername != "" {
if testPassword == "" {
return map[string]interface{}{"success": false, "message": "请填写测试账号的密码"}
}
entry, err := verifyLDAPPassword(cfg, testUsername, testPassword)
if err != nil {
return map[string]interface{}{"success": false, "message": fmt.Sprintf("服务器连接正常,但用户「%s」验证失败: %v", testUsername, err)}
}
result["message"] = fmt.Sprintf("连接成功,用户「%s」验证通过 (DN: %s)", testUsername, entry.DN)
result["userInfo"] = map[string]string{
"dn": entry.DN, "username": entry.Username,
"displayName": entry.DisplayName, "email": entry.Email,
}
}
return result
}