Files
gochat/internal/handler/api/v1/ldap_handler.go
T
2026-06-04 15:44:48 +08:00

580 lines
20 KiB
Go

package v1
// Reference: M13 §4.4 — LDAP HTTP endpoints for login, config, and connectivity testing
// Provides four endpoints for LDAP/Active Directory integration:
// - POST /api/v1/ldap/login → LDAP bind authentication + JWT issuance
// - POST /api/v1/ldap/test → Admin-only LDAP connectivity test
// - GET /api/v1/ldap/config → Admin-only LDAP settings retrieval
// - PUT /api/v1/ldap/config → Admin-only LDAP settings update
//
// Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding
// LDAP support for traditional enterprise AD/LDAP environments.
//
// Login flow:
// 1. Client sends {username, password, account_id} to /api/v1/ldap/login
// 2. Handler delegates to SSOMiddleware.AuthenticateLDAP for unified SSO processing
// 3. SSOMiddleware routes to LDAPService.Authenticate (Bind + search + group extraction)
// 4. On success: SSOMiddleware auto-provisions user, maps groups→roles, creates SSO session
// 5. Handler issues JWT token pair via JWTService.GenerateTokenPair
// 6. Store refresh token, return access+refresh tokens to client
//
// Config management (admin-only):
// - Account administrators can configure per-account LDAP settings
// - TestConnection validates LDAP bind connectivity before saving config
// - GetConfig/UpdateConfig manage per-account LDAP settings in DB
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/response"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// LDAPHandler handles LDAP authentication HTTP endpoints.
type LDAPHandler struct {
ldapService *auth.LDAPService
ssoMiddleware *auth.SSOMiddleware
jwtService *auth.JWTService
refreshStore *auth.RefreshTokenStore
ssoSessionStore *auth.SSOSessionStore
ldapCfg *config.LDAPConfig
db *gorm.DB
}
// NewLDAPHandler creates an LDAP handler with service dependencies.
func NewLDAPHandler(
ldapService *auth.LDAPService,
ssoMiddleware *auth.SSOMiddleware,
jwtService *auth.JWTService,
refreshStore *auth.RefreshTokenStore,
ssoSessionStore *auth.SSOSessionStore,
ldapCfg *config.LDAPConfig,
db *gorm.DB,
) *LDAPHandler {
return &LDAPHandler{
ldapService: ldapService,
ssoMiddleware: ssoMiddleware,
jwtService: jwtService,
refreshStore: refreshStore,
ssoSessionStore: ssoSessionStore,
ldapCfg: ldapCfg,
db: db,
}
}
// ldapLoginRequest is the JSON body for POST /api/v1/ldap/login.
type ldapLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
AccountID uint `json:"account_id" binding:"required"`
}
// Login authenticates a user via LDAP bind and issues a JWT token pair.
// POST /api/v1/ldap/login
// This endpoint is PUBLIC — no AuthMiddleware required (LDAP login is the entry point).
func (h *LDAPHandler) Login(c *gin.Context) {
if !h.ldapCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "LDAP is not enabled",
},
})
return
}
var req ldapLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
// Delegate to SSO middleware for unified authentication flow
// (auto-provision, group→role mapping, SSO session creation)
result, err := h.ssoMiddleware.AuthenticateLDAP(c.Request.Context(), req.AccountID, req.Username, req.Password)
if err != nil {
applogger.L().Errorf("LDAP authentication failed (account=%d, username=%s): %v", req.AccountID, req.Username, err)
statusCode := http.StatusInternalServerError
errCode := response.ErrInternal
if errors.Is(err, auth.ErrLDAPDisabled) {
statusCode = http.StatusNotFound
errCode = response.ErrNotFound
} else if errors.Is(err, auth.ErrLDAPInvalidConfig) {
statusCode = http.StatusBadRequest
errCode = response.ErrBadRequest
} else if errors.Is(err, auth.ErrLDAPConnection) {
statusCode = http.StatusServiceUnavailable
errCode = response.ErrServiceUnavail
} else if errors.Is(err, auth.ErrLDAPUserNotFound) || errors.Is(err, auth.ErrLDAPBindFailed) {
statusCode = http.StatusUnauthorized
errCode = response.ErrUnauthorized
}
c.JSON(statusCode, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: errCode,
Message: "LDAP authentication failed",
Detail: err.Error(),
},
})
return
}
// Issue JWT token pair using JWTService
// SSO middleware already created the user and mapped roles
tokenPair, err := h.jwtService.GenerateTokenPair(
&model.User{
Base: model.Base{ID: result.UserID},
Email: result.Email,
Name: result.Name,
Provider: "ldap",
UID: result.Subject,
Role: result.Role,
},
result.AccountID,
result.Role,
)
if err != nil {
applogger.L().Errorf("Failed to generate JWT for LDAP user (account=%d, user=%d): %v", result.AccountID, result.UserID, err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to generate authentication tokens",
},
})
return
}
// Store refresh token
if h.refreshStore != nil {
if err := h.refreshStore.Store(c.Request.Context(), result.UserID, tokenPair.RefreshToken); err != nil {
applogger.L().Warnf("Failed to store refresh token for LDAP user %d: %v", result.UserID, err)
// Non-fatal: access token is still valid, refresh just won't work until re-login
}
}
// Create SSO session in Redis (for session tracking and SLO)
if h.ssoSessionStore != nil {
sessionData := &auth.SSOSessionData{
UserID: result.UserID,
Provider: "ldap",
IdPEntityID: fmt.Sprintf("ldap-account-%d", result.AccountID), // LDAP server as IdP identifier
NameID: result.Subject, // LDAP DN as NameID
AccountID: result.AccountID,
Role: result.Role,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(h.ssoSessionStore.SessionTTL()).Unix(),
}
sessionID, err := h.ssoSessionStore.Create(c.Request.Context(), sessionData)
if err != nil {
applogger.L().Warnf("Failed to create SSO session for LDAP user %d: %v", result.UserID, err)
// Non-fatal: JWT tokens are still valid, SSO session is for tracking/SLO only
} else {
applogger.L().Infof("SSO session %s created for LDAP user %d (account=%d)", sessionID, result.UserID, result.AccountID)
}
}
// Return successful auth response (same format as SAML ACS and regular login)
response.OK(c, gin.H{
"user": gin.H{
"id": result.UserID,
"email": result.Email,
"name": result.Name,
"provider": "ldap",
"uid": result.Subject,
"role": result.Role,
},
"access_token": tokenPair.AccessToken,
"refresh_token": tokenPair.RefreshToken,
"expires_at": tokenPair.ExpiresAt,
})
}
// ldapTestRequest is the JSON body for POST /api/v1/ldap/test.
type ldapTestRequest struct {
AccountID uint `json:"account_id" binding:"required"`
}
// getAccountSettingsFromDB loads per-account LDAP configuration from DB directly.
// This is needed because LDAPService.getAccountSettings is unexported.
func (h *LDAPHandler) getAccountSettingsFromDB(accountID uint) (*model.AccountLDAPSettings, error) {
var settings model.AccountLDAPSettings
err := h.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// No per-account settings — check if global defaults exist
if h.ldapCfg.DefaultHost == "" {
return nil, nil // LDAP not configured for this account
}
// Use global defaults as a fallback
settings = model.AccountLDAPSettings{
AccountID: accountID,
Host: h.ldapCfg.DefaultHost,
Port: h.ldapCfg.DefaultPort,
UseTLS: h.ldapCfg.DefaultUseTLS,
BaseDN: h.ldapCfg.DefaultBaseDN,
BindDN: h.ldapCfg.DefaultBindDN,
BindPassword: h.ldapCfg.DefaultBindPassword,
UserFilter: h.ldapCfg.DefaultUserFilter,
EmailAttribute: h.ldapCfg.DefaultEmailAttribute,
NameAttribute: h.ldapCfg.DefaultNameAttribute,
GroupAttribute: h.ldapCfg.DefaultGroupAttribute,
AutoProvision: true,
Active: true,
}
return &settings, nil
}
return nil, err
}
return &settings, nil
}
// testLDAPConnectivity tests LDAP bind connectivity for an account's configuration.
// Connects to the LDAP server and attempts a bind with the service account to verify
// that the configuration is correct before saving.
func (h *LDAPHandler) testLDAPConnectivity(settings *model.AccountLDAPSettings) error {
// Use LDAPService.Authenticate with a dummy test to verify connectivity.
// The LDAPService handles connection + bind internally, so we use it to validate.
// We attempt a bind-only test by calling Authenticate with empty credentials
// and catching the specific error pattern.
// However, since Authenticate requires a real username/password, we instead
// try to directly connect and bind using the service account credentials.
//
// For simplicity, we delegate to ldapService.Authenticate with a test username.
// If the connection itself fails, we get ErrLDAPConnection.
// If the service account bind fails, we get an appropriate error.
// If the user search fails (expected for test username), we know connectivity works.
ctx := context.Background()
_, err := h.ldapService.Authenticate(ctx, settings.AccountID, "__ldap_connectivity_test__", "__invalid_test_password__")
if err == nil {
// Unexpected: test credentials actually worked. Still means connectivity is good.
return nil
}
// If connection failed, return that error
if errors.Is(err, auth.ErrLDAPConnection) || errors.Is(err, auth.ErrLDAPDisabled) || errors.Is(err, auth.ErrLDAPInvalidConfig) {
return err
}
// If we got ErrLDAPUserNotFound or ErrLDAPBindFailed, that means the connection
// and service account bind succeeded — only the test user lookup/bind failed,
// which is expected. Connectivity is confirmed.
return nil
}
// TestConnection tests LDAP bind connectivity for an account's configuration.
// POST /api/v1/ldap/test
// Admin-only: requires AuthMiddleware + admin role (enforced at router level).
func (h *LDAPHandler) TestConnection(c *gin.Context) {
if !h.ldapCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "LDAP is not enabled",
},
})
return
}
var req ldapTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
// Load per-account LDAP settings from DB
settings, err := h.getAccountSettingsFromDB(req.AccountID)
if err != nil {
applogger.L().Errorf("Failed to load LDAP settings for account %d: %v", req.AccountID, err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to load LDAP settings",
Detail: err.Error(),
},
})
return
}
if settings == nil {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "No LDAP configuration found for this account",
},
})
return
}
// Test LDAP connectivity
err = h.testLDAPConnectivity(settings)
if err != nil {
applogger.L().Errorf("LDAP connectivity test failed (account=%d, host=%s:%d): %v", req.AccountID, settings.Host, settings.Port, err)
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "LDAP connectivity test failed",
Detail: err.Error(),
},
})
return
}
response.OK(c, gin.H{
"account_id": req.AccountID,
"host": settings.Host,
"port": settings.Port,
"connected": true,
})
}
// GetConfig retrieves LDAP settings for an account.
// GET /api/v1/ldap/config?account_id=123
// Admin-only: requires AuthMiddleware + admin role (enforced at router level).
func (h *LDAPHandler) GetConfig(c *gin.Context) {
if !h.ldapCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "LDAP is not enabled",
},
})
return
}
accountIDStr := c.Query("account_id")
if accountIDStr == "" {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "account_id query parameter is required",
},
})
return
}
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid account_id",
Detail: err.Error(),
},
})
return
}
settings, err := h.getAccountSettingsFromDB(uint(accountID))
if err != nil {
applogger.L().Errorf("Failed to get LDAP config for account %d: %v", accountID, err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to retrieve LDAP configuration",
Detail: err.Error(),
},
})
return
}
if settings == nil {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "No LDAP configuration found for this account",
},
})
return
}
response.OK(c, settings)
}
// ldapUpdateConfigRequest is the JSON body for PUT /api/v1/ldap/config.
type ldapUpdateConfigRequest struct {
AccountID uint `json:"account_id" binding:"required"`
Host string `json:"host" binding:"required"`
Port int `json:"port"`
UseTLS bool `json:"use_tls"`
BaseDN string `json:"base_dn" binding:"required"`
BindDN string `json:"bind_dn,omitempty"`
BindPassword string `json:"bind_password,omitempty"`
UserFilter string `json:"user_filter"`
EmailAttribute string `json:"email_attribute"`
NameAttribute string `json:"name_attribute"`
FirstNameAttribute string `json:"first_name_attribute"`
LastNameAttribute string `json:"last_name_attribute"`
GroupAttribute string `json:"group_attribute"`
GroupFilter string `json:"group_filter"`
RoleMappings json.RawMessage `json:"role_mappings"`
AutoProvision bool `json:"auto_provision"`
SyncInterval int `json:"sync_interval"`
Active bool `json:"active"`
}
// UpdateConfig updates per-account LDAP settings.
// PUT /api/v1/ldap/config
// Admin-only: requires AuthMiddleware + admin role (enforced at router level).
func (h *LDAPHandler) UpdateConfig(c *gin.Context) {
if !h.ldapCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "LDAP is not enabled",
},
})
return
}
var req ldapUpdateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Invalid request body",
Detail: err.Error(),
},
})
return
}
// Default port values
if req.Port == 0 {
if req.UseTLS {
req.Port = 636
} else {
req.Port = 389
}
}
// Load existing settings or create new
var settings model.AccountLDAPSettings
err := h.db.Where("account_id = ?", req.AccountID).First(&settings).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
applogger.L().Errorf("Failed to check existing LDAP settings for account %d: %v", req.AccountID, err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to check existing LDAP configuration",
Detail: err.Error(),
},
})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new settings
settings = model.AccountLDAPSettings{
AccountID: req.AccountID,
Host: req.Host,
Port: req.Port,
UseTLS: req.UseTLS,
BaseDN: req.BaseDN,
BindDN: req.BindDN,
BindPassword: req.BindPassword,
UserFilter: req.UserFilter,
EmailAttribute: req.EmailAttribute,
NameAttribute: req.NameAttribute,
FirstNameAttribute: req.FirstNameAttribute,
LastNameAttribute: req.LastNameAttribute,
GroupAttribute: req.GroupAttribute,
GroupFilter: req.GroupFilter,
RoleMappings: req.RoleMappings,
AutoProvision: req.AutoProvision,
SyncInterval: req.SyncInterval,
Active: req.Active,
}
} else {
// Update existing settings
settings.Host = req.Host
settings.Port = req.Port
settings.UseTLS = req.UseTLS
settings.BaseDN = req.BaseDN
settings.BindDN = req.BindDN
settings.BindPassword = req.BindPassword
settings.UserFilter = req.UserFilter
settings.EmailAttribute = req.EmailAttribute
settings.NameAttribute = req.NameAttribute
settings.FirstNameAttribute = req.FirstNameAttribute
settings.LastNameAttribute = req.LastNameAttribute
settings.GroupAttribute = req.GroupAttribute
settings.GroupFilter = req.GroupFilter
settings.RoleMappings = req.RoleMappings
settings.AutoProvision = req.AutoProvision
settings.SyncInterval = req.SyncInterval
settings.Active = req.Active
}
// Save to DB
if err := h.db.Save(&settings).Error; err != nil {
applogger.L().Errorf("Failed to save LDAP settings for account %d: %v", req.AccountID, err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to save LDAP configuration",
Detail: err.Error(),
},
})
return
}
applogger.L().Infof("LDAP settings updated for account %d (host=%s, port=%d, active=%v)", req.AccountID, req.Host, req.Port, req.Active)
response.OK(c, settings)
}
// RegisterLDAPRoutes sets up LDAP routes on a Gin router group.
// Login route is PUBLIC — no AuthRequired middleware (LDAP login doesn't require existing JWT).
// Config management routes require AuthMiddleware + admin role (enforced at router level).
func RegisterLDAPRoutes(rg *gin.RouterGroup, handler *LDAPHandler) {
ldapGroup := rg.Group("/ldap")
{
// Public route: LDAP login (no AuthRequired middleware)
ldapGroup.POST("/login", handler.Login)
// Admin-only routes: config management + connectivity test
// These are wired into the authenticated + admin router group externally,
// so AuthMiddleware + admin role check is enforced at the router level.
ldapGroup.POST("/test", handler.TestConnection)
ldapGroup.GET("/config", handler.GetConfig)
ldapGroup.PUT("/config", handler.UpdateConfig)
}
}