Files
opsxsql/backend/internal/handler/auth.go
T

342 lines
9.7 KiB
Go
Raw 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/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"
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 ──
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"})
}