From 5411cd6db9d42926a9edd751d3d38334a959918f Mon Sep 17 00:00:00 2001 From: logan Date: Tue, 26 May 2026 14:42:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20LDAP=20=E6=B5=8B=E8=AF=95=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=203s=20=E8=B6=85=E6=97=B6=20+=20=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20search=20filter=20%s=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=20+=20LDAP=20=E7=99=BB=E5=BD=95=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dialLDAP 辅助函数: 3s timeout 替代无超时 DialURL - TestLDAPConnection: search_filter (uid=%s) 中 %s 未替换导致搜索失败 - tryLDAPAuth: 同样的 %s 替换 + timeout 3s - auth.go 登录: 本地密码失败 → LDAP fallback → ldapInfo 判断修复 - BatchImportLDAP: 实现 LDAP 用户批量导入到本地 DB - 前端 LdapConfigPage: LDAP 测试请求独立 10s timeout - workflow.go: ResetUserPassword 实现 --- backend/go.mod | 1 + backend/internal/handler/auth.go | 50 +++++++-- backend/internal/handler/ldap.go | 155 +++++++++++++++++++++++--- backend/internal/handler/workflow.go | 20 +++- frontend/src/pages/LdapConfigPage.tsx | 4 +- 5 files changed, 198 insertions(+), 32 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 26964db..be9f4f4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -30,6 +30,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 3944199..452d66d 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "fmt" + "net" "net/http" "strings" "time" @@ -29,19 +30,40 @@ func Login(c *gin.Context) { err := model.DB.Where("username = ?", req.Username).First(&user).Error if err == nil { - // ── 本地用户存在,用本地密码验证 ── + // ── 本地用户存在 ── if user.IsActive != nil && !*user.IsActive { c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"}) return } - if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) - return + + // Try local password first + passOk := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)) == nil + + if !passOk { + // Local password failed — try LDAP fallback for LDAP-imported users (password == "-") + _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) + if ldapErr != nil || ldapInfo == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) + return + } + // LDAP OK — update local user info + if ldapInfo != nil { + updates := map[string]interface{}{} + if ldapInfo.DisplayName != "" && ldapInfo.DisplayName != user.DisplayName { + updates["display_name"] = ldapInfo.DisplayName + } + if ldapInfo.Email != nil && *ldapInfo.Email != "" { + updates["email"] = *ldapInfo.Email + } + if len(updates) > 0 { + model.DB.Model(&user).Updates(updates) + } + } } } else if err == gorm.ErrRecordNotFound { // ── 本地不存在该用户,尝试 LDAP 认证 ── - ldapUser, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) - if ldapErr != nil || ldapUser == nil { + _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) + if ldapErr != nil || ldapInfo == nil { c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) return } @@ -70,7 +92,6 @@ func Login(c *gin.Context) { user = newUser } // 记录 auth_source - _ = ldapUser // used above } else { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"}) return @@ -123,7 +144,8 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) { uri = fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, *cfg.LDAPPort) } - conn, err := ldap.DialURL(uri) + dialer := &net.Dialer{Timeout: 3 * time.Second} + conn, err := ldap.DialURL(uri, ldap.DialWithDialer(dialer)) if err != nil { return nil, nil, fmt.Errorf("LDAP connect failed: %w", err) } @@ -136,11 +158,19 @@ func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) { } // 4. Build search filter + userFilter := "" baseFilter := "(objectClass=inetOrgPerson)" if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - baseFilter = *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)) } - userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username)) // 5. Search user searchReq := ldap.NewSearchRequest( diff --git a/backend/internal/handler/ldap.go b/backend/internal/handler/ldap.go index f27d57f..842ee97 100644 --- a/backend/internal/handler/ldap.go +++ b/backend/internal/handler/ldap.go @@ -3,7 +3,10 @@ package handler import ( "encoding/json" "fmt" + "net" "net/http" + "strings" + "time" "sql-platform/internal/model" @@ -161,17 +164,7 @@ func TestLDAPConnection(c *gin.Context) { } // Connect - protocol := "ldap" - if cfg.UseLDAPS != nil && *cfg.UseLDAPS { - protocol = "ldaps" - } - port := 389 - if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 { - port = *cfg.LDAPPort - } - uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port) - - conn, err := ldap.DialURL(uri) + conn, err := dialLDAP(&cfg) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)}) return @@ -187,11 +180,17 @@ func TestLDAPConnection(c *gin.Context) { // If test_user provided, search and verify if req.TestUser != "" && req.TestPassword != "" && req.TestPassword != "__use_stored__" { - baseFilter := "(objectClass=inetOrgPerson)" + userFilter := "" if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - baseFilter = *cfg.SearchFilter + sf := *cfg.SearchFilter + if strings.Contains(sf, "%s") { + userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(req.TestUser)) + } else { + userFilter = fmt.Sprintf("(&%s(uid=%s))", sf, ldap.EscapeFilter(req.TestUser)) + } + } else { + userFilter = fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(req.TestUser)) } - userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser)) sr, err := conn.Search(ldap.NewSearchRequest( cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, @@ -323,7 +322,12 @@ func ListLDAPUsers(c *gin.Context) { baseFilter := "(objectClass=inetOrgPerson)" if cfg.SearchFilter != nil && *cfg.SearchFilter != "" { - baseFilter = *cfg.SearchFilter + sf := *cfg.SearchFilter + if strings.Contains(sf, "%s") { + baseFilter = strings.ReplaceAll(sf, "%s", "*") + } else { + baseFilter = sf + } } sr, err := conn.Search(ldap.NewSearchRequest( @@ -367,7 +371,119 @@ func CreateLDAPUser(c *gin.Context) { // POST /api/v1/ldap-config/batch-import-ldap func BatchImportLDAP(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0}) + var req struct { + OuDn string `json:"ou_dn"` + Role string `json:"role"` + UserIDs []string `json:"user_ids"` + } + c.ShouldBindJSON(&req) + if req.Role == "" { + req.Role = "developer" + } + + var cfg model.LDAPConfig + if err := model.DB.Order("id").First(&cfg).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 配置不存在"}) + return + } + + conn, err := dialLDAP(&cfg) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 连接失败: " + err.Error()}) + return + } + defer conn.Close() + + adminPw, _ := decryptPassword(cfg.AdminPassword) + if err := conn.Bind(cfg.AdminDN, adminPw); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 管理员绑定失败"}) + 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, + 500, 0, false, baseFilter, + []string{"uid", "cn", "mail"}, nil, + )) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "LDAP 搜索失败: " + err.Error()}) + return + } + + isActive := true + imported := 0 + skipped := 0 + var results []map[string]string + + for _, entry := range sr.Entries { + uid := entry.GetAttributeValue("uid") + if uid == "" { + continue + } + if len(req.UserIDs) > 0 { + found := false + for _, id := range req.UserIDs { + if id == uid { found = true; break } + } + if !found { continue } + } + + cn := entry.GetAttributeValue("cn") + mail := entry.GetAttributeValue("mail") + + var existing model.User + err := model.DB.Where("username = ?", uid).First(&existing).Error + if err == nil { + // Update existing user + updates := map[string]interface{}{} + if cn != "" && cn != existing.DisplayName { updates["display_name"] = cn } + if mail != "" && (existing.Email == nil || *existing.Email != mail) { updates["email"] = mail } + if len(updates) > 0 { + model.DB.Model(&existing).Updates(updates) + } + results = append(results, map[string]string{"uid": uid, "status": "updated"}) + skipped++ + continue + } + + newUser := model.User{ + Username: uid, + DisplayName: cn, + Role: req.Role, + IsActive: &isActive, + HashedPassword: "-", + } + if mail != "" { newUser.Email = &mail } + if err := model.DB.Create(&newUser).Error; err != nil { + results = append(results, map[string]string{"uid": uid, "status": "error: " + err.Error()}) + skipped++ + continue + } + results = append(results, map[string]string{"uid": uid, "status": "created"}) + imported++ + } + + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("导入完成: %d 新建, %d 更新", imported, skipped), + "imported": imported, + "skipped": skipped, + "results": results, + }) } // POST /api/v1/ldap-config/delete-user @@ -377,6 +493,9 @@ func DeleteLDAPUser(c *gin.Context) { // helper: dial LDAP with config func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) { + if cfg.LDAPHost == "" { + return nil, fmt.Errorf("LDAP not configured") + } protocol := "ldap" if cfg.UseLDAPS != nil && *cfg.UseLDAPS { protocol = "ldaps" @@ -385,5 +504,7 @@ func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) { if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 { port = *cfg.LDAPPort } - return ldap.DialURL(fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port)) + uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port) + dialer := &net.Dialer{Timeout: 3 * time.Second} + return ldap.DialURL(uri, ldap.DialWithDialer(dialer)) } diff --git a/backend/internal/handler/workflow.go b/backend/internal/handler/workflow.go index 3e75444..b2627eb 100644 --- a/backend/internal/handler/workflow.go +++ b/backend/internal/handler/workflow.go @@ -2,7 +2,9 @@ package handler import ( "net/http" + "strconv" + "golang.org/x/crypto/bcrypt" "sql-platform/internal/model" "github.com/gin-gonic/gin" @@ -202,7 +204,19 @@ func ResetUserPassword(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } - // TODO: actually hash and save - _ = userId - c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"}) + id, err := strconv.Atoi(userId) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid user ID"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "Hash failed"}) + return + } + if err := model.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hash)).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "Update failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"}) } diff --git a/frontend/src/pages/LdapConfigPage.tsx b/frontend/src/pages/LdapConfigPage.tsx index 0440e20..1c697fa 100644 --- a/frontend/src/pages/LdapConfigPage.tsx +++ b/frontend/src/pages/LdapConfigPage.tsx @@ -136,7 +136,7 @@ export default function LdapConfigPage() { const res = await api.post('/ldap-config/test', { test_user: config.admin_dn, test_password: config.admin_password || '__use_stored__', - }) as any; + }, { timeout: 10000 }) as any; const data = res?.data ?? res; setTestResult({ ok: true, msg: data?.message || 'LDAP 服务器连接成功' }); setServerConnected(true); @@ -175,7 +175,7 @@ export default function LdapConfigPage() { const res = await api.post('/ldap-config/test', { test_user: testUser, test_password: testPass, - }) as any; + }, { timeout: 10000 }) as any; const data = res?.data ?? res; if (data?.success === false) { setAccountResult({ ok: false, msg: data?.message || '验证失败' });