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) }