Files
Logan.Li 13fb3aceb3 feat(ldap): ad/openldap dual compat and config gating
- multi-round user filters (config filter first, then
  objectClass=inetOrgPerson, then AD sAMAccountName/userPrincipalName
  fallback) applied to login search, user listing and batch import;
  previously pure-AD directories found no users
- attribute fallback chains (uid/sAMAccountName/userPrincipalName,
  cn/displayName, telephoneNumber/mobile) via ldapEntryAttr
- import errors explain likely cause instead of empty result
- AES-encrypted admin password kept (aiops stores plaintext)
- frontend gating per aiops pattern: LDAP account management panel
  hidden with hint until a config is actually saved (was gated on
  unsaved form values); batch-import modal hides to/from-LDAP tabs
  when unconfigured, local CSV always available
2026-10-26 20:08:46 +08:00

385 lines
11 KiB
Go
Raw Permalink 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 handler
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"sql-platform/internal/handler/resp"
"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 {
resp.BadRequest(c, "username and password required")
return
}
var user model.User
err := model.DB.Where("username = ?", req.Username).First(&user).Error
// ── 登录失败锁定检查 ──
if err == nil && user.LockedUntil != nil && user.LockedUntil.After(time.Now()) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMin := getSettingInt("login_lockout_minutes", 30)
remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1
resp.TooManyRequests(c, fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), remaining)
return
}
// Lock expired → reset
if err == nil && user.LockedUntil != nil && !user.LockedUntil.After(time.Now()) {
user.FailedLoginAttempts = 0
user.LockedUntil = nil
model.DB.Save(&user)
}
if err == nil {
// ── 本地用户存在 ──
if user.IsActive != nil && !*user.IsActive {
resp.Forbidden(c, "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 {
// Both local and LDAP failed → record failed attempt
handleFailedLogin(&user)
resp.Unauthorized(c, "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 {
resp.Unauthorized(c, "Invalid username or password")
return
}
// LDAP 认证成功,自动创建或恢复本地用户
isActive := true
defaultRole := "developer"
if ldapInfo.DisplayName == "" {
ldapInfo.DisplayName = ldapInfo.Username
}
newUser := model.User{
Username: ldapInfo.Username,
DisplayName: ldapInfo.DisplayName,
Email: ldapInfo.Email,
HashedPassword: "-", // LDAP 用户不存本地密码
AuthSource: "ldap",
Role: defaultRole,
IsActive: &isActive,
}
// FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突)
result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser)
if result.Error != nil {
resp.InternalErr(c, result.Error)
return
}
// 更新已有用户的 display_name / email(LDAP 信息可能变更)
if newUser.ID != 0 {
updates := map[string]interface{}{
"display_name": ldapInfo.DisplayName,
"auth_source": "ldap",
"is_active": true,
}
if ldapInfo.Email != nil {
updates["email"] = *ldapInfo.Email
}
model.DB.Model(&newUser).Updates(updates)
}
user = newUser
} else {
resp.InternalMsg(c, "Database error")
return
}
// ── 生成 Token ──
// Login success → reset failed attempts
user.FailedLoginAttempts = 0
user.LockedUntil = nil
token, err := middleware.GenerateToken(&user)
if err != nil {
resp.InternalMsg(c, "Failed to generate token")
return
}
now := time.Now()
user.LastLogin = &now
model.DB.Save(&user)
// 签发 refresh token(失败不阻塞登录,仅记录)
refreshToken, refreshErr := middleware.GenerateRefreshToken(user.ID)
if refreshErr != nil {
fmt.Printf("generate refresh token failed: %v\n", refreshErr)
}
c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token,
RefreshToken: refreshToken,
TokenType: "bearer",
User: user,
})
}
// POST /api/v1/auth/refresh — 用 refresh_token 轮换新的 token 对
func RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "refresh_token required")
return
}
user, newRefresh, err := middleware.ConsumeRefreshToken(req.RefreshToken)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
return
}
access, err := middleware.GenerateToken(user)
if err != nil {
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": access,
"refresh_token": newRefresh,
"token_type": "bearer",
})
}
// POST /api/v1/auth/logout — 撤销当前用户全部 refresh token
func Logout(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
middleware.RevokeUserRefreshTokens(userID)
resp.Message(c, "Logged out")
}
// 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-5. 多轮过滤器搜索用户(OpenLDAP + AD 兜底,借鉴 aiops)
entry, err := ldapSearchFirstUser(conn, &cfg, username)
if err != nil {
return nil, nil, err
}
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)
}
// 7. 属性回退链提取身份(OpenLDAP 与 AD 字段差异由 ldapEntryAttr 抹平)
identUser, identName, identEmail := ldapIdentityFromEntry(entry, username)
info := &LDAPAuthInfo{
Username: identUser,
DisplayName: identName,
}
if identEmail != "" {
info.Email = &identEmail
}
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 {
resp.NotFound(c, "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 {
resp.BadRequest(c, err.Error())
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
resp.InternalMsg(c, "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") {
resp.BadRequest(c, "Username already exists")
} else {
resp.InternalErr(c, err)
}
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 {
resp.NotFound(c, "User not found")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, 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 {
resp.InternalErr(c, err)
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" {
resp.Forbidden(c, "Cannot delete system reserved users")
return
}
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
}
// handleFailedLogin records a failed login attempt and locks the account if threshold is reached.
func handleFailedLogin(user *model.User) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMinutes := getSettingInt("login_lockout_minutes", 30)
user.FailedLoginAttempts++
if user.FailedLoginAttempts >= maxAttempts {
lockedUntil := time.Now().Add(time.Duration(lockMinutes) * time.Minute)
user.LockedUntil = &lockedUntil
}
model.DB.Save(user)
}