后端移除: - SAML: auth/saml.go, handler/saml_handler.go, account_saml_settings_handler.go, model/account_saml_settings.go, model/saml_idp_config.go, repo/*.go - LDAP: auth/ldap.go, handler/ldap_handler.go, model/account_ldap_settings.go, repo/account_ldap_settings_repo.go - MFA: auth/mfa.go, handler/mfa_handler.go - auth_service: 移除 mfaService 依赖、MFARequired 字段、LoginWithMFA 方法 - auth_handler: 移除 LoginMFA handler、MFA 分支逻辑 - bootstrap: 移除 SAML/LDAP/MFA service 初始化和 handler 注册 - sso_middleware: 精简为仅支持 OIDC provider - router: 移除 SAML/LDAP/MFA 路由注册 - config: 移除 SAMLConfig/LDAPConfig struct 和 defaults 前端移除: - v3/login: 移除 MFA 验证流程和 SAML 登录入口 - v3/api/auth: 移除 MFA 响应处理 - v3/routes: 移除 SSO login 路由 - dashboard: 移除 MFA 设置页面、SAML 安全设置页面 - i18n: 移除 mfa.json - featureFlags: 移除 SAML feature flag .env.example / .env: 移除 SAML/LDAP 配置段
389 lines
12 KiB
Go
389 lines
12 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 (OIDC) based on the account's
|
|
// configuration. This is the "glue" that ties enterprise auth providers together.
|
|
//
|
|
// Enterprise feature: GoChat's unified SSO middleware is a multi-tenant routing layer
|
|
// that Chatwoot does not offer. GoChat provides OIDC 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 (OIDC)
|
|
// 3. Middleware delegates to the appropriate service (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 OIDC settings → OIDC
|
|
// - Override via query param: ?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 (
|
|
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
|
|
oidcService *OIDCService
|
|
jwtSecret string
|
|
jwtExpiry time.Duration
|
|
}
|
|
|
|
// NewSSOMiddleware creates the unified SSO middleware.
|
|
func NewSSOMiddleware(
|
|
db *gorm.DB,
|
|
rdb redis.Cmdable,
|
|
cfg *config.Config,
|
|
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,
|
|
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: OIDC (only enterprise SSO protocol supported)
|
|
providers := []SSOProviderType{SSOProviderOIDC}
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
// 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 ---
|
|
|
|
// 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 "oidc":
|
|
if m.oidcService != nil {
|
|
oidcSettings, _ := m.oidcService.getAccountSettings(accountID)
|
|
if oidcSettings != nil {
|
|
autoProvision = oidcSettings.AutoProvision
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
} |