- 新增 crypto.go: AES-256-GCM 加解密,与 Python backend 兼容 - auth.go: Login 添加 LDAP fallback(本地优先→LDAP绑定→自动创建本地用户) - ldap.go: 实现真实 LDAP 连接/用户搜索/密码验证/OU列表/用户列表 - 所有 LDAP 操作解密 admin_password 后使用 - 新增/更新密码存储时加密 - router.go: 补充 delete-user 路由 - 添加 go-ldap/v3 依赖
298 lines
8.2 KiB
Go
298 lines
8.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
|
return
|
|
}
|
|
} else if err == gorm.ErrRecordNotFound {
|
|
// ── 本地不存在该用户,尝试 LDAP 认证 ──
|
|
ldapUser, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
|
|
if ldapErr != nil || ldapUser == 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
|
|
_ = ldapUser // used above
|
|
} 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)
|
|
}
|
|
|
|
conn, err := ldap.DialURL(uri)
|
|
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
|
|
baseFilter := "(objectClass=inetOrgPerson)"
|
|
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
|
baseFilter = *cfg.SearchFilter
|
|
}
|
|
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")
|
|
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"})
|
|
}
|