Files
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

331 lines
9.6 KiB
Go

// Package services — LDAP 接入:配置存储(SystemSetting 表 JSON)、连接测试、
// 用户搜索预览、批量导入与登录验证共用同一套连接/搜索代码。
// 约定:has_password 脱敏、空密码保留旧值、批量导入冲突策略。
package services
import (
"encoding/json"
"fmt"
"net"
"strings"
"time"
"lokistack/models"
"lokistack/pkg/crypto"
"lokistack/repository"
"lokistack/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
}