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