Files
gochat/internal/auth/sso_middleware.go
T
2026-06-04 15:44:48 +08:00

500 lines
16 KiB
Go

package auth
// Reference: M13 §4 — Unified SSO Middleware + Provider Router
// Provides a single entry point for enterprise SSO authentication that routes
// requests to the correct provider (SAML, LDAP, OIDC) based on the account's
// configuration. This is the "glue" that ties all enterprise auth providers together.
//
// Enterprise feature: GoChat's unified SSO middleware is a multi-tenant routing layer
// that Chatwoot does not offer. While Chatwoot only supports SAML (and basic OAuth),
// GoChat provides LDAP, OIDC, and SAML with per-account isolation, enabling enterprise
// customers to choose the identity provider that fits their infrastructure.
//
// Flow:
// 1. Client sends auth request to /api/v1/sso/authenticate or /api/v1/sso/callback
// 2. SSO middleware resolves the account's configured provider (SAML, LDAP, OIDC)
// 3. Middleware delegates to the appropriate service (SAMLService, LDAPService, OIDCService)
// 4. After successful authentication, middleware:
// a. Auto-provisions GoChat user if configured
// b. Maps external groups/roles to GoChat roles
// c. Creates SSO session in Redis
// d. Issues JWT token for API access
//
// Provider resolution priority (per-account):
// - If account has active SAML settings → SAML
// - If account has active OIDC settings → OIDC
// - If account has active LDAP settings → LDAP
// - If multiple providers are active, the first configured wins (SAML > OIDC > LDAP)
// - Override via query param: ?provider=ldap or ?provider=oidc
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// SSOProviderType identifies the enterprise SSO provider.
type SSOProviderType string
const (
SSOProviderSAML SSOProviderType = "saml"
SSOProviderLDAP SSOProviderType = "ldap"
SSOProviderOIDC SSOProviderType = "oidc"
)
// SSOAuthResult holds the result of a unified SSO authentication attempt.
type SSOAuthResult struct {
Provider SSOProviderType // which provider authenticated the user
UserID uint // GoChat user ID (0 if auto-provision needed)
AccountID uint // account context
Email string // user email from IdP
Name string // user display name from IdP
FirstName string // first name (if available)
LastName string // last name (if available)
Subject string // unique identifier from IdP (NameID, DN, sub)
Role string // mapped GoChat role
Groups []string // groups from IdP (for audit/logging)
AutoProvision bool // whether the user needs auto-provisioning
}
// SSOMiddleware provides unified SSO routing and post-authentication processing.
type SSOMiddleware struct {
db *gorm.DB
rdb redis.Cmdable
cfg *config.Config
samlService *SAMLService
ldapService *LDAPService
oidcService *OIDCService
jwtSecret string
jwtExpiry time.Duration
}
// NewSSOMiddleware creates the unified SSO middleware.
func NewSSOMiddleware(
db *gorm.DB,
rdb redis.Cmdable,
cfg *config.Config,
samlSvc *SAMLService,
ldapSvc *LDAPService,
oidcSvc *OIDCService,
) *SSOMiddleware {
jwtExpiry := 24 * time.Hour // default 24h JWT expiry
if cfg.Session.ExpirySeconds > 0 {
jwtExpiry = time.Duration(cfg.Session.ExpirySeconds) * time.Second
}
return &SSOMiddleware{
db: db,
rdb: rdb,
cfg: cfg,
samlService: samlSvc,
ldapService: ldapSvc,
oidcService: oidcSvc,
jwtSecret: cfg.JWT.Secret,
jwtExpiry: jwtExpiry,
}
}
// ResolveProvider determines which SSO provider is active for a given account.
// Returns the provider type, or empty string if no provider is configured.
// Optional override via providerHint query parameter.
func (m *SSOMiddleware) ResolveProvider(ctx context.Context, accountID uint, providerHint string) (SSOProviderType, error) {
// If an explicit provider hint is given, validate it's active for this account
if providerHint != "" {
pt := SSOProviderType(strings.ToLower(providerHint))
active, err := m.isProviderActive(ctx, accountID, pt)
if err != nil {
return "", fmt.Errorf("provider %s check failed: %w", pt, err)
}
if !active {
return "", fmt.Errorf("provider %s is not active for account %d", pt, accountID)
}
return pt, nil
}
// Priority: SAML > OIDC > LDAP (SAML is the most mature enterprise SSO protocol)
providers := []SSOProviderType{SSOProviderSAML, SSOProviderOIDC, SSOProviderLDAP}
for _, pt := range providers {
active, err := m.isProviderActive(ctx, accountID, pt)
if err != nil {
applogger.L().Warnf("SSO provider check failed (account=%d, provider=%s): %v", accountID, pt, err)
continue
}
if active {
return pt, nil
}
}
return "", fmt.Errorf("no SSO provider configured for account %d", accountID)
}
// isProviderActive checks whether a given SSO provider is active for an account.
func (m *SSOMiddleware) isProviderActive(ctx context.Context, accountID uint, provider SSOProviderType) (bool, error) {
switch provider {
case SSOProviderSAML:
if m.cfg.SAML.Enabled {
var settings model.AccountSamlSettings
err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
if err == nil {
return true, nil // per-account SAML is active
}
if err == gorm.ErrRecordNotFound {
return m.cfg.SAML.Enabled, nil // fall back to global
}
return false, err
}
return false, nil
case SSOProviderLDAP:
if m.cfg.LDAP.Enabled {
var settings model.AccountLDAPSettings
err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
if err == nil {
return true, nil
}
if err == gorm.ErrRecordNotFound {
return m.cfg.LDAP.Enabled, nil
}
return false, err
}
return false, nil
case SSOProviderOIDC:
if m.cfg.OIDC.Enabled {
var settings model.AccountOIDCSettings
err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
if err == nil {
return true, nil
}
if err == gorm.ErrRecordNotFound {
return m.cfg.OIDC.Enabled, nil
}
return false, err
}
return false, nil
default:
return false, fmt.Errorf("unknown SSO provider: %s", provider)
}
}
// AuthenticateLDAP performs LDAP authentication via the unified SSO middleware.
// Returns SSOAuthResult with user info and provisioning status.
func (m *SSOMiddleware) AuthenticateLDAP(ctx context.Context, accountID uint, username, password string) (*SSOAuthResult, error) {
if m.ldapService == nil {
return nil, fmt.Errorf("LDAP service not initialized")
}
userInfo, err := m.ldapService.Authenticate(ctx, accountID, username, password)
if err != nil {
return nil, fmt.Errorf("LDAP authentication failed: %w", err)
}
// Resolve the GoChat user from LDAP attributes
result := &SSOAuthResult{
Provider: SSOProviderLDAP,
AccountID: accountID,
Email: userInfo.Email,
Name: userInfo.DisplayName,
FirstName: userInfo.FirstName,
LastName: userInfo.LastName,
Subject: userInfo.DN, // LDAP DN is the unique identifier
Groups: userInfo.Groups,
}
// Map LDAP groups to GoChat role
ldapSettings := m.getLDAPSettings(accountID)
if ldapSettings != nil {
result.Role = m.ldapService.MapLDAPGroupsToRoles(ldapSettings, userInfo.Groups)
} else {
result.Role = "agent" // default role
}
// Look up existing GoChat user by email
user, err := m.findOrCreateUser(ctx, accountID, userInfo.Email, userInfo.DisplayName, userInfo.DN, "ldap", result.Role)
if err != nil {
return nil, fmt.Errorf("failed to resolve GoChat user: %w", err)
}
result.UserID = user.ID
result.AutoProvision = user.ID == 0
return result, nil
}
// AuthenticateOIDC performs OIDC callback processing via the unified SSO middleware.
// Returns SSOAuthResult with user info and provisioning status.
func (m *SSOMiddleware) AuthenticateOIDC(ctx context.Context, state, code string) (*SSOAuthResult, error) {
if m.oidcService == nil {
return nil, fmt.Errorf("OIDC service not initialized")
}
oidcUserInfo, oidcState, err := m.oidcService.HandleCallback(ctx, state, code)
if err != nil {
return nil, fmt.Errorf("OIDC authentication failed: %w", err)
}
result := &SSOAuthResult{
Provider: SSOProviderOIDC,
AccountID: oidcState.AccountID,
Email: oidcUserInfo.Email,
Name: oidcUserInfo.Name,
FirstName: oidcUserInfo.FirstName,
LastName: oidcUserInfo.LastName,
Subject: oidcUserInfo.Subject, // OIDC sub claim
Groups: oidcUserInfo.Groups,
}
// Map OIDC groups to GoChat role
oidcSettings, _ := m.oidcService.getAccountSettings(oidcState.AccountID)
if oidcSettings != nil {
result.Role = m.oidcService.MapOIDCGroupsToRoles(oidcSettings, oidcUserInfo.Groups)
} else {
result.Role = "agent"
}
// Look up existing GoChat user by email
user, err := m.findOrCreateUser(ctx, oidcState.AccountID, oidcUserInfo.Email, oidcUserInfo.Name, oidcUserInfo.Subject, "oidc", result.Role)
if err != nil {
return nil, fmt.Errorf("failed to resolve GoChat user: %w", err)
}
result.UserID = user.ID
result.AutoProvision = user.ID == 0
return result, nil
}
// IssueJWT creates a JWT token for the authenticated SSO user.
func (m *SSOMiddleware) IssueJWT(result *SSOAuthResult) (string, error) {
if result.UserID == 0 {
return "", fmt.Errorf("cannot issue JWT for unprovisioned user")
}
claims := jwt.MapClaims{
"user_id": result.UserID,
"account_id": result.AccountID,
"role": result.Role,
"provider": string(result.Provider),
"subject": result.Subject,
"email": result.Email,
"iat": time.Now().Unix(),
"exp": time.Now().Add(m.jwtExpiry).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(m.jwtSecret))
}
// CreateSSOSession creates an SSO session in Redis for the authenticated user.
func (m *SSOMiddleware) CreateSSOSession(ctx context.Context, result *SSOAuthResult) (string, error) {
sessionID, err := generateSSOSessionID()
if err != nil {
return "", fmt.Errorf("failed to generate SSO session ID: %w", err)
}
sessionData := SSOSessionData{
SessionID: sessionID,
UserID: result.UserID,
Provider: string(result.Provider),
AccountID: result.AccountID,
Role: result.Role,
NameID: result.Subject,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(m.jwtExpiry).Unix(),
}
// Store session data in Redis
sessionJSON, _ := json.Marshal(sessionData)
key := fmt.Sprintf("sso:session:%s", sessionID)
if err := m.rdb.Set(ctx, key, sessionJSON, m.jwtExpiry).Err(); err != nil {
return "", fmt.Errorf("failed to store SSO session: %w", err)
}
// Also track user→sessions mapping for SLO (single logout)
userSessionsKey := fmt.Sprintf("sso:user_sessions:%d", result.UserID)
m.rdb.SAdd(ctx, userSessionsKey, sessionID)
applogger.L().Infof("SSO session created (user=%d, account=%d, provider=%s, session=%s)",
result.UserID, result.AccountID, result.Provider, sessionID)
return sessionID, nil
}
// --- Gin middleware handler for SSO session validation ---
// SSOSessionValidator returns a gin.HandlerFunc that validates SSO sessions
// from the Authorization header or query parameter.
// This middleware is optional — it supplements the regular JWT AuthMiddleware
// by also checking SSO session validity in Redis.
func (m *SSOMiddleware) SSOSessionValidator() gin.HandlerFunc {
return func(c *gin.Context) {
// Get session ID from header or query
sessionID := c.GetHeader("X-SSO-Session")
if sessionID == "" {
sessionID = c.Query("sso_session")
}
if sessionID == "" {
// No SSO session provided — skip SSO validation (regular JWT auth continues)
c.Next()
return
}
// Validate session in Redis
key := fmt.Sprintf("sso:session:%s", sessionID)
val, err := m.rdb.Get(c.Request.Context(), key).Bytes()
if err != nil {
if err == redis.Nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "SSO session expired or invalid",
})
return
}
applogger.L().Warnf("SSO session lookup failed: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "SSO session validation failed",
})
return
}
var sessionData SSOSessionData
if err := json.Unmarshal(val, &sessionData); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "SSO session data corrupt",
})
return
}
// Set SSO session data in gin context for downstream handlers
c.Set("sso_session", sessionData)
c.Set("sso_provider", sessionData.Provider)
c.Set("user_id", sessionData.UserID)
c.Set("account_id", sessionData.AccountID)
c.Set("role", sessionData.Role)
c.Next()
}
}
// --- Internal helpers ---
// getLDAPSettings loads per-account LDAP settings for the middleware.
func (m *SSOMiddleware) getLDAPSettings(accountID uint) *model.AccountLDAPSettings {
if m.db == nil {
return nil
}
var settings model.AccountLDAPSettings
err := m.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// Return nil — LDAPService.Authenticate will use global defaults
return nil
}
applogger.L().Warnf("LDAP settings lookup failed (account=%d): %v", accountID, err)
return nil
}
return &settings
}
// findOrCreateUser looks up an existing GoChat user by email/provider/uid,
// or creates a new user if auto-provision is enabled.
func (m *SSOMiddleware) findOrCreateUser(ctx context.Context, accountID uint, email, name, uid, provider, role string) (*model.User, error) {
if m.db == nil {
return nil, fmt.Errorf("database not available")
}
// 1. Try to find existing user by email
var user model.User
err := m.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if err == nil {
// User found — ensure they belong to this account
m.ensureAccountMembership(ctx, user.ID, accountID, role)
return &user, nil
}
if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("user lookup failed: %w", err)
}
// 2. No user found — auto-provision if enabled
autoProvision := true // default
switch provider {
case "ldap":
settings := m.getLDAPSettings(accountID)
if settings != nil {
autoProvision = settings.AutoProvision
}
case "oidc":
if m.oidcService != nil {
oidcSettings, _ := m.oidcService.getAccountSettings(accountID)
if oidcSettings != nil {
autoProvision = oidcSettings.AutoProvision
}
}
case "saml":
// SAML model has no AutoProvision field — default to true.
// Role mapping is handled by the SAML service itself.
autoProvision = true
}
if !autoProvision {
return &model.User{}, fmt.Errorf("user %s not found and auto-provision is disabled", email)
}
// 3. Create new user
newUser := model.User{
Email: email,
Name: name,
Provider: provider,
UID: uid,
}
if err := m.db.WithContext(ctx).Create(&newUser).Error; err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
applogger.L().Infof("Auto-provisioned user (id=%d, email=%s, provider=%s)", newUser.ID, email, provider)
// 4. Add user to account with mapped role
m.ensureAccountMembership(ctx, newUser.ID, accountID, role)
return &newUser, nil
}
// ensureAccountMembership ensures a user belongs to the given account with the specified role.
func (m *SSOMiddleware) ensureAccountMembership(ctx context.Context, userID, accountID uint, role string) {
var accountUser model.AccountUser
err := m.db.WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if err == nil {
// Already a member — update role if needed
if accountUser.Role != role {
m.db.WithContext(ctx).Model(&accountUser).Update("role", role)
}
return
}
// Not a member yet — add them
accountUser = model.AccountUser{
AccountID: accountID,
UserID: userID,
Role: role,
}
if err := m.db.WithContext(ctx).Create(&accountUser).Error; err != nil {
applogger.L().Warnf("Failed to add user %d to account %d: %v", userID, accountID, err)
} else {
applogger.L().Infof("Added user %d to account %d with role %s", userID, accountID, role)
}
}