Files
aiops/backend/controller/ldap_users.go
T
attacker 77c234b060 feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
2026-06-25 20:16:30 +08:00

398 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"golang.org/x/crypto/bcrypt"
)
// ListLDAPUsers 列出LDAP用户
// POST /api/v1/admin/ldap-config/users
func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) {
var req struct {
OuDn string `json:"ou_dn"`
}
c.ShouldBindJSON(&req)
var cfg model.LDAPConfig
if err := h.db.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
defer conn.Close()
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
searchBase := cfg.BaseDN
if req.OuDn != "" {
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", "telephoneNumber", "title", "department"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
Phone string `json:"phone"`
Title string `json:"title"`
Department string `json:"department"`
}
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"),
Phone: e.GetAttributeValue("telephoneNumber"),
Title: e.GetAttributeValue("title"),
Department: e.GetAttributeValue("department"),
})
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: users})
}
// BatchImportLDAP 批量导入LDAP用户
// POST /api/v1/admin/ldap-config/import
func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) {
var req struct {
Users []struct {
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
} `json:"users"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
imported := 0
skipped := 0
for _, lu := range req.Users {
uid := lu.UID
if uid == "" {
uid = lu.Username
}
cn := lu.CN
if cn == "" {
cn = lu.Nickname
}
mail := lu.Mail
if mail == "" {
mail = lu.Email
}
if uid == "" {
skipped++
continue
}
// Check if already exists
var existing model.User
if err := h.db.Where("username = ?", uid).First(&existing).Error; err == nil {
// Already exists, update info
updates := map[string]interface{}{
"auth_source": "ldap",
}
if cn != "" {
updates["real_name"] = cn
}
if mail != "" {
updates["email"] = mail
}
h.db.Model(&existing).Updates(updates)
imported++
continue
}
// Create new LDAP user
user := model.User{
Username: uid,
Password: "-", // LDAP users don't have local password
RealName: cn,
Email: mail,
Status: 1,
AuthSource: "ldap",
}
if err := h.db.Create(&user).Error; err != nil {
skipped++
continue
}
imported++
}
// bcrypt import to avoid unused import error
_ = bcrypt.MinCost
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("导入完成: %d 个用户成功, %d 个跳过", imported, skipped),
Data: gin.H{"imported": imported, "skipped": skipped},
})
}
// CreateLDAPUser 在LDAP中创建用户
// POST /api/v1/admin/ldap-config/create-user
func (h *LDAPHandler) CreateLDAPUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
OUDN string `json:"ou_dn"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "用户名和密码不能为空"})
return
}
var configs []model.LDAPConfig
if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LDAP未配置"})
return
}
cfg := configs[0]
port := 389
if cfg.LDAPPort != nil {
port = *cfg.LDAPPort
}
if req.OUDN == "" {
req.OUDN = cfg.BaseDN
}
userDN := fmt.Sprintf("uid=%s,%s", ldap.EscapeFilter(req.Username), req.OUDN)
// Parse user mapping
mapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"}
if cfg.UserMapping != nil {
_ = json.Unmarshal([]byte(*cfg.UserMapping), &mapping)
}
uidAttr := mapping["username"]
if uidAttr == "" {
uidAttr = "uid"
}
mailAttr := mapping["email"]
if mailAttr == "" {
mailAttr = "mail"
}
conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()})
return
}
defer conn.Close()
conn.SetTimeout(5 * time.Second)
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()})
return
}
addReq := ldap.NewAddRequest(userDN, nil)
addReq.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"})
addReq.Attribute(uidAttr, []string{req.Username})
addReq.Attribute("cn", []string{req.DisplayName})
addReq.Attribute("sn", []string{req.DisplayName})
if req.Email != "" {
addReq.Attribute(mailAttr, []string{req.Email})
}
addReq.Attribute("userPassword", []string{req.Password})
if err := conn.Add(addReq); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建用户失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "用户创建成功", Data: gin.H{"dn": userDN}})
}
// DeleteLDAPUser 删除LDAP条目(用户或OU)
// POST /api/v1/admin/ldap-config/delete-user
func (h *LDAPHandler) DeleteLDAPUser(c *gin.Context) {
var req struct {
DN string `json:"dn"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "DN不能为空"})
return
}
var configs []model.LDAPConfig
if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LDAP未配置"})
return
}
cfg := configs[0]
port := 389
if cfg.LDAPPort != nil {
port = *cfg.LDAPPort
}
conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()})
return
}
defer conn.Close()
conn.SetTimeout(5 * time.Second)
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()})
return
}
delReq := ldap.NewDelRequest(req.DN, nil)
if err := conn.Del(delReq); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
}
// UpdateLDAPUser 修改 LDAP 用户属性
// POST /api/v1/admin/ldap-config/update-user
func (h *LDAPHandler) UpdateLDAPUser(c *gin.Context) {
var req struct {
DN string `json:"dn"`
CN string `json:"cn"`
Mail string `json:"mail"`
Phone string `json:"phone"`
Title string `json:"title"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DN == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "DN不能为空"})
return
}
var configs []model.LDAPConfig
if err := h.db.Order("id").Find(&configs).Error; err != nil || len(configs) == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LDAP未配置"})
return
}
cfg := configs[0]
port := 389
if cfg.LDAPPort != nil {
port = *cfg.LDAPPort
}
conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:%d", cfg.LDAPHost, port))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "LDAP连接失败: " + err.Error()})
return
}
defer conn.Close()
conn.SetTimeout(5 * time.Second)
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "管理员认证失败: " + err.Error()})
return
}
// 先查当前值,做 diff
sr, err := conn.Search(ldap.NewSearchRequest(
req.DN, ldap.ScopeBaseObject, ldap.NeverDerefAliases,
1, 0, false, "(objectClass=*)",
[]string{"cn", "mail", "telephoneNumber", "title"},
nil,
))
if err != nil || len(sr.Entries) == 0 {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "用户不存在"})
return
}
entry := sr.Entries[0]
modReq := ldap.NewModifyRequest(req.DN, nil)
// cn
oldCN := entry.GetAttributeValue("cn")
if req.CN != "" && req.CN != oldCN {
modReq.Replace("cn", []string{req.CN})
modReq.Replace("sn", []string{req.CN}) // sn 同步更新
}
// mail
oldMail := entry.GetAttributeValue("mail")
if req.Mail != oldMail {
if req.Mail == "" {
oldVals := entry.GetAttributeValues("mail")
if len(oldVals) > 0 {
modReq.Delete("mail", oldVals)
}
} else {
modReq.Replace("mail", []string{req.Mail})
}
}
// telephoneNumber
oldPhone := entry.GetAttributeValue("telephoneNumber")
if req.Phone != oldPhone {
if req.Phone == "" {
oldVals := entry.GetAttributeValues("telephoneNumber")
if len(oldVals) > 0 {
modReq.Delete("telephoneNumber", oldVals)
}
} else {
modReq.Replace("telephoneNumber", []string{req.Phone})
}
}
// title
oldTitle := entry.GetAttributeValue("title")
if req.Title != oldTitle {
if req.Title == "" {
oldVals := entry.GetAttributeValues("title")
if len(oldVals) > 0 {
modReq.Delete("title", oldVals)
}
} else {
modReq.Replace("title", []string{req.Title})
}
}
if len(modReq.Changes) > 0 {
if err := conn.Modify(modReq); err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "修改失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "修改成功"})
}