- query组: ExecuteQuery读取query_limit_max/query_timeout/query_dangerous_pattern - 查询超时通过context.WithTimeout生效,db.QueryContext/QueryRowContext - 危险模式检测拦截无WHERE的DELETE/UPDATE/DROP/TRUNCATE - LIMIT上限从settings读取,不再硬编码1000 - security组: Login读取login_max_attempts/login_lockout_minutes - 新增User字段: failed_login_attempts + locked_until - 登录失败达到阈值自动锁定,成功时清除计数 - JWT Token过期时间读取session_timeout_hours设置 - executor组: ExecuteTicket读取executor_timeout/executor_dry_run - 执行超时通过context.WithTimeout控制 - 开启dry_run时先EXPLAIN预检查SQL语法 - db.ExecContext替代db.Exec - cleanup组: SettingsCleanupPreview/Run从占位改为真实实现 - 预览统计4类数据(工单/查询历史/慢日志/审计日志)待清理数量 - 执行清理按retention_days配置删除过期数据 - ghost组: 13项gh-ost配置加入seedDefaultSettings - enabled/path/mode/osc_priority/env/cloud/trigger/chunk/load/lag等 - 已被ghost.go中getGhostConfig等函数读取 - 前端: PlatformConfigPage新增Ghost/OSC分组显示
375 lines
11 KiB
Go
375 lines
11 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 && 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
|
||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": 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 {
|
||
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 {
|
||
// Both local and LDAP failed → record failed attempt
|
||
handleFailedLogin(&user)
|
||
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"
|
||
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 {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user: " + result.Error.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 {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
||
return
|
||
}
|
||
|
||
// ── 生成 Token ──
|
||
// Login success → reset failed attempts
|
||
user.FailedLoginAttempts = 0
|
||
user.LockedUntil = nil
|
||
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"})
|
||
}
|
||
|
||
// 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)
|
||
}
|