Files
opsxsql/backend/internal/handler/auth.go
T
logan 5411cd6db9 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 实现
2026-05-26 14:42:31 +08:00

333 lines
9.3 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// POST /api/v1/auth/login
// Strategy: local account first, then LDAP fallback
func Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"})
return
}
var user model.User
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
}
// 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 认证 ──
_, 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 认证成功,自动创建本地用户
isActive := true
defaultRole := "developer"
newUser := model.User{
Username: ldapInfo.Username,
DisplayName: ldapInfo.DisplayName,
Email: ldapInfo.Email,
HashedPassword: "-", // LDAP 用户不存本地密码
Role: defaultRole,
IsActive: &isActive,
}
if newUser.DisplayName == "" {
newUser.DisplayName = newUser.Username
}
if createErr := model.DB.Create(&newUser).Error; createErr != nil {
// 可能并发创建冲突,再查一次
model.DB.Where("username = ?", ldapInfo.Username).First(&user)
if user.ID == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user"})
return
}
} else {
user = newUser
}
// 记录 auth_source
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
return
}
// ── 生成 Token ──
token, err := middleware.GenerateToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
return
}
now := time.Now()
user.LastLogin = &now
model.DB.Save(&user)
c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token,
TokenType: "bearer",
User: user,
})
}
// LDAPAuthInfo holds extracted user info from LDAP
type LDAPAuthInfo struct {
Username string
DisplayName string
Email *string
}
// tryLDAPAuth attempts LDAP bind for the given username/password.
// Returns (ldapConn, userInfo, error). Caller does NOT need to close conn.
func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
// 1. Load LDAP config from DB
var cfg model.LDAPConfig
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
return nil, nil, fmt.Errorf("no LDAP config")
}
if cfg.LDAPHost == "" {
return nil, nil, fmt.Errorf("LDAP not configured")
}
// 2. Connect to LDAP server
protocol := "ldap"
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
protocol = "ldaps"
}
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, 389)
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
uri = fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, *cfg.LDAPPort)
}
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)
}
defer conn.Close()
// 3. Admin bind to search user (decrypt password first)
adminPw, _ := decryptPassword(cfg.AdminPassword)
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
return nil, nil, fmt.Errorf("LDAP admin bind failed: %w", err)
}
// 4. Build search filter
userFilter := ""
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *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))
}
// 5. Search user
searchReq := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, nil, fmt.Errorf("user not found in LDAP")
}
entry := sr.Entries[0]
userDN := entry.DN
// 6. Verify password by binding as user
if err := conn.Bind(userDN, password); err != nil {
return nil, nil, fmt.Errorf("LDAP user bind failed: %w", err)
}
// 7. Extract user info using mapping
userMapping := map[string]string{
"username": "uid",
"email": "mail",
"display_name": "cn",
}
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
getAttr := func(key string) string {
if v := entry.GetAttributeValue(key); v != "" {
return v
}
return ""
}
info := &LDAPAuthInfo{
Username: getAttr(userMapping["username"]),
DisplayName: getAttr(userMapping["display_name"]),
}
if email := getAttr(userMapping["email"]); email != "" {
info.Email = &email
}
if info.Username == "" {
info.Username = username
}
return nil, info, nil // conn already closed by defer
}
// GET /api/v1/auth/me
func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
// GET /api/v1/users
func ListUsers(c *gin.Context) {
var users []model.User
model.DB.Order("id").Find(&users)
c.JSON(http.StatusOK, users)
}
// POST /api/v1/users
func CreateUser(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
DisplayName string `json:"display_name" binding:"required"`
Password string `json:"password" binding:"required"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"})
return
}
isActive := true
user := model.User{
Username: input.Username,
DisplayName: input.DisplayName,
HashedPassword: string(hashed),
Email: input.Email,
Phone: input.Phone,
Role: input.Role,
IsActive: &isActive,
}
if err := model.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
}
return
}
c.JSON(http.StatusCreated, user)
}
// PUT /api/v1/users/:id
func UpdateUser(c *gin.Context) {
id := c.Param("id")
var user model.User
if err := model.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if pw, ok := input["password"].(string); ok && pw != "" {
hashed, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
input["hashed_password"] = string(hashed)
}
delete(input, "password")
if err := model.DB.Model(&user).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
model.DB.First(&user, id)
c.JSON(http.StatusOK, user)
}
// DELETE /api/v1/users/:id
func DeleteUser(c *gin.Context) {
id := c.Param("id")
// Protect admin (id=1) and dba (id=2) from being deleted
if id == "1" || id == "2" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Cannot delete system reserved users"})
return
}
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
}