fix: LDAP 测试连接 3s 超时 + 账号验证 search filter %s 替换 + LDAP 登录 fallback
- 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 实现
This commit is contained in:
1 parent
95222d42ad
commit
5411cd6db9
5 files changed
+198
-32
No files matched your search
@@ -30,6 +30,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,19 +30,40 @@ func Login(c *gin.Context) {
|
|||||||
err := model.DB.Where("username = ?", req.Username).First(&user).Error
|
err := model.DB.Where("username = ?", req.Username).First(&user).Error
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// ── 本地用户存在,用本地密码验证 ──
|
// ── 本地用户存在 ──
|
||||||
if user.IsActive != nil && !*user.IsActive {
|
if user.IsActive != nil && !*user.IsActive {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
|
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
// Try local password first
|
||||||
return
|
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 {
|
} else if err == gorm.ErrRecordNotFound {
|
||||||
// ── 本地不存在该用户,尝试 LDAP 认证 ──
|
// ── 本地不存在该用户,尝试 LDAP 认证 ──
|
||||||
ldapUser, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
|
_, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
|
||||||
if ldapErr != nil || ldapUser == nil {
|
if ldapErr != nil || ldapInfo == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -70,7 +92,6 @@ func Login(c *gin.Context) {
|
|||||||
user = newUser
|
user = newUser
|
||||||
}
|
}
|
||||||
// 记录 auth_source
|
// 记录 auth_source
|
||||||
_ = ldapUser // used above
|
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
||||||
return
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("LDAP connect failed: %w", err)
|
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
|
// 4. Build search filter
|
||||||
|
userFilter := ""
|
||||||
baseFilter := "(objectClass=inetOrgPerson)"
|
baseFilter := "(objectClass=inetOrgPerson)"
|
||||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
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
|
// 5. Search user
|
||||||
searchReq := ldap.NewSearchRequest(
|
searchReq := ldap.NewSearchRequest(
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sql-platform/internal/model"
|
"sql-platform/internal/model"
|
||||||
|
|
||||||
@@ -161,17 +164,7 @@ func TestLDAPConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
protocol := "ldap"
|
conn, err := dialLDAP(&cfg)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)})
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)})
|
||||||
return
|
return
|
||||||
@@ -187,11 +180,17 @@ func TestLDAPConnection(c *gin.Context) {
|
|||||||
|
|
||||||
// If test_user provided, search and verify
|
// If test_user provided, search and verify
|
||||||
if req.TestUser != "" && req.TestPassword != "" && req.TestPassword != "__use_stored__" {
|
if req.TestUser != "" && req.TestPassword != "" && req.TestPassword != "__use_stored__" {
|
||||||
baseFilter := "(objectClass=inetOrgPerson)"
|
userFilter := ""
|
||||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
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(
|
sr, err := conn.Search(ldap.NewSearchRequest(
|
||||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
@@ -323,7 +322,12 @@ func ListLDAPUsers(c *gin.Context) {
|
|||||||
|
|
||||||
baseFilter := "(objectClass=inetOrgPerson)"
|
baseFilter := "(objectClass=inetOrgPerson)"
|
||||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
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(
|
sr, err := conn.Search(ldap.NewSearchRequest(
|
||||||
@@ -367,7 +371,119 @@ func CreateLDAPUser(c *gin.Context) {
|
|||||||
|
|
||||||
// POST /api/v1/ldap-config/batch-import-ldap
|
// POST /api/v1/ldap-config/batch-import-ldap
|
||||||
func BatchImportLDAP(c *gin.Context) {
|
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
|
// POST /api/v1/ldap-config/delete-user
|
||||||
@@ -377,6 +493,9 @@ func DeleteLDAPUser(c *gin.Context) {
|
|||||||
|
|
||||||
// helper: dial LDAP with config
|
// helper: dial LDAP with config
|
||||||
func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||||||
|
if cfg.LDAPHost == "" {
|
||||||
|
return nil, fmt.Errorf("LDAP not configured")
|
||||||
|
}
|
||||||
protocol := "ldap"
|
protocol := "ldap"
|
||||||
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
|
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
|
||||||
protocol = "ldaps"
|
protocol = "ldaps"
|
||||||
@@ -385,5 +504,7 @@ func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
|||||||
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
|
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
|
||||||
port = *cfg.LDAPPort
|
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))
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"sql-platform/internal/model"
|
"sql-platform/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -202,7 +204,19 @@ func ResetUserPassword(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: actually hash and save
|
id, err := strconv.Atoi(userId)
|
||||||
_ = userId
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
|
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"})
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ export default function LdapConfigPage() {
|
|||||||
const res = await api.post('/ldap-config/test', {
|
const res = await api.post('/ldap-config/test', {
|
||||||
test_user: config.admin_dn,
|
test_user: config.admin_dn,
|
||||||
test_password: config.admin_password || '__use_stored__',
|
test_password: config.admin_password || '__use_stored__',
|
||||||
}) as any;
|
}, { timeout: 10000 }) as any;
|
||||||
const data = res?.data ?? res;
|
const data = res?.data ?? res;
|
||||||
setTestResult({ ok: true, msg: data?.message || 'LDAP 服务器连接成功' });
|
setTestResult({ ok: true, msg: data?.message || 'LDAP 服务器连接成功' });
|
||||||
setServerConnected(true);
|
setServerConnected(true);
|
||||||
@@ -175,7 +175,7 @@ export default function LdapConfigPage() {
|
|||||||
const res = await api.post('/ldap-config/test', {
|
const res = await api.post('/ldap-config/test', {
|
||||||
test_user: testUser,
|
test_user: testUser,
|
||||||
test_password: testPass,
|
test_password: testPass,
|
||||||
}) as any;
|
}, { timeout: 10000 }) as any;
|
||||||
const data = res?.data ?? res;
|
const data = res?.data ?? res;
|
||||||
if (data?.success === false) {
|
if (data?.success === false) {
|
||||||
setAccountResult({ ok: false, msg: data?.message || '验证失败' });
|
setAccountResult({ ok: false, msg: data?.message || '验证失败' });
|
||||||
|
|||||||
Reference in new issue
Block a user