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