diff --git a/.env.example b/.env.example index 8f8ebe30..1391221f 100644 --- a/.env.example +++ b/.env.example @@ -60,26 +60,6 @@ GOCHAT_STORAGE_PROVIDER=local GOCHAT_STORAGE_LOCAL_PATH=./uploads GOCHAT_STORAGE_MAX_FILE_SIZE=20971520 # 20MB -# ---- SAML (Enterprise SSO, disabled by default) ---- -GOCHAT_SAML_ENABLED=false -GOCHAT_SAML_IDP_METADATA_URL= -GOCHAT_SAML_SP_ENTITY_ID=gochat -GOCHAT_SAML_SP_ACS_URL=https://your-domain.com/api/v1/auth/saml/acs - -# ---- LDAP (Enterprise Authentication, disabled by default) ---- -GOCHAT_LDAP_ENABLED=false -GOCHAT_LDAP_DEFAULT_HOST=ldap.example.com -GOCHAT_LDAP_DEFAULT_PORT=389 -GOCHAT_LDAP_DEFAULT_USE_TLS=false -GOCHAT_LDAP_DEFAULT_BASE_DN=dc=example,dc=com -GOCHAT_LDAP_DEFAULT_BIND_DN=cn=admin,dc=example,dc=com -GOCHAT_LDAP_DEFAULT_BIND_PASSWORD=CHANGE_ME -GOCHAT_LDAP_DEFAULT_USER_FILTER=(objectClass=person) -GOCHAT_LDAP_DEFAULT_EMAIL_ATTRIBUTE=mail -GOCHAT_LDAP_DEFAULT_NAME_ATTRIBUTE=cn -GOCHAT_LDAP_DEFAULT_GROUP_ATTRIBUTE=memberOf -GOCHAT_LDAP_SYNC_INTERVAL=3600 - # ---- OIDC (Enterprise OAuth/OIDC SSO, disabled by default) ---- GOCHAT_OIDC_ENABLED=false GOCHAT_OIDC_DEFAULT_CLIENT_ID= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 2372aee9..7024de67 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -184,11 +184,6 @@ func autoMigrate(db *gorm.DB) error { &model.PushToken{}, &model.WebhookSubscription{}, &model.WebhookDelivery{}, - // M13: SAML/SSO enterprise authentication models - &model.SAMLIdPConfig{}, - &model.AccountSamlSettings{}, - // M13: LDAP enterprise authentication model - &model.AccountLDAPSettings{}, // M13: OIDC enterprise authentication model &model.AccountOIDCSettings{}, // P9: AgentBot rule engine models (defined in automation package to avoid import cycle) diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index f5763351..67fbebd5 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -62,7 +62,7 @@ func (d *dbProvider) DB() *gorm.DB { return d.db } // 2. Init logger // 3. Connect database // 4. Connect Redis -// 5. Wire auth services (JWT, OAuth, MFA, refresh tokens) +// 5. Wire auth services (JWT, OAuth, refresh tokens) // 6. Wire repositories -> services -> handlers (dependency injection) // 7. Setup HTTP router + middleware chain func Bootstrap(env string) (*App, error) { @@ -139,40 +139,11 @@ func Bootstrap(env string) (*App, error) { jwtService := auth.NewJWTService(&cfg.JWT) refreshStore := auth.NewRefreshTokenStore(rdb, &cfg.JWT) sessionStore := auth.NewSessionStore(&cfg.Session) // session management (ref: Chatwoot Devise sessions) - mfaService := auth.NewMFAService(db) webhookRegistry := auth.NewWebhookTokenRegistry() - // Step 6b: Wire SAML service (if enabled) - var samlService *auth.SAMLService - if cfg.SAML.Enabled { - samlSvc, err := auth.NewSAMLService(&cfg.SAML, rdb, db) - if err != nil { - return nil, fmt.Errorf("saml service init failed: %w", err) - } - samlService = samlSvc - applogger.L().Info("SAML service initialized (enabled)") - } else { - samlService, _ = auth.NewSAMLService(&cfg.SAML, nil, nil) - applogger.L().Info("SAML service initialized (disabled)") - } - // Step 6c: Wire SSO session store (M13 — SSO session tracking + SLO support) ssoSessionStore := auth.NewSSOSessionStore(rdb, 24*time.Hour) - // Step 6d: Wire LDAP service (if enabled) - var ldapService *auth.LDAPService - if cfg.LDAP.Enabled { - ldapSvc, err := auth.NewLDAPService(&cfg.LDAP, rdb, db) - if err != nil { - return nil, fmt.Errorf("ldap service init failed: %w", err) - } - ldapService = ldapSvc - applogger.L().Info("LDAP service initialized (enabled)") - } else { - ldapService, _ = auth.NewLDAPService(&cfg.LDAP, nil, nil) - applogger.L().Info("LDAP service initialized (disabled)") - } - // Step 6e: Wire OIDC service (if enabled) var oidcService *auth.OIDCService if cfg.OIDC.Enabled { @@ -188,7 +159,7 @@ func Bootstrap(env string) (*App, error) { } // Step 6f: Wire unified SSO middleware (M13 — provider routing + post-auth processing) - ssoMiddleware := auth.NewSSOMiddleware(db, rdb, cfg, samlService, ldapService, oidcService) + ssoMiddleware := auth.NewSSOMiddleware(db, rdb, cfg, oidcService) // Step 7: Wire repositories (data access layer) accountRepo := repository.NewAccountRepo(db) @@ -294,15 +265,8 @@ func Bootstrap(env string) (*App, error) { integrationHookRepo := repository.NewIntegrationHookRepo(db) integrationAppRepo := repository.NewIntegrationAppRepo(db) - // M13 repos: SAML IdP config + Account SAML settings (enterprise SSO) - samlIdPConfigRepo := repository.NewSAMLIdPConfigRepo(db) - accountSamlSettingsRepo := repository.NewAccountSamlSettingsRepo(db) - _ = samlIdPConfigRepo // used by SAML service later - - // M13 repos: LDAP + OIDC account settings (enterprise SSO) - accountLDAPSettingsRepo := repository.NewAccountLDAPSettingsRepo(db) + // M13 repos: OIDC account settings (enterprise SSO) accountOIDCSettingsRepo := repository.NewAccountOIDCSettingsRepo(db) - _ = accountLDAPSettingsRepo // used by LDAP handler later _ = accountOIDCSettingsRepo // used by OIDC handler later // Custom attribute definition + custom filter repos @@ -319,7 +283,7 @@ func Bootstrap(env string) (*App, error) { agentCapacityPolicyRepo := repository.NewAgentCapacityPolicyRepo(db) // Step 8: Wire services (business logic layer) - authService := service.NewAuthService(db, jwtService, refreshStore, mfaService) + authService := service.NewAuthService(db, jwtService, refreshStore) accountService := service.NewAccountService(accountRepo) accountService.SetWorkerPool(workerPool) whatsAppCallService := service.NewWhatsAppCallService(whatsAppCallRepo) @@ -825,8 +789,6 @@ func Bootstrap(env string) (*App, error) { handlers := &router.Handlers{ RBAC: service.NewRBACService(db), Auth: v1.NewAuthHandler(authService, profileService), - MFA: v1.NewMFAHandler(mfaService), - SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher), @@ -906,11 +868,9 @@ func Bootstrap(env string) (*App, error) { CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService), Search: v1.NewSearchHandler(searchService, db), Widget: widgetHandler, - // M13: SSO/SAML enterprise authentication handlers - AccountSamlSettings: v1.NewAccountSamlSettingsHandler(accountSamlSettingsRepo), + // M13: SSO enterprise authentication handlers SSOSession: v1.NewSSOSessionHandler(ssoSessionStore), - // M13: LDAP/OIDC enterprise authentication handlers - LDAP: v1.NewLDAPHandler(ldapService, ssoMiddleware, jwtService, refreshStore, ssoSessionStore, &cfg.LDAP, db), + // M13: OIDC enterprise authentication handlers OIDC: v1.NewOIDCHandler(oidcService, ssoMiddleware, jwtService, refreshStore, &cfg.OIDC), SSOMiddleware: ssoMiddleware, // M12: AgentBot handlers (platform-level + account-level bots) diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 11546296..5cadfe5d 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -21,7 +21,7 @@ type Claims struct { AccountID uint `json:"account_id"` // current active account Role string `json:"role"` // agent/administrator/custom_role UserType string `json:"user_type,omitempty"` // user/super_admin platform identity - Provider string `json:"provider"` // email/google/saml + Provider string `json:"provider"` // email/google/oidc CustomRoleID uint `json:"custom_role_id,omitempty"` // enterprise custom role ClientID string `json:"client_id,omitempty"` // DeviseTokenAuth-compatible session id jwt.RegisteredClaims diff --git a/backend/internal/auth/ldap.go b/backend/internal/auth/ldap.go deleted file mode 100644 index a111a31c..00000000 --- a/backend/internal/auth/ldap.go +++ /dev/null @@ -1,822 +0,0 @@ -package auth - -// Reference: M13 §4.4 — LDAP/Active Directory authentication service -// Provides LDAP Bind authentication, user search, group extraction, and role mapping. -// Supports per-account LDAP configuration for multi-tenant identity isolation. -// Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding -// LDAP support for traditional enterprise AD/LDAP environments. -// -// Authentication flow: -// 1. User enters enterprise username + password -// 2. GoChat connects to LDAP server using service account (BindDN + BindPassword) -// 3. Search BaseDN + UserFilter to find user entry DN -// 4. Bind with user DN + password to verify credentials -// 5. Extract email/name/groups attributes from user entry -// 6. LDAPUserBuilder maps attributes to GoChat user fields -// 7. Auto-provision GoChat user if configured -// 8. Issue JWT token + create SSO session -// -// Connection modes: -// - Plain LDAP (port 389) — insecure, not recommended for production -// - LDAP + StartTLS (port 389 + TLS upgrade) — recommended -// - LDAPS (port 636) — TLS from the start, also recommended - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - "net" - "strings" - "sync" - "time" - - "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" -) - -// LDAP errors -var ( - ErrLDAPDisabled = fmt.Errorf("ldap authentication is not enabled") - ErrLDAPInvalidConfig = fmt.Errorf("ldap configuration is invalid") - ErrLDAPConnection = fmt.Errorf("failed to connect to ldap server") - ErrLDAPBindFailed = fmt.Errorf("ldap bind authentication failed") - ErrLDAPUserNotFound = fmt.Errorf("ldap user not found") - ErrLDAPSearchFailed = fmt.Errorf("ldap search failed") -) - -// LDAPUserInfo represents user info extracted from an LDAP directory entry. -type LDAPUserInfo struct { - DN string // Distinguished Name — unique identifier in LDAP - Email string // email attribute (typically mail) - DisplayName string // display name attribute (typically cn) - FirstName string // first name attribute (typically givenName) - LastName string // last name attribute (typically sn) - Groups []string // group memberships (typically memberOf attribute) - Attributes map[string]string // all extracted attributes for flexibility -} - -// LDAPService provides LDAP authentication and user/group lookup. -type LDAPService struct { - cfg *config.LDAPConfig - rdb redis.Cmdable - db *gorm.DB - mu sync.RWMutex - connPool map[uint]*ldapConnection // per-account connection pool -} - -// ldapConnection wraps a raw LDAP TCP/TLS connection for reuse. -type ldapConnection struct { - host string - port int - useTLS bool - conn net.Conn - lastUsed time.Time -} - -// NewLDAPService creates an LDAP service with configuration. -func NewLDAPService(cfg *config.LDAPConfig, rdb redis.Cmdable, db *gorm.DB) (*LDAPService, error) { - if !cfg.Enabled { - applogger.L().Info("LDAP service initialized (disabled)") - return &LDAPService{ - cfg: cfg, - rdb: rdb, - db: db, - connPool: make(map[uint]*ldapConnection), - }, nil - } - - // Validate global default configuration if host is set - if cfg.DefaultHost != "" { - if cfg.DefaultPort == 0 { - cfg.DefaultPort = 389 - } - if cfg.DefaultBaseDN == "" { - return nil, ErrLDAPInvalidConfig - } - } - - svc := &LDAPService{ - cfg: cfg, - rdb: rdb, - db: db, - connPool: make(map[uint]*ldapConnection), - } - - applogger.L().Infof("LDAP service initialized (enabled, default_host=%s, default_port=%d)", cfg.DefaultHost, cfg.DefaultPort) - return svc, nil -} - -// Authenticate authenticates a user against LDAP using Bind verification. -// Steps: (1) connect to LDAP, (2) bind with service account, (3) search for user, -// (4) bind with user DN + password to verify credentials, (5) extract user attributes. -func (s *LDAPService) Authenticate(ctx context.Context, accountID uint, username, password string) (*LDAPUserInfo, error) { - if !s.cfg.Enabled { - return nil, ErrLDAPDisabled - } - - // Get per-account LDAP settings from DB - settings, err := s.getAccountSettings(accountID) - if err != nil { - return nil, fmt.Errorf("failed to load LDAP settings for account %d: %w", accountID, err) - } - if settings == nil { - return nil, ErrLDAPDisabled - } - - // Connect to LDAP server - conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrLDAPConnection, err) - } - defer conn.Close() - - // Step 1: Bind with service account to search for user - if settings.BindDN != "" && settings.BindPassword != "" { - err = s.ldapBind(conn, settings.BindDN, settings.BindPassword) - if err != nil { - return nil, fmt.Errorf("service account bind failed: %w", err) - } - } - - // Step 2: Search for user entry - userDN, userAttrs, err := s.searchUser(conn, settings, username) - if err != nil { - return nil, fmt.Errorf("%w: username=%s, %v", ErrLDAPUserNotFound, username, err) - } - - // Step 3: Bind with user DN + password to verify credentials - err = s.ldapBind(conn, userDN, password) - if err != nil { - return nil, ErrLDAPBindFailed - } - - // Step 4: Extract user info from attributes - userInfo := s.extractUserInfo(settings, userDN, userAttrs) - - // Step 5: Extract group memberships - groups, err := s.extractGroups(conn, settings, userDN, userAttrs) - if err != nil { - applogger.L().Warnf("Failed to extract LDAP groups for user %s: %v", userDN, err) - } else { - userInfo.Groups = groups - } - - applogger.L().Infof("LDAP authentication successful (account=%d, user=%s, dn=%s)", accountID, username, userDN) - return userInfo, nil -} - -// getAccountSettings loads per-account LDAP configuration from DB. -// Falls back to global defaults for fields not overridden per-account. -func (s *LDAPService) getAccountSettings(accountID uint) (*model.AccountLDAPSettings, error) { - if s.db == nil { - return nil, fmt.Errorf("database not available") - } - - var settings model.AccountLDAPSettings - err := s.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - // No per-account settings — check if global defaults exist - if s.cfg.DefaultHost == "" { - return nil, nil // LDAP not configured for this account - } - // Use global defaults as a fallback - settings = model.AccountLDAPSettings{ - AccountID: accountID, - Host: s.cfg.DefaultHost, - Port: s.cfg.DefaultPort, - UseTLS: s.cfg.DefaultUseTLS, - BaseDN: s.cfg.DefaultBaseDN, - BindDN: s.cfg.DefaultBindDN, - BindPassword: s.cfg.DefaultBindPassword, - UserFilter: s.cfg.DefaultUserFilter, - EmailAttribute: s.cfg.DefaultEmailAttribute, - NameAttribute: s.cfg.DefaultNameAttribute, - GroupAttribute: s.cfg.DefaultGroupAttribute, - AutoProvision: true, - Active: true, - } - return &settings, nil - } - return nil, err - } - - // Override empty per-account fields with global defaults - if settings.Host == "" && s.cfg.DefaultHost != "" { - settings.Host = s.cfg.DefaultHost - } - if settings.Port == 0 { - settings.Port = s.cfg.DefaultPort - if settings.Port == 0 { - settings.Port = 389 - } - } - if settings.UserFilter == "" && s.cfg.DefaultUserFilter != "" { - settings.UserFilter = s.cfg.DefaultUserFilter - } - if settings.EmailAttribute == "" && s.cfg.DefaultEmailAttribute != "" { - settings.EmailAttribute = s.cfg.DefaultEmailAttribute - } - if settings.NameAttribute == "" && s.cfg.DefaultNameAttribute != "" { - settings.NameAttribute = s.cfg.DefaultNameAttribute - } - if settings.GroupAttribute == "" && s.cfg.DefaultGroupAttribute != "" { - settings.GroupAttribute = s.cfg.DefaultGroupAttribute - } - - return &settings, nil -} - -// connect establishes a TCP connection to the LDAP server, optionally upgrading to TLS. -func (s *LDAPService) connect(host string, port int, useTLS bool) (net.Conn, error) { - address := fmt.Sprintf("%s:%d", host, port) - - if useTLS { - // Direct TLS connection (LDAPS) - tlsConfig := &tls.Config{ - ServerName: host, - // In production, InsecureSkipVerify should be false. - // For initial testing with self-signed certs, it can be set to true via config. - } - conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", address, tlsConfig) - if err != nil { - return nil, fmt.Errorf("TLS connection to %s failed: %w", address, err) - } - return conn, nil - } - - // Plain TCP connection (may upgrade to StartTLS later) - conn, err := net.DialTimeout("tcp", address, 10*time.Second) - if err != nil { - return nil, fmt.Errorf("TCP connection to %s failed: %w", address, err) - } - return conn, nil -} - -// ldapBind performs an LDAP Bind operation over a TCP/TLS connection. -// This is a simplified LDAP Bind — it sends the LDAP Bind request packet -// and reads the response. In production, use the go-ldap/ldap3 library -// for full LDAP protocol support. This implementation provides the basic -// wire protocol for Bind operations. -func (s *LDAPService) ldapBind(conn net.Conn, dn, password string) error { - // LDAP Bind request packet (BER encoding) - // MessageID = 1, BindRequest = version 3, DN, simple auth - bindRequest := encodeLDAPBindRequest(1, 3, dn, password) - if _, err := conn.Write(bindRequest); err != nil { - return fmt.Errorf("failed to write LDAP bind request: %w", err) - } - - // Read LDAP Bind response - response, err := readLDAPResponse(conn) - if err != nil { - return fmt.Errorf("failed to read LDAP bind response: %w", err) - } - - // Check result code — 0 = success - if response.ResultCode != 0 { - return fmt.Errorf("LDAP bind failed: result code %d (%s)", response.ResultCode, response.DiagnosticMessage) - } - - return nil -} - -// searchUser searches for a user entry in the LDAP directory. -// Returns the user's DN and a map of attributes. -func (s *LDAPService) searchUser(conn net.Conn, settings *model.AccountLDAPSettings, username string) (string, map[string]string, error) { - // Construct search filter: combine base UserFilter with username - searchFilter := settings.UserFilter - if !strings.Contains(searchFilter, "=") { - // Default filter: (objectClass=person) — add uid/email matching - searchFilter = fmt.Sprintf("(&(%s)(|(uid=%s)(mail=%s)(cn=%s)))", - settings.UserFilter, username, username, username) - } else { - // Append username matching to existing filter - searchFilter = fmt.Sprintf("(&%s(|(uid=%s)(mail=%s)))", searchFilter, username, username) - } - - // LDAP Search request - searchRequest := encodeLDAPSearchRequest(2, settings.BaseDN, 2, // scope: whole subtree - 0, // derefAliases: never - 0, // sizeLimit: no limit - 30, // timeLimit: 30 seconds - false, // typesOnly: false - searchFilter, - []string{settings.EmailAttribute, settings.NameAttribute, settings.FirstNameAttribute, - settings.LastNameAttribute, settings.GroupAttribute, "uid", "dn", "cn"}, - ) - - if _, err := conn.Write(searchRequest); err != nil { - return "", nil, fmt.Errorf("failed to write LDAP search request: %w", err) - } - - // Read search results - results, err := readLDAPSearchResults(conn) - if err != nil { - return "", nil, fmt.Errorf("%w: %v", ErrLDAPSearchFailed, err) - } - - if len(results) == 0 { - return "", nil, fmt.Errorf("no LDAP entries found for username=%s", username) - } - - // Return first matching entry - entry := results[0] - return entry.DN, entry.Attributes, nil -} - -// extractUserInfo maps LDAP attributes to LDAPUserInfo struct. -func (s *LDAPService) extractUserInfo(settings *model.AccountLDAPSettings, dn string, attrs map[string]string) *LDAPUserInfo { - info := &LDAPUserInfo{ - DN: dn, - Email: getAttr(attrs, settings.EmailAttribute, "mail"), - DisplayName: getAttr(attrs, settings.NameAttribute, "cn"), - FirstName: getAttr(attrs, settings.FirstNameAttribute, "givenName"), - LastName: getAttr(attrs, settings.LastNameAttribute, "sn"), - Attributes: attrs, - } - return info -} - -// extractGroups extracts group memberships from the user's LDAP entry. -// Supports both memberOf attribute extraction and group search. -func (s *LDAPService) extractGroups(conn net.Conn, settings *model.AccountLDAPSettings, userDN string, attrs map[string]string) ([]string, error) { - groups := []string{} - - // Method 1: Extract from memberOf attribute - groupAttr := settings.GroupAttribute - if groupAttr == "" { - groupAttr = "memberOf" - } - if groupValues, ok := attrs[groupAttr]; ok { - for _, g := range strings.Split(groupValues, ";") { - g = strings.TrimSpace(g) - if g != "" { - groups = append(groups, g) - } - } - } - - // Method 2: If no memberOf, search groups containing this user DN - if len(groups) == 0 && settings.GroupFilter != "" { - groupFilter := fmt.Sprintf("(&%s(%s=%s))", settings.GroupFilter, "member", userDN) - searchRequest := encodeLDAPSearchRequest(3, settings.BaseDN, 2, 0, 0, 10, false, - groupFilter, []string{"cn", "dn"}) - - if _, err := conn.Write(searchRequest); err == nil { - results, err := readLDAPSearchResults(conn) - if err == nil { - for _, entry := range results { - if cn, ok := entry.Attributes["cn"]; ok { - groups = append(groups, cn) - } - } - } - } - } - - return groups, nil -} - -// MapLDAPGroupsToRoles maps LDAP groups to GoChat roles using the role mapping config. -func (s *LDAPService) MapLDAPGroupsToRoles(settings *model.AccountLDAPSettings, groups []string) string { - if settings.RoleMappings == nil || len(settings.RoleMappings) == 0 { - return "agent" // default role - } - - var mappings map[string]string - if err := json.Unmarshal(settings.RoleMappings, &mappings); err != nil { - applogger.L().Warnf("Invalid LDAP role mappings JSON: %v", err) - return "agent" - } - - // Check each group against mappings — highest privilege wins - rolePriority := map[string]int{ - "administrator": 4, - "admin": 3, - "supervisor": 2, - "agent": 1, - } - - bestRole := "agent" - bestPriority := 1 - - for _, group := range groups { - // Extract group CN from DN (e.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins") - groupCN := extractCNFromDN(group) - - // Check direct mapping - if mappedRole, ok := mappings[groupCN]; ok { - if p, ok := rolePriority[mappedRole]; ok && p > bestPriority { - bestRole = mappedRole - bestPriority = p - } - } - // Check full DN mapping - if mappedRole, ok := mappings[group]; ok { - if p, ok := rolePriority[mappedRole]; ok && p > bestPriority { - bestRole = mappedRole - bestPriority = p - } - } - } - - return bestRole -} - -// SyncGroups performs periodic LDAP group synchronization. -// Reads LDAP groups for all provisioned users and updates their GoChat roles. -func (s *LDAPService) SyncGroups(ctx context.Context, accountID uint) error { - if !s.cfg.Enabled { - return ErrLDAPDisabled - } - - settings, err := s.getAccountSettings(accountID) - if err != nil || settings == nil { - return fmt.Errorf("LDAP not configured for account %d", accountID) - } - - applogger.L().Infof("LDAP group sync starting for account %d", accountID) - - // Query all users in this account that were provisioned via LDAP - // Use the User model's Provider field to identify LDAP-provisioned users - var accountUsers []model.AccountUser - if err := s.db.Where("account_id = ?", accountID). - Preload("User", "provider = 'ldap'"). - Find(&accountUsers).Error; err != nil { - return fmt.Errorf("failed to query LDAP-provisioned users: %w", err) - } - - // Filter to only users with LDAP provider - ldapUsers := []model.AccountUser{} - for _, au := range accountUsers { - if au.User.Provider == "ldap" { - ldapUsers = append(ldapUsers, au) - } - } - - conn, err := s.connect(settings.Host, settings.Port, settings.UseTLS) - if err != nil { - return fmt.Errorf("LDAP connection failed: %w", err) - } - defer conn.Close() - - if settings.BindDN != "" { - if err := s.ldapBind(conn, settings.BindDN, settings.BindPassword); err != nil { - return fmt.Errorf("LDAP service account bind failed: %w", err) - } - } - - for _, au := range ldapUsers { - // Search for user in LDAP by their UID (external identifier from LDAP) - ldapUID := au.User.UID - if ldapUID == "" { - // Fallback: use email as LDAP search key - ldapUID = au.User.Email - } - - userDN, userAttrs, err := s.searchUser(conn, settings, ldapUID) - if err != nil { - applogger.L().Warnf("LDAP user not found during sync: %s (dn=%s)", ldapUID, userDN) - continue - } - - groups, err := s.extractGroups(conn, settings, userDN, userAttrs) - if err != nil { - applogger.L().Warnf("LDAP group extraction failed for %s: %v", userDN, err) - continue - } - - mappedRole := s.MapLDAPGroupsToRoles(settings, groups) - - // Update user role if changed - if au.Role != mappedRole { - s.db.Model(&model.AccountUser{}).Where("id = ?", au.ID).Update("role", mappedRole) - applogger.L().Infof("LDAP role sync: user %d role changed from %s to %s", au.UserID, au.Role, mappedRole) - } - } - - applogger.L().Infof("LDAP group sync completed for account %d (%d users checked)", accountID, len(ldapUsers)) - return nil -} - -// Helper functions - -// getAttr retrieves an attribute from the map, with fallback. -func getAttr(attrs map[string]string, key, fallback string) string { - if v, ok := attrs[key]; ok && v != "" { - return v - } - return fallback -} - -// extractCNFromDN extracts the CN component from a DN string. -// E.g. "cn=admins,ou=groups,dc=example,dc=com" → "admins" -func extractCNFromDN(dn string) string { - parts := strings.Split(dn, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "cn=") || strings.HasPrefix(part, "CN=") { - return part[3:] - } - } - return dn // return full DN if no CN found -} - -// --- LDAP BER encoding helpers --- -// These implement minimal BER (Basic Encoding Rules) for LDAP protocol packets. -// In production, replace with the go-ldap/ldap3 library for full protocol support. -// These helpers provide the basic wire protocol needed for Bind/Search operations. - -func encodeLDAPBindRequest(messageID int, version int, dn, password string) []byte { - // BER-encoded LDAP BindRequest - // Sequence { MessageID, BindRequest { version, name, simpleAuth { password } } } - authSimple := berEncodeOctetString(password) - bindRequestInner := berEncodeSequence( - berEncodeInteger(version), - berEncodeOctetString(dn), - berEncodeContextTag(0, authSimple), // simple auth (tag 0) - ) - bindRequestWrapped := berEncodeApplicationTag(0, bindRequestInner) // BindRequest app tag 0 - - message := berEncodeSequence( - berEncodeInteger(messageID), - bindRequestWrapped, - ) - return message -} - -func encodeLDAPSearchRequest(messageID int, baseDN string, scope, derefAliases, sizeLimit, timeLimit int, - typesOnly bool, filter string, attributes []string) []byte { - // BER-encoded LDAP SearchRequest - attrListParts := [][]byte{} - for _, attr := range attributes { - attrListParts = append(attrListParts, berEncodeOctetString(attr)) - } - attrListSeq := berEncodeSequence(attrListParts...) - - searchRequestInner := berEncodeSequence( - berEncodeOctetString(baseDN), - berEncodeEnumerated(scope), - berEncodeEnumerated(derefAliases), - berEncodeInteger(sizeLimit), - berEncodeInteger(timeLimit), - berEncodeBoolean(typesOnly), - berEncodeOctetString(filter), // simplified: pass filter as octet string - attrListSeq, - ) - searchRequestWrapped := berEncodeApplicationTag(3, searchRequestInner) // SearchRequest app tag 3 - - message := berEncodeSequence( - berEncodeInteger(messageID), - searchRequestWrapped, - ) - return message -} - -// LDAPSearchEntry represents a single search result entry. -type LDAPSearchEntry struct { - DN string - Attributes map[string]string -} - -// LDAPResponse represents a parsed LDAP response. -type LDAPResponse struct { - MessageID int - ResultCode int - DiagnosticMessage string -} - -// readLDAPResponse reads and parses a minimal LDAP response. -func readLDAPResponse(conn net.Conn) (*LDAPResponse, error) { - // Read response header and parse BER - buf := make([]byte, 4096) - n, err := conn.Read(buf) - if err != nil { - return nil, fmt.Errorf("failed to read LDAP response: %w", err) - } - - if n < 10 { - return nil, fmt.Errorf("LDAP response too short (%d bytes)", n) - } - - // Parse the BER-encoded response to extract result code - // Simplified: scan for the result code in the response bytes - resp := parseLDAPBindResponse(buf[:n]) - return resp, nil -} - -// readLDAPSearchResults reads and parses LDAP search result entries. -func readLDAPSearchResults(conn net.Conn) ([]*LDAPSearchEntry, error) { - buf := make([]byte, 8192) - n, err := conn.Read(buf) - if err != nil { - return nil, fmt.Errorf("failed to read LDAP search response: %w", err) - } - - // Simplified parsing — in production use go-ldap/ldap3 library - // For now, return mock entries parsed from BER-encoded response - return parseLDAPSearchEntries(buf[:n]), nil -} - -// parseLDAPBindResponse extracts result code from BER-encoded LDAP BindResponse. -func parseLDAPBindResponse(data []byte) *LDAPResponse { - resp := &LDAPResponse{} - - // Skip BER envelope header (sequence + message ID + app tag) - // Find the result code — it's typically at a fixed offset in BindResponse - if len(data) > 15 { - // Result code is encoded as INTEGER after the BindResponse app tag - // Scan for integer tag (0x02) after the app tag - for i := 8; i < len(data)-3; i++ { - if data[i] == 0x02 { // INTEGER tag - if data[i+1] <= 2 && i+2+int(data[i+1]) <= len(data) { - // Parse integer value - length := int(data[i+1]) - value := 0 - for j := 0; j < length; j++ { - value = value*256 + int(data[i+2+j]) - } - resp.ResultCode = value - break - } - } - } - } - - // Extract diagnostic message if present - if len(data) > 20 { - for i := 12; i < len(data)-2; i++ { - if data[i] == 0x04 && i+1+int(data[i+1]) <= len(data) { - // OCTET STRING — could be diagnostic message - length := int(data[i+1]) - if i+2+length <= len(data) { - msg := string(data[i+2 : i+2+length]) - if msg != "" && len(msg) < 200 { - resp.DiagnosticMessage = msg - } - } - break - } - } - } - - return resp -} - -// parseLDAPSearchEntries parses BER-encoded LDAP search result entries. -func parseLDAPSearchEntries(data []byte) []*LDAPSearchEntry { - // Simplified parsing — in production, use go-ldap/ldap3 library - // This provides basic extraction of DN and key attributes from BER-encoded data - entries := []*LDAPSearchEntry{} - - if len(data) < 10 { - return entries - } - - // Check if this is a search result done (no entries found) - // app tag 5 = SearchResDone - if len(data) > 4 && data[4] == 0x65 { - return entries - } - - // For a real implementation, the go-ldap/ldap3 library handles full BER parsing. - // This stub returns the parsed structure indicating the protocol framework is in place. - return entries -} - -// --- BER encoding primitives --- -// Minimal BER (Basic Encoding Rules) helpers for LDAP wire protocol. -// Tag byte format: Class (2 bits) | Constructed (1 bit) | Tag number (5 bits) - -func berEncodeInteger(value int) []byte { - if value == 0 { - return []byte{0x02, 0x01, 0x00} // INTEGER tag, length 1, value 0 - } - // Encode non-zero integer - bytes := []byte{} - v := value - if v < 0 { - // Negative integers need special handling - v = -v - for shift := 0; v >> uint(shift) != 0 || shift == 0; shift += 8 { - bytes = append([]byte{byte(v >> uint(shift))}, bytes...) - } - // Negate: two's complement - for i := range bytes { - bytes[i] = ^bytes[i] - } - for i := len(bytes) - 1; i >= 0; i-- { - bytes[i]++ - if bytes[i] != 0 { - break - } - } - if bytes[0]&0x80 != 0 { - bytes = append([]byte{0xFF}, bytes...) - } - } else { - for shift := 24; shift >= 0; shift -= 8 { - b := byte(v >> uint(shift)) - if b != 0 || len(bytes) > 0 { - bytes = append(bytes, b) - } - } - if len(bytes) == 0 { - bytes = []byte{0} - } - if bytes[0]&0x80 != 0 { - bytes = append([]byte{0}, bytes...) - } - } - result := []byte{0x02, byte(len(bytes))} - result = append(result, bytes...) - return result -} - -func berEncodeEnumerated(value int) []byte { - // ENUMERATED has same encoding as INTEGER, just tag 0x0a - encoded := berEncodeInteger(value) - encoded[0] = 0x0a // ENUMERATED tag - return encoded -} - -func berEncodeOctetString(value string) []byte { - data := []byte(value) - length := len(data) - if length < 128 { - return append([]byte{0x04, byte(length)}, data...) - } - // Long form length - lengthBytes := []byte{} - for ; length > 0; length >>= 8 { - lengthBytes = append([]byte{byte(length)}, lengthBytes...) - } - result := []byte{0x04, byte(0x80 | len(lengthBytes))} - result = append(result, lengthBytes...) - result = append(result, data...) - return result -} - -func berEncodeBoolean(value bool) []byte { - if value { - return []byte{0x01, 0x01, 0xFF} // TRUE - } - return []byte{0x01, 0x01, 0x00} // FALSE -} - -func berEncodeSequence(parts ...[]byte) []byte { - content := []byte{} - for _, p := range parts { - content = append(content, p...) - } - length := len(content) - if length < 128 { - return append([]byte{0x30, byte(length)}, content...) - } - lengthBytes := []byte{} - for ; length > 0; length >>= 8 { - lengthBytes = append([]byte{byte(length)}, lengthBytes...) - } - result := []byte{0x30, byte(0x80 | len(lengthBytes))} - result = append(result, lengthBytes...) - result = append(result, content...) - return result -} - -func berEncodeApplicationTag(tagNum int, content []byte) []byte { - // Application tag: class=01 (application), constructed=1, tag number - firstByte := byte(0x40 | 0x20 | byte(tagNum)) // application | constructed | tag number - length := len(content) - if length < 128 { - return append([]byte{firstByte, byte(length)}, content...) - } - lengthBytes := []byte{} - for ; length > 0; length >>= 8 { - lengthBytes = append([]byte{byte(length)}, lengthBytes...) - } - result := []byte{firstByte, byte(0x80 | len(lengthBytes))} - result = append(result, lengthBytes...) - result = append(result, content...) - return result -} - -func berEncodeContextTag(tagNum int, content []byte) []byte { - // Context-specific tag: class=10 (context), constructed=1 - firstByte := byte(0x80 | 0x20 | byte(tagNum)) // context | constructed | tag number - length := len(content) - if length < 128 { - return append([]byte{firstByte, byte(length)}, content...) - } - lengthBytes := []byte{} - for ; length > 0; length >>= 8 { - lengthBytes = append([]byte{byte(length)}, lengthBytes...) - } - result := []byte{firstByte, byte(0x80 | len(lengthBytes))} - result = append(result, lengthBytes...) - result = append(result, content...) - return result -} \ No newline at end of file diff --git a/backend/internal/auth/ldap_test.go b/backend/internal/auth/ldap_test.go deleted file mode 100644 index 7188f905..00000000 --- a/backend/internal/auth/ldap_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package auth - -import ( - "context" - "encoding/json" - "testing" - - "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" - - "github.com/gochat/gochat/internal/config" - "github.com/gochat/gochat/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -// --- Helpers for LDAP tests --- - -func newTestLDAPDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - require.NoError(t, err) - err = db.AutoMigrate(&model.AccountLDAPSettings{}, &model.User{}, &model.Account{}, &model.AccountUser{}) - require.NoError(t, err) - return db -} - -func newTestLDAPConfig(enabled bool) *config.LDAPConfig { - return &config.LDAPConfig{ - Enabled: enabled, - DefaultHost: "localhost", - DefaultPort: 389, - DefaultUseTLS: false, - DefaultBaseDN: "dc=example,dc=com", - DefaultBindDN: "cn=admin,dc=example,dc=com", - DefaultBindPassword: "adminpassword", - DefaultUserFilter: "(uid=%s)", - DefaultEmailAttribute: "mail", - DefaultNameAttribute: "cn", - DefaultGroupAttribute: "memberOf", - SyncInterval: 300, - } -} - -// --- NewLDAPService tests --- - -func TestNewLDAPService_Disabled(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(false) - svc, err := NewLDAPService(cfg, rdb, db) - assert.NoError(t, err, "disabled LDAP should not error") - assert.NotNil(t, svc) -} - -func TestNewLDAPService_EnabledValidConfig(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(true) - svc, err := NewLDAPService(cfg, rdb, db) - assert.NoError(t, err, "valid LDAP config should not error") - assert.NotNil(t, svc) -} - -func TestNewLDAPService_MissingBaseDN(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(true) - cfg.DefaultHost = "ldap.example.com" - cfg.DefaultBaseDN = "" - _, err := NewLDAPService(cfg, rdb, db) - assert.ErrorIs(t, err, ErrLDAPInvalidConfig, "host set but empty BaseDN should error") -} - -func TestNewLDAPService_EmptyHost(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(true) - cfg.DefaultHost = "" - // Empty host with BaseDN set — should succeed (per-account config can override) - svc, err := NewLDAPService(cfg, rdb, db) - assert.NoError(t, err, "empty host is valid when BaseDN is also empty (defer to per-account)") - assert.NotNil(t, svc) -} - -// --- extractCNFromDN tests --- - -func TestExtractCNFromDN(t *testing.T) { - assert.Equal(t, "admins", extractCNFromDN("cn=admins,ou=groups,dc=example,dc=com")) - assert.Equal(t, "John Doe", extractCNFromDN("cn=John Doe,ou=people,dc=example,dc=com")) - assert.Equal(t, "user1", extractCNFromDN("CN=user1,dc=example,dc=com"), "case-insensitive CN prefix") -} - -func TestExtractCNFromDN_NoCN(t *testing.T) { - // No cn= prefix in DN — should return full DN as fallback - assert.Equal(t, "ou=groups,dc=example,dc=com", extractCNFromDN("ou=groups,dc=example,dc=com")) -} - -func TestExtractCNFromDN_Empty(t *testing.T) { - assert.Equal(t, "", extractCNFromDN("")) -} - -func TestExtractCNFromDN_SingleCN(t *testing.T) { - assert.Equal(t, "admin", extractCNFromDN("cn=admin")) -} - -// --- getAttr tests --- - -func TestGetAttr(t *testing.T) { - attrs := map[string]string{"mail": "user@example.com", "cn": "Test User"} - - assert.Equal(t, "user@example.com", getAttr(attrs, "mail", "fallback")) - assert.Equal(t, "Test User", getAttr(attrs, "cn", "fallback")) - assert.Equal(t, "fallback", getAttr(attrs, "missing", "fallback"), "missing key should return fallback") - assert.Equal(t, "", getAttr(attrs, "missing", ""), "missing key with empty fallback should return empty string") -} - -// --- MapLDAPGroupsToRoles tests --- - -func TestMapLDAPGroupsToRoles_AdminGroup(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"admins": "administrator", "developers": "agent"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "administrator", role, "admins group should map to administrator") -} - -func TestMapLDAPGroupsToRoles_MultipleGroups(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"admins": "administrator", "developers": "agent", "managers": "supervisor"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{ - "cn=developers,ou=groups,dc=example,dc=com", - "cn=admins,ou=groups,dc=example,dc=com", - }) - assert.Equal(t, "administrator", role, "highest privilege group should win") -} - -func TestMapLDAPGroupsToRoles_SupervisorAndAgent(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"managers": "supervisor", "developers": "agent"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{ - "cn=managers,ou=groups,dc=example,dc=com", - "cn=developers,ou=groups,dc=example,dc=com", - }) - assert.Equal(t, "supervisor", role, "supervisor should beat agent") -} - -func TestMapLDAPGroupsToRoles_NoMappings(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: nil, - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "agent", role, "no mappings should default to agent") -} - -func TestMapLDAPGroupsToRoles_EmptyMappings(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(``), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "agent", role, "empty mappings should default to agent") -} - -func TestMapLDAPGroupsToRoles_InvalidJSONMappings(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`invalid json`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "agent", role, "invalid JSON should default to agent") -} - -func TestMapLDAPGroupsToRoles_NoMatchingGroup(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"admins": "administrator"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=unknown,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "agent", role, "no matching group should default to agent") -} - -func TestMapLDAPGroupsToRoles_DirectDNMapping(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"cn=admins,ou=groups,dc=example,dc=com": "administrator"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"cn=admins,ou=groups,dc=example,dc=com"}) - assert.Equal(t, "administrator", role, "should match by full DN as well as CN") -} - -func TestMapLDAPGroupsToRoles_EmptyGroupsList(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"admins": "administrator"}`), - } - - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{}) - assert.Equal(t, "agent", role, "empty groups list should default to agent") -} - -func TestMapLDAPGroupsToRoles_AdminVsAdministrator(t *testing.T) { - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{"superadmins": "administrator", "admins": "admin"}`), - } - - // administrator (priority 4) > admin (priority 3) - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{ - "cn=admins,ou=groups,dc=example,dc=com", - "cn=superadmins,ou=groups,dc=example,dc=com", - }) - assert.Equal(t, "administrator", role, "administrator should beat admin in priority") -} - -// --- BER encoding tests --- - -func TestBerEncodeInteger(t *testing.T) { - // Zero - result := berEncodeInteger(0) - assert.Equal(t, []byte{0x02, 0x01, 0x00}, result, "zero integer encoding") - - // Small positive integer - result = berEncodeInteger(1) - assert.Equal(t, byte(0x02), result[0], "should have INTEGER tag") - assert.Equal(t, byte(0x01), result[1], "length should be 1") - assert.Equal(t, byte(0x01), result[2], "value should be 1") - - // Larger integer - result = berEncodeInteger(255) - assert.Equal(t, byte(0x02), result[0]) - // 255 = 0x00FF, needs 2 bytes (leading zero for positive sign) - assert.True(t, len(result) >= 3, "255 needs at least 3 bytes") -} - -func TestBerEncodeEnumerated(t *testing.T) { - result := berEncodeEnumerated(0) - assert.Equal(t, byte(0x0a), result[0], "ENUMERATED tag should be 0x0a") - assert.Equal(t, byte(0x01), result[1], "length should be 1") - assert.Equal(t, byte(0x00), result[2], "value should be 0") - - result = berEncodeEnumerated(2) - assert.Equal(t, byte(0x0a), result[0]) -} - -func TestBerEncodeOctetString(t *testing.T) { - // Empty string - result := berEncodeOctetString("") - assert.Equal(t, []byte{0x04, 0x00}, result, "empty string encoding") - - // Short string (< 128 chars) - result = berEncodeOctetString("hello") - assert.Equal(t, byte(0x04), result[0], "should have OCTET STRING tag") - assert.Equal(t, byte(0x05), result[1], "length should be 5") - assert.Equal(t, []byte("hello"), result[2:], "value should be 'hello'") - - // Long string (>= 128 chars) — uses long form length - longStr := string(make([]byte, 200)) - result = berEncodeOctetString(longStr) - assert.Equal(t, byte(0x04), result[0]) - // Long form: second byte has high bit set - assert.True(t, result[1]&0x80 != 0, "long form length should have high bit set") -} - -func TestBerEncodeBoolean(t *testing.T) { - result := berEncodeBoolean(true) - assert.Equal(t, []byte{0x01, 0x01, 0xFF}, result, "TRUE encoding") - - result = berEncodeBoolean(false) - assert.Equal(t, []byte{0x01, 0x01, 0x00}, result, "FALSE encoding") -} - -func TestBerEncodeSequence(t *testing.T) { - // Empty sequence - result := berEncodeSequence() - assert.Equal(t, []byte{0x30, 0x00}, result, "empty sequence encoding") - - // Sequence with one element - elem := berEncodeInteger(1) - result = berEncodeSequence(elem) - assert.Equal(t, byte(0x30), result[0], "SEQUENCE tag") - assert.Equal(t, byte(byte(len(elem))), result[1], "length should match element size") - - // Sequence with multiple elements - elem1 := berEncodeInteger(1) - elem2 := berEncodeOctetString("test") - result = berEncodeSequence(elem1, elem2) - assert.Equal(t, byte(0x30), result[0]) - expectedLen := len(elem1) + len(elem2) - assert.Equal(t, byte(expectedLen), result[1], "length should be sum of elements") -} - -func TestBerEncodeApplicationTag(t *testing.T) { - content := berEncodeOctetString("test") - result := berEncodeApplicationTag(0, content) - - // Application tag 0: class=01 (application), constructed=1, tag=0 → 0x60 - assert.Equal(t, byte(0x60), result[0], "Application tag 0 should be 0x60") - assert.Equal(t, byte(len(content)), result[1], "length should match content") -} - -func TestBerEncodeApplicationTag_HigherTag(t *testing.T) { - content := berEncodeOctetString("test") - result := berEncodeApplicationTag(3, content) - - // Application tag 3: 0x40 | 0x20 | 0x03 = 0x63 - assert.Equal(t, byte(0x63), result[0], "Application tag 3 should be 0x63") -} - -func TestBerEncodeContextTag(t *testing.T) { - content := berEncodeOctetString("test") - result := berEncodeContextTag(0, content) - - // Context tag 0: class=10 (context), constructed=1, tag=0 → 0xA0 - assert.Equal(t, byte(0xA0), result[0], "Context tag 0 should be 0xA0") - assert.Equal(t, byte(len(content)), result[1], "length should match content") -} - -func TestBerEncodeContextTag_HigherTag(t *testing.T) { - content := berEncodeOctetString("test") - result := berEncodeContextTag(3, content) - - // Context tag 3: 0x80 | 0x20 | 0x03 = 0xA3 - assert.Equal(t, byte(0xA3), result[0], "Context tag 3 should be 0xA3") -} - -// --- LDAP Bind Request encoding test --- - -func TestEncodeLDAPBindRequest(t *testing.T) { - result := encodeLDAPBindRequest(1, 3, "cn=admin,dc=example,dc=com", "password") - - // The result should start with a SEQUENCE (0x30) - assert.Equal(t, byte(0x30), result[0], "bind request should start with SEQUENCE") - - // Total length should be reasonable - assert.True(t, len(result) > 20, "bind request should have meaningful length") - - // Should contain the integer message ID (1) - // Inside the sequence: first element is messageID integer - assert.Equal(t, byte(0x02), result[2], "should contain INTEGER tag for messageID") -} - -func TestEncodeLDAPBindRequest_DifferentMessageID(t *testing.T) { - result1 := encodeLDAPBindRequest(1, 3, "cn=user1,dc=example,dc=com", "pass1") - result2 := encodeLDAPBindRequest(2, 3, "cn=user1,dc=example,dc=com", "pass1") - - // Different message IDs should produce different encodings - assert.NotEqual(t, result1, result2, "different message IDs should produce different encodings") -} - -// --- LDAP Search Request encoding test --- - -func TestEncodeLDAPSearchRequest(t *testing.T) { - result := encodeLDAPSearchRequest( - 2, // messageID - "dc=example,dc=com", // baseDN - 2, // scope (wholeSubtree) - 0, // derefAliases (never) - 0, // sizeLimit - 0, // timeLimit - false, // typesOnly - "(uid=testuser)", // filter - []string{"mail", "cn"}, // attributes - ) - - // Should start with SEQUENCE - assert.Equal(t, byte(0x30), result[0], "search request should start with SEQUENCE") - assert.True(t, len(result) > 30, "search request should have meaningful length") -} - -func TestEncodeLDAPSearchRequest_EmptyAttributes(t *testing.T) { - result := encodeLDAPSearchRequest( - 1, "dc=example,dc=com", 0, 0, 0, 0, false, "(objectClass=*)", []string{}, - ) - - assert.Equal(t, byte(0x30), result[0]) - assert.True(t, len(result) > 10, "even empty attributes should produce valid encoding") -} - -// --- LDAP error sentinel tests --- - -func TestLDAPErrorSentinels(t *testing.T) { - assert.Equal(t, "ldap authentication is not enabled", ErrLDAPDisabled.Error()) - assert.Equal(t, "ldap configuration is invalid", ErrLDAPInvalidConfig.Error()) - assert.Equal(t, "failed to connect to ldap server", ErrLDAPConnection.Error()) - assert.Equal(t, "ldap bind authentication failed", ErrLDAPBindFailed.Error()) - assert.Equal(t, "ldap user not found", ErrLDAPUserNotFound.Error()) - assert.Equal(t, "ldap search failed", ErrLDAPSearchFailed.Error()) -} - -// --- LDAP Authenticate tests (disabled) --- - -func TestLDAPService_Authenticate_Disabled(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(false) - svc, err := NewLDAPService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.Authenticate(context.Background(), 1, "uid", "password") - assert.ErrorIs(t, err, ErrLDAPDisabled, "disabled service should reject authentication") -} - -// --- LDAP SyncGroups tests (disabled) --- - -func TestLDAPService_SyncGroups_Disabled(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - cfg := newTestLDAPConfig(false) - svc, err := NewLDAPService(cfg, rdb, db) - require.NoError(t, err) - - err = svc.SyncGroups(nil, 1) - assert.ErrorIs(t, err, ErrLDAPDisabled, "disabled service should reject sync") -} - -// --- LDAP per-account settings tests --- - -func TestLDAPService_WithPerAccountSettings(t *testing.T) { - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer rdb.Close() - - db := newTestLDAPDB(t) - - account := &model.Account{Name: "Test Corp", Active: true} - require.NoError(t, db.Create(account).Error) - - settings := &model.AccountLDAPSettings{ - AccountID: account.ID, - Host: "ldap.corp.example.com", - Port: 636, - UseTLS: true, - BaseDN: "dc=corp,dc=example,dc=com", - BindDN: "cn=admin,dc=corp,dc=example,dc=com", - BindPassword: "corppassword", - UserFilter: "(uid=%s)", - EmailAttribute: "mail", - NameAttribute: "cn", - GroupAttribute: "memberOf", - RoleMappings: json.RawMessage(`{"admins": "administrator"}`), - AutoProvision: true, - Active: true, - } - require.NoError(t, db.Create(settings).Error) - - cfg := newTestLDAPConfig(true) - svc, err := NewLDAPService(cfg, rdb, db) - require.NoError(t, err) - assert.NotNil(t, svc) -} - -// --- LDAPUserInfo struct test --- - -func TestLDAPUserInfo(t *testing.T) { - info := LDAPUserInfo{ - DN: "cn=testuser,ou=people,dc=example,dc=com", - Email: "testuser@example.com", - DisplayName: "Test User", - FirstName: "Test", - LastName: "User", - Groups: []string{"cn=admins,ou=groups,dc=example,dc=com"}, - Attributes: map[string]string{"mail": "testuser@example.com", "cn": "Test User"}, - } - - assert.Equal(t, "cn=testuser,ou=people,dc=example,dc=com", info.DN) - assert.Equal(t, "testuser@example.com", info.Email) - assert.Equal(t, []string{"cn=admins,ou=groups,dc=example,dc=com"}, info.Groups) - assert.Equal(t, "Test User", info.Attributes["cn"]) -} - -// --- LDAP role priority constants verification --- - -func TestLDAPRolePriority(t *testing.T) { - // Verify role priority is: administrator > admin > supervisor > agent - // via MapLDAPGroupsToRoles behavior - settings := &model.AccountLDAPSettings{ - RoleMappings: json.RawMessage(`{ - "grp1": "administrator", - "grp2": "admin", - "grp3": "supervisor", - "grp4": "agent" - }`), - } - - // administrator should win over all - role := (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp1", "grp2", "grp3", "grp4"}) - assert.Equal(t, "administrator", role) - - // admin should win over supervisor and agent - role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp2", "grp3", "grp4"}) - assert.Equal(t, "admin", role) - - // supervisor should win over agent - role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp3", "grp4"}) - assert.Equal(t, "supervisor", role) - - // agent alone - role = (&LDAPService{}).MapLDAPGroupsToRoles(settings, []string{"grp4"}) - assert.Equal(t, "agent", role) -} \ No newline at end of file diff --git a/backend/internal/auth/mfa.go b/backend/internal/auth/mfa.go deleted file mode 100644 index dd0ad918..00000000 --- a/backend/internal/auth/mfa.go +++ /dev/null @@ -1,390 +0,0 @@ -package auth - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha1" - "encoding/base32" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "github.com/gochat/gochat/internal/model" - pkgcrypto "github.com/gochat/gochat/pkg/crypto" - "gorm.io/gorm" -) - -const mfaBackupCodesAttribute = "mfa_backup_code_hashes" - -// Reference: P2E §1.5 — MFA (TOTP) support -// Implements time-based one-time password (TOTP) per RFC 6238. -// Corresponds to Chatwoot enterprise TwoFactorAuthController pattern. - -// MFAService manages multi-factor authentication using TOTP. -type MFAService struct { - db *gorm.DB -} - -// NewMFAService creates a MFA service backed by GORM. -func NewMFAService(db *gorm.DB) *MFAService { - return &MFAService{db: db} -} - -// TOTPConfig holds TOTP algorithm parameters. -// Standard parameters per RFC 6238: SHA-1, 30-second step, 6 digits. -type TOTPConfig struct { - Period uint64 // time step in seconds (default: 30) - Digits int // number of digits (default: 6) - Algorithm string // hash algorithm (default: SHA1) - Issuer string // issuer name for QR code (default: GoChat) -} - -// DefaultTOTPConfig returns standard TOTP parameters. -func DefaultTOTPConfig() TOTPConfig { - return TOTPConfig{ - Period: 30, - Digits: 6, - Algorithm: "SHA1", - Issuer: "GoChat", - } -} - -// GenerateTOTPSecret creates a random base32-encoded TOTP secret for a user. -// The secret is 160 bits (20 bytes) per RFC 4226 recommendation. -func (s *MFAService) GenerateTOTPSecret(userID uint) (string, string, error) { - // Generate 20 random bytes for 160-bit secret - secretBytes := make([]byte, 20) - if _, err := rand.Read(secretBytes); err != nil { - return "", "", fmt.Errorf("failed to generate random secret: %w", err) - } - - // Encode as base32 (uppercase, no padding per RFC 4648) - secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes) - - // Look up user email for QR code URI - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return "", "", fmt.Errorf("user not found: %w", err) - } - - // Generate otpauth URI for QR code scanning - cfg := DefaultTOTPConfig() - uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d", - cfg.Issuer, - user.Email, - secret, - cfg.Issuer, - cfg.Algorithm, - cfg.Digits, - cfg.Period, - ) - - return secret, uri, nil -} - -// EnableTOTP stores the TOTP secret for a user after successful verification. -// This is a two-step process: user must verify a TOTP code before enabling. -func (s *MFAService) EnableTOTP(userID uint, secret string) error { - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return fmt.Errorf("user not found: %w", err) - } - - user.TOTPSecret = secret - user.TOTPEnabled = true - - if err := s.db.Save(&user).Error; err != nil { - return fmt.Errorf("failed to enable totp: %w", err) - } - - return nil -} - -// BeginTOTPSetup creates and stores a pending TOTP secret for Chatwoot's -// profile MFA setup flow. The user is activated only after VerifyAndActivateTOTP. -func (s *MFAService) BeginTOTPSetup(userID uint) (string, string, error) { - secret, uri, err := s.GenerateTOTPSecret(userID) - if err != nil { - return "", "", err - } - - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return "", "", fmt.Errorf("user not found: %w", err) - } - user.TOTPSecret = secret - user.TOTPEnabled = false - if err := s.db.Save(&user).Error; err != nil { - return "", "", fmt.Errorf("failed to store pending totp secret: %w", err) - } - return secret, uri, nil -} - -// VerifyAndActivateTOTP validates the pending profile MFA code, enables MFA, -// and returns the one-time backup codes expected by Chatwoot's verify response. -func (s *MFAService) VerifyAndActivateTOTP(userID uint, code string) ([]string, error) { - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - if user.TOTPSecret == "" { - return nil, fmt.Errorf("mfa setup not started for user") - } - if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) { - return nil, fmt.Errorf("invalid totp code") - } - user.TOTPEnabled = true - if err := s.db.Save(&user).Error; err != nil { - return nil, fmt.Errorf("failed to enable totp: %w", err) - } - return s.GenerateBackupCodes(userID) -} - -// VerifyTOTPCode validates a TOTP code against the user's stored secret. -// Uses a 1-period window (±30 seconds) to account for clock drift per RFC 6238. -func (s *MFAService) VerifyTOTPCode(userID uint, code string) (bool, error) { - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return false, fmt.Errorf("user not found: %w", err) - } - - if !user.TOTPEnabled || user.TOTPSecret == "" { - return false, fmt.Errorf("mfa not enabled for user") - } - - cfg := DefaultTOTPConfig() - return validateTOTP(user.TOTPSecret, code, cfg), nil -} - -// DisableTOTP removes TOTP configuration for a user. -// Requires verification of current TOTP code before disabling. -func (s *MFAService) DisableTOTP(userID uint, code string) error { - // Verify current code before allowing disable - valid, err := s.VerifyTOTPCode(userID, code) - if err != nil { - return fmt.Errorf("verification failed: %w", err) - } - if !valid { - return fmt.Errorf("invalid totp code") - } - - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return fmt.Errorf("user not found: %w", err) - } - - user.TOTPSecret = "" - user.TOTPEnabled = false - - if err := s.db.Save(&user).Error; err != nil { - return fmt.Errorf("failed to disable totp: %w", err) - } - - return nil -} - -// DisableTOTPWithPassword mirrors Chatwoot profile MFA destroy: the current -// password and either an OTP code or a backup code must be provided. -func (s *MFAService) DisableTOTPWithPassword(userID uint, password, code, backupCode string) error { - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return fmt.Errorf("user not found: %w", err) - } - if !user.TOTPEnabled || user.TOTPSecret == "" { - return fmt.Errorf("mfa not enabled for user") - } - if !pkgcrypto.CheckPassword(password, user.PasswordDigest) && !pkgcrypto.CheckPassword(password, user.Password) { - return fmt.Errorf("invalid credentials") - } - if backupCode != "" { - if err := s.consumeBackupCode(&user, backupCode); err != nil { - return err - } - } else if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) { - return fmt.Errorf("invalid totp code") - } - - user.TOTPSecret = "" - user.TOTPEnabled = false - if err := s.db.Save(&user).Error; err != nil { - return fmt.Errorf("failed to disable totp: %w", err) - } - return nil -} - -// IsMFAEnabled checks whether MFA is enabled for a user. -func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) { - var user model.User - if err := s.db.Select("totp_enabled").First(&user, userID).Error; err != nil { - return false, fmt.Errorf("user not found: %w", err) - } - return user.TOTPEnabled, nil -} - -// BackupCodesGenerated reports whether the user currently has MFA backup codes. -func (s *MFAService) BackupCodesGenerated(userID uint) (bool, error) { - var user model.User - if err := s.db.Select("custom_attributes").First(&user, userID).Error; err != nil { - return false, fmt.Errorf("user not found: %w", err) - } - codes := backupCodeHashes(user.CustomAttributes) - return len(codes) > 0, nil -} - -// validateTOTP validates a TOTP code against a secret using the given config. -// Allows ±1 period window for clock drift tolerance. -func validateTOTP(secret string, code string, cfg TOTPConfig) bool { - now := time.Now().Unix() - period := int64(cfg.Period) - - // Check current period and ±1 period for clock drift - for offset := -1; offset <= 1; offset++ { - t := (now + int64(offset)*period) / period - expected := generateTOTP(secret, t, cfg) - if expected == strings.TrimSpace(code) { - return true - } - } - - return false -} - -// generateTOTP generates a TOTP code for the given time counter. -// Implements RFC 6238 algorithm: HMAC-SHA1 with time-based counter. -func generateTOTP(secret string, timeCounter int64, cfg TOTPConfig) string { - // Decode base32 secret - key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) - if err != nil { - return "" - } - - // Encode time counter as 8-byte big-endian - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, uint64(timeCounter)) - - // HMAC-SHA1 - h := hmac.New(sha1.New, key) - h.Write(buf) - hash := h.Sum(nil) - - // Dynamic truncation per RFC 4226 - offset := hash[len(hash)-1] & 0x0f - truncated := (int32(hash[offset]&0x7f) << 24) | - (int32(hash[offset+1]&0xff) << 16) | - (int32(hash[offset+2]&0xff) << 8) | - (int32(hash[offset+3] & 0xff)) - - // Modulo 10^digits - mod := int32(math.Pow10(cfg.Digits)) - code := truncated % mod - - // Format with leading zeros to achieve correct digit count - return fmt.Sprintf("%0*d", cfg.Digits, code) -} - -// ValidateTOTPCode is a public helper that validates a TOTP code against a given secret. -// Used by handlers to verify TOTP codes during MFA setup before enabling on the user. -func ValidateTOTPCode(secret string, code string, cfg TOTPConfig) bool { - return validateTOTP(secret, code, cfg) -} - -// --- math.Pow10 helper --- -func init() { - // Ensure math package is linked - _ = math.E -} - -// GenerateBackupCodes creates a set of one-time backup codes for MFA recovery. -// Reference: Chatwoot MfaController#backup_codes -func (s *MFAService) GenerateBackupCodes(userID uint) ([]string, error) { - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - var codes []string - var hashes []string - for i := 0; i < 10; i++ { - code := cryptoRandomString(8) - codes = append(codes, code) - hash, err := pkgcrypto.HashPassword(code) - if err != nil { - return nil, fmt.Errorf("failed to hash backup code: %w", err) - } - hashes = append(hashes, hash) - } - attrs := customAttributesMap(user.CustomAttributes) - attrs[mfaBackupCodesAttribute] = hashes - encoded, err := json.Marshal(attrs) - if err != nil { - return nil, fmt.Errorf("failed to encode backup codes: %w", err) - } - user.CustomAttributes = encoded - if err := s.db.Save(&user).Error; err != nil { - return nil, fmt.Errorf("failed to store backup codes: %w", err) - } - return codes, nil -} - -func (s *MFAService) consumeBackupCode(user *model.User, code string) error { - hashes := backupCodeHashes(user.CustomAttributes) - for i, hash := range hashes { - if pkgcrypto.CheckPassword(code, hash) { - hashes = append(hashes[:i], hashes[i+1:]...) - attrs := customAttributesMap(user.CustomAttributes) - attrs[mfaBackupCodesAttribute] = hashes - encoded, err := json.Marshal(attrs) - if err != nil { - return fmt.Errorf("failed to encode backup codes: %w", err) - } - user.CustomAttributes = encoded - if err := s.db.Save(user).Error; err != nil { - return fmt.Errorf("failed to consume backup code: %w", err) - } - return nil - } - } - return fmt.Errorf("invalid backup code") -} - -func backupCodeHashes(raw []byte) []string { - attrs := customAttributesMap(raw) - value, ok := attrs[mfaBackupCodesAttribute] - if !ok { - return nil - } - items, ok := value.([]any) - if !ok { - return nil - } - hashes := make([]string, 0, len(items)) - for _, item := range items { - if text, ok := item.(string); ok && text != "" { - hashes = append(hashes, text) - } - } - return hashes -} - -func customAttributesMap(raw []byte) map[string]any { - attrs := map[string]any{} - if len(raw) == 0 { - return attrs - } - _ = json.Unmarshal(raw, &attrs) - return attrs -} - -// cryptoRandomString generates a random alphanumeric string of given length. -func cryptoRandomString(length int) string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, length) - for i := range b { - buf := make([]byte, 1) - _, _ = rand.Read(buf) - b[i] = charset[int(buf[0])%len(charset)] - } - return string(b) -} diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index 303f6aa3..0c9bd4ac 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -5,8 +5,7 @@ package auth // and role/claim mapping for enterprise identity providers. // Supports per-account OIDC configuration for multi-tenant identity isolation. // -// Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding -// OIDC support for modern enterprise IdPs (Google Workspace, Auth0, Keycloak, +// Enterprise feature: GoChat provides OIDC support for modern enterprise IdPs (Google Workspace, Auth0, Keycloak, // Azure AD, Okta, and any OIDC-compliant provider). // // Authentication flow (Authorization Code + PKCE): diff --git a/backend/internal/auth/saml.go b/backend/internal/auth/saml.go deleted file mode 100644 index 454c4595..00000000 --- a/backend/internal/auth/saml.go +++ /dev/null @@ -1,1171 +0,0 @@ -package auth - -// Reference: P2E §1.6 — SAML 2.0 Service Provider integration -// Pure Go implementation using encoding/xml for SAML XML parsing. -// No external SAML library dependency — all XML parsing, request generation, -// and response validation implemented with Go stdlib. -// Enterprise SSO feature: SP-initiated SSO with IdP metadata, ACS endpoint, -// attribute mapping, and replay attack prevention. - -import ( - "compress/flate" - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "encoding/xml" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "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" -) - -// SAML errors -var ( - ErrSAMLEnabled = errors.New("saml authentication is not enabled") - ErrSAMLInvalidConfig = errors.New("saml configuration is invalid") - ErrSAMLInvalidResponse = errors.New("saml response validation failed") - ErrSAMLReplay = errors.New("saml response replay detected") - ErrSAMLMissingNameID = errors.New("saml response missing NameID") - ErrSAMLMissingEmail = errors.New("saml response missing email attribute") - ErrSAMLIdPMetadata = errors.New("failed to load IdP metadata") -) - -// SAMLUserInfo represents user info extracted from a SAML assertion. -type SAMLUserInfo struct { - NameID string // Subject NameID — unique identifier from IdP - Email string // Mapped from SAML attribute - DisplayName string // Mapped from SAML attribute - FirstName string // Mapped from SAML attribute - LastName string // Mapped from SAML attribute - Attributes map[string]string // All SAML attributes for flexibility -} - -// IdPMetadata holds parsed IdP metadata fields. -type IdPMetadata struct { - EntityID string // IdP entity ID - SSOURL string // SingleSignOnService redirect binding URL - SLORedirectURL string // SingleLogoutService redirect binding URL - SLOPostURL string // SingleLogoutService POST binding URL - ACSURL string // Assertion Consumer Service URL (from config, not IdP metadata) - Certificates []string // PEM-encoded X.509 certificates from IdP metadata -} - -// SAMLService provides SAML 2.0 SP authentication. -type SAMLService struct { - cfg *config.SAMLConfig - idpMetadata *IdPMetadata - rdb redis.Cmdable - db *gorm.DB -} - -// GetIdPEntityID returns the configured IdP entity ID. -// Used by the ACS handler to populate SSO session data. -func (s *SAMLService) GetIdPEntityID() string { - if s.idpMetadata != nil { - return s.idpMetadata.EntityID - } - return "" -} - -// --- SAML XML types for encoding/xml parsing/generation --- -// These are local types that map to SAML XML namespaces using xml.Name. - -// SamlpNamespace is the SAML protocol namespace. -const SamlpNamespace = "urn:oasis:names:tc:SAML:2.0:protocol" -const SamlNamespace = "urn:oasis:names:tc:SAML:2.0:assertion" -const MDNamespace = "urn:oasis:names:tc:SAML:2.0:metadata" - -// --- IdP Metadata XML types --- - -// EntityDescriptorXML represents a SAML metadata EntityDescriptor. -type EntityDescriptorXML struct { - XMLName xml.Name `xml:"EntityDescriptor"` - EntityID string `xml:"entityID,attr"` - IDPSSODescriptor *IDPSSODescriptorXML `xml:"IDPSSODescriptor"` -} - -// IDPSSODescriptorXML represents an IDPSSODescriptor element. -type IDPSSODescriptorXML struct { - XMLName xml.Name `xml:"IDPSSODescriptor"` - NameIDFormats []NameIDFormatXML `xml:"NameIDFormat"` - SingleSignOnServices []SingleSignOnServiceXML `xml:"SingleSignOnService"` - SingleLogoutServices []SingleLogoutServiceXML `xml:"SingleLogoutService"` - KeyDescriptors []KeyDescriptorXML `xml:"KeyDescriptor"` -} - -// SingleLogoutServiceXML represents a SingleLogoutService element in IdP metadata. -type SingleLogoutServiceXML struct { - XMLName xml.Name `xml:"SingleLogoutService"` - Binding string `xml:"Binding,attr"` - Location string `xml:"Location,attr"` -} - -// NameIDFormatXML represents a NameIDFormat element. -type NameIDFormatXML struct { - XMLName xml.Name `xml:"NameIDFormat"` - Value string `xml:",chardata"` -} - -// SingleSignOnServiceXML represents a SingleSignOnService element. -type SingleSignOnServiceXML struct { - XMLName xml.Name `xml:"SingleSignOnService"` - Binding string `xml:"Binding,attr"` - Location string `xml:"Location,attr"` -} - -// KeyDescriptorXML represents a KeyDescriptor element in metadata. -type KeyDescriptorXML struct { - XMLName xml.Name `xml:"KeyDescriptor"` - Use string `xml:"use,attr"` - KeyInfo X509KeyInfoXML `xml:"KeyInfo"` -} - -// X509KeyInfoXML represents a KeyInfo element with X509 data. -type X509KeyInfoXML struct { - XMLName xml.Name `xml:"KeyInfo"` - X509Data X509DataXML `xml:"X509Data"` -} - -// X509DataXML represents X509Data element. -type X509DataXML struct { - XMLName xml.Name `xml:"X509Data"` - X509Certificates []X509CertXML `xml:"X509Certificate"` -} - -// X509CertXML represents a base64-encoded X509 certificate. -type X509CertXML struct { - XMLName xml.Name `xml:"X509Certificate"` - Value string `xml:",chardata"` -} - -// --- SAML Response XML types --- - -// SAMLResponseXML represents a SAML Response element. -type SAMLResponseXML struct { - XMLName xml.Name `xml:"Response"` - ID string `xml:"ID,attr"` - InResponseTo string `xml:"InResponseTo,attr"` - IssueInstant string `xml:"IssueInstant,attr"` - Destination string `xml:"Destination,attr"` - Assertions []SAMLAssertionXML `xml:"Assertion"` -} - -// SAMLAssertionXML represents a SAML Assertion element. -type SAMLAssertionXML struct { - XMLName xml.Name `xml:"Assertion"` - ID string `xml:"ID,attr"` - IssueInstant string `xml:"IssueInstant,attr"` - Subject SAMLSubjectXML `xml:"Subject"` - Conditions SAMLConditionsXML `xml:"Conditions"` - AttributeStatements []SAMLAttributeStatementXML `xml:"AttributeStatement"` -} - -// SAMLSubjectXML represents a SAML Subject element. -type SAMLSubjectXML struct { - XMLName xml.Name `xml:"Subject"` - NameID SAMLNameIDXML `xml:"NameID"` -} - -// SAMLNameIDXML represents a SAML NameID element. -type SAMLNameIDXML struct { - XMLName xml.Name `xml:"NameID"` - Format string `xml:"Format,attr"` - Value string `xml:",chardata"` -} - -// SAMLConditionsXML represents SAML Conditions. -type SAMLConditionsXML struct { - XMLName xml.Name `xml:"Conditions"` - NotBefore string `xml:"NotBefore,attr"` - NotOnOrAfter string `xml:"NotOnOrAfter,attr"` - AudienceRestrictions []SAMLAudienceRestrictionXML `xml:"AudienceRestriction"` -} - -// SAMLAudienceRestrictionXML represents an AudienceRestriction element. -type SAMLAudienceRestrictionXML struct { - XMLName xml.Name `xml:"AudienceRestriction"` - Audiences []SAMLAudienceXML `xml:"Audience"` -} - -// SAMLAudienceXML represents an Audience element. -type SAMLAudienceXML struct { - XMLName xml.Name `xml:"Audience"` - Value string `xml:",chardata"` -} - -// SAMLAttributeStatementXML represents an AttributeStatement element. -type SAMLAttributeStatementXML struct { - XMLName xml.Name `xml:"AttributeStatement"` - Attributes []SAMLAttributeXML `xml:"Attribute"` -} - -// SAMLAttributeXML represents a SAML Attribute element. -type SAMLAttributeXML struct { - XMLName xml.Name `xml:"Attribute"` - Name string `xml:"Name,attr"` - FriendlyName string `xml:"FriendlyName,attr"` - Values []SAMLAttributeValueXML `xml:"AttributeValue"` -} - -// SAMLAttributeValueXML represents a SAML AttributeValue element. -type SAMLAttributeValueXML struct { - XMLName xml.Name `xml:"AttributeValue"` - Value string `xml:",chardata"` -} - -// --- SP Metadata XML types (for generation) --- - -// SPEntityDescriptorXML represents an SP EntityDescriptor for metadata generation. -type SPEntityDescriptorXML struct { - XMLName xml.Name `xml:"EntityDescriptor"` - EntityID string `xml:"entityID,attr"` - SPSSODescriptor *SPSSODescriptorXML `xml:"SPSSODescriptor"` -} - -// SPSSODescriptorXML represents an SPSSODescriptor for metadata generation. -type SPSSODescriptorXML struct { - XMLName xml.Name `xml:"SPSSODescriptor"` - AuthnRequestsSigned string `xml:"authnRequestsSigned,attr"` - WantAssertionsSigned string `xml:"wantAssertionsSigned,attr"` - ProtocolSupportEnum string `xml:"protocolSupportEnumeration,attr"` - NameIDFormats []SPNameIDFormatXML `xml:"NameIDFormat"` - AssertionConsumerServices []SPAssertionConsumerServiceXML `xml:"AssertionConsumerService"` - KeyDescriptors []SPKeyDescriptorXML `xml:"KeyDescriptor"` -} - -// SPNameIDFormatXML represents a NameIDFormat for SP metadata. -type SPNameIDFormatXML struct { - XMLName xml.Name `xml:"NameIDFormat"` - Value string `xml:",chardata"` -} - -// SPAssertionConsumerServiceXML represents an AssertionConsumerService for SP metadata. -type SPAssertionConsumerServiceXML struct { - XMLName xml.Name `xml:"AssertionConsumerService"` - Binding string `xml:"Binding,attr"` - Location string `xml:"Location,attr"` - Index int `xml:"index,attr"` -} - -// SPKeyDescriptorXML represents a KeyDescriptor for SP metadata. -type SPKeyDescriptorXML struct { - XMLName xml.Name `xml:"KeyDescriptor"` - Use string `xml:"use,attr"` - KeyInfo SPKeyInfoXML `xml:"KeyInfo"` -} - -// SPKeyInfoXML represents KeyInfo for SP metadata. -type SPKeyInfoXML struct { - XMLName xml.Name `xml:"KeyInfo"` - X509Data SPX509DataXML `xml:"X509Data"` -} - -// SPX509DataXML represents X509Data for SP metadata. -type SPX509DataXML struct { - XMLName xml.Name `xml:"X509Data"` - X509Certificates []SPX509CertXML `xml:"X509Certificate"` -} - -// SPX509CertXML represents an X509Certificate for SP metadata. -type SPX509CertXML struct { - XMLName xml.Name `xml:"X509Certificate"` - Value string `xml:",chardata"` -} - -// --- AuthnRequest XML type (for generation) --- - -// AuthnRequestXML represents a SAML AuthnRequest for SP-initiated login. -type AuthnRequestXML struct { - XMLName xml.Name `xml:"AuthnRequest"` - ID string `xml:"ID,attr"` - Version string `xml:"Version,attr"` - IssueInstant string `xml:"IssueInstant,attr"` - Destination string `xml:"Destination,attr"` - AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"` - Issuer SAMLIssuerXML `xml:"Issuer"` -} - -// SAMLIssuerXML represents a SAML Issuer element. -type SAMLIssuerXML struct { - XMLName xml.Name `xml:"Issuer"` - Value string `xml:",chardata"` -} - -// --- Public API --- - -// NewSAMLService creates a SAML SP service from config, Redis, and database. -// When SAML is disabled, returns a minimal service that rejects all operations. -func NewSAMLService(cfg *config.SAMLConfig, rdb redis.Cmdable, db *gorm.DB) (*SAMLService, error) { - if !cfg.Enabled { - applogger.L().Info("SAML service created (disabled)") - return &SAMLService{cfg: cfg, rdb: rdb, db: db}, nil - } - - // Validate required fields for enabled SAML - if cfg.SPEntityID == "" { - return nil, ErrSAMLInvalidConfig - } - if cfg.ACSURL == "" { - return nil, ErrSAMLInvalidConfig - } - if cfg.IdPMetadataURL == "" && cfg.IdPMetadataXML == "" { - return nil, ErrSAMLIdPMetadata - } - - // Load and parse IdP metadata - idpMetadata, err := loadIdPMetadata(cfg) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrSAMLIdPMetadata, err) - } - - applogger.L().Infof("SAML service initialized (idpEntityID=%s, ssoURL=%s)", idpMetadata.EntityID, idpMetadata.SSOURL) - return &SAMLService{ - cfg: cfg, - idpMetadata: idpMetadata, - rdb: rdb, - db: db, - }, nil -} - -// InitiateLogin generates a SAML AuthnRequest redirect URL for SP-initiated SSO. -// The returned URL includes SAMLRequest (base64-encoded AuthnRequest) and RelayState. -// Stores InResponseTo in Redis for replay validation (5 min TTL). -func (s *SAMLService) InitiateLogin(state string) (string, error) { - if !s.cfg.Enabled || s.idpMetadata == nil { - return "", ErrSAMLEnabled - } - - // Generate AuthnRequest - requestID := fmt.Sprintf("id_%s", generateRandomID()) - now := time.Now().UTC().Format(time.RFC3339) - - authnRequest := AuthnRequestXML{ - ID: requestID, - Version: "2.0", - IssueInstant: now, - Destination: s.idpMetadata.SSOURL, - AssertionConsumerServiceURL: s.cfg.ACSURL, - Issuer: SAMLIssuerXML{ - Value: s.cfg.SPEntityID, - }, - } - - // Marshal to XML - xmlBytes, err := xml.Marshal(authnRequest) - if err != nil { - return "", fmt.Errorf("failed to marshal AuthnRequest: %w", err) - } - - // Wrap in SAML protocol envelope - xmlStr := fmt.Sprintf("%s", string(xmlBytes)) - - // Deflate + base64 encode for redirect binding - encoded, err := deflateAndBase64Encode(xmlStr) - if err != nil { - return "", fmt.Errorf("failed to encode AuthnRequest: %w", err) - } - - // Build redirect URL - redirectURL, err := url.Parse(s.idpMetadata.SSOURL) - if err != nil { - return "", fmt.Errorf("invalid IdP SSO URL: %w", err) - } - q := redirectURL.Query() - q.Set("SAMLRequest", encoded) - q.Set("RelayState", state) - redirectURL.RawQuery = q.Encode() - - // Store InResponseTo in Redis for replay validation (5 min TTL) - if s.rdb != nil { - ctx := context.Background() - key := fmt.Sprintf("saml:inResponseTo:%s", requestID) - if err := s.rdb.Set(ctx, key, state, 5*time.Minute).Err(); err != nil { - applogger.L().Warnf("Failed to store SAML InResponseTo in Redis: %v", err) - // Non-critical: continue without Redis tracking - } - } - - applogger.L().Infof("SAML AuthnRequest generated (id=%s, destination=%s)", requestID, s.idpMetadata.SSOURL) - return redirectURL.String(), nil -} - -// ProcessResponse validates a SAML Response (base64-encoded XML from IdP), -// extracts NameID + attributes, validates conditions and replay attacks. -// Returns SAMLUserInfo on success. -func (s *SAMLService) ProcessResponse(samlResponse string) (*SAMLUserInfo, error) { - if !s.cfg.Enabled || s.idpMetadata == nil { - return nil, ErrSAMLEnabled - } - - // Step 1: Base64-decode the SAMLResponse - xmlBytes, err := base64.StdEncoding.DecodeString(samlResponse) - if err != nil { - return nil, fmt.Errorf("%w: failed to base64 decode response: %v", ErrSAMLInvalidResponse, err) - } - - // Step 2: Parse XML - response, err := parseSAMLResponseXML(xmlBytes) - if err != nil { - return nil, fmt.Errorf("%w: failed to parse response XML: %v", ErrSAMLInvalidResponse, err) - } - - // Step 3: Extract assertion (first assertion) - if len(response.Assertions) == 0 { - return nil, fmt.Errorf("%w: response contains no assertions", ErrSAMLInvalidResponse) - } - assertion := response.Assertions[0] - - // Step 4: Validate conditions - if err := validateConditions(&assertion.Conditions, s.cfg.SPEntityID, time.Now(), s.cfg.ClockDriftDuration()); err != nil { - return nil, fmt.Errorf("%w: conditions validation failed: %v", ErrSAMLInvalidResponse, err) - } - - // Step 5: Check replay (response ID must not have been used before) - if err := checkAndTrackReplay(response.ID, s.rdb); err != nil { - return nil, err - } - - // Step 6: Extract NameID - nameID := assertion.Subject.NameID.Value - if nameID == "" { - return nil, ErrSAMLMissingNameID - } - - // Step 7: Extract attributes - attrs := extractAttributesFromXML(assertion.AttributeStatements) - attrMap := s.cfg.AttributeMap - - userInfo := &SAMLUserInfo{ - NameID: nameID, - Attributes: attrs, - } - - userInfo.Email = getAttributeFromXML(attrs, attrMap.Email) - userInfo.DisplayName = getAttributeFromXML(attrs, attrMap.DisplayName) - userInfo.FirstName = getAttributeFromXML(attrs, attrMap.FirstName) - userInfo.LastName = getAttributeFromXML(attrs, attrMap.LastName) - - // If DisplayName not found, compose from FirstName + LastName - if userInfo.DisplayName == "" && (userInfo.FirstName != "" || userInfo.LastName != "") { - userInfo.DisplayName = strings.TrimSpace(userInfo.FirstName + " " + userInfo.LastName) - } - - // Email is required for user mapping - if userInfo.Email == "" { - // Fall back to NameID as email if it looks like an email - if strings.Contains(nameID, "@") { - userInfo.Email = nameID - } else { - return nil, ErrSAMLMissingEmail - } - } - - applogger.L().Infof("SAML auth successful (nameID=%s, email=%s)", nameID, userInfo.Email) - return userInfo, nil -} - -// GetSPMetadata returns the SP XML metadata for IdP configuration. -// This endpoint allows IdP admins to import our SP metadata. -func (s *SAMLService) GetSPMetadata() ([]byte, error) { - if !s.cfg.Enabled || s.idpMetadata == nil { - return nil, ErrSAMLEnabled - } - - spMetadata := generateSPMetadataXML(s.cfg) - xmlBytes, err := xml.MarshalIndent(spMetadata, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal SP metadata: %w", err) - } - - return []byte(fmt.Sprintf("\n%s", string(xmlBytes))), nil -} - -// FindOrCreateUser maps SAML user info to a GoChat User model. -// Looks up user by (provider=saml, uid=nameID) or by email. -// Creates a new user if not found (auto-provisioning). -func (s *SAMLService) FindOrCreateUser(userInfo *SAMLUserInfo) (*model.User, error) { - if s.db == nil { - return nil, errors.New("database not available for SAML user lookup") - } - - var user model.User - - // First: try to find by provider + UID (SAML NameID) - err := s.db.Where("provider = ? AND uid = ?", "saml", userInfo.NameID).First(&user).Error - if err == nil { - // Update user attributes from latest SAML assertion - user.Name = userInfo.DisplayName - user.Email = userInfo.Email - user.AvatarURL = userInfo.Attributes["avatarURL"] - if err := s.db.Save(&user).Error; err != nil { - return nil, fmt.Errorf("failed to update SAML user: %w", err) - } - return &user, nil - } - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to query SAML user: %w", err) - } - - // Second: try to find by email (link existing account to SAML) - err = s.db.Where("email = ?", userInfo.Email).First(&user).Error - if err == nil { - // Link existing account to SAML provider - user.Provider = "saml" - user.UID = userInfo.NameID - user.Name = userInfo.DisplayName - if err := s.db.Save(&user).Error; err != nil { - return nil, fmt.Errorf("failed to link SAML to existing user: %w", err) - } - return &user, nil - } - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to query user by email: %w", err) - } - - // Third: create new user (auto-provisioning) - user = model.User{ - Name: userInfo.DisplayName, - Email: userInfo.Email, - Provider: "saml", - UID: userInfo.NameID, - Role: "agent", // default role for SAML-provisioned users - Active: true, - // AccountID must be set by caller or via a default account assignment - // Password is empty — SAML users don't use password auth - } - - if err := s.db.Create(&user).Error; err != nil { - return nil, fmt.Errorf("failed to create SAML user: %w", err) - } - - applogger.L().Infof("SAML auto-provisioned user (id=%d, email=%s)", user.ID, user.Email) - return &user, nil -} - -// --- Helper functions --- - -// loadIdPMetadata fetches IdP metadata from URL or parses inline XML. -func loadIdPMetadata(cfg *config.SAMLConfig) (*IdPMetadata, error) { - var data []byte - - if cfg.IdPMetadataURL != "" { - metadataURL, err := url.Parse(cfg.IdPMetadataURL) - if err != nil { - return nil, fmt.Errorf("invalid IdP metadata URL: %w", err) - } - resp, err := http.Get(metadataURL.String()) - if err != nil { - return nil, fmt.Errorf("failed to fetch IdP metadata from URL: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("IdP metadata URL returned status %d", resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read IdP metadata response: %w", err) - } - data = body - } else if cfg.IdPMetadataXML != "" { - data = []byte(cfg.IdPMetadataXML) - } else { - return nil, errors.New("no IdP metadata source configured") - } - - return parseIdPMetadataXML(data) -} - -// parseIdPMetadataXML parses IdP metadata XML into an IdPMetadata struct. -func parseIdPMetadataXML(data []byte) (*IdPMetadata, error) { - var entityDesc EntityDescriptorXML - if err := xml.Unmarshal(data, &entityDesc); err != nil { - return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err) - } - - if entityDesc.EntityID == "" { - return nil, errors.New("IdP metadata missing entityID") - } - - metadata := &IdPMetadata{ - EntityID: entityDesc.EntityID, - } - - if entityDesc.IDPSSODescriptor != nil { - // Prefer HTTP-Redirect binding SSO URL - for _, sso := range entityDesc.IDPSSODescriptor.SingleSignOnServices { - if sso.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" { - metadata.SSOURL = sso.Location - break - } - } - // Fallback to any SSO URL if redirect binding not found - if metadata.SSOURL == "" && len(entityDesc.IDPSSODescriptor.SingleSignOnServices) > 0 { - metadata.SSOURL = entityDesc.IDPSSODescriptor.SingleSignOnServices[0].Location - } - - // Extract signing certificates - for _, kd := range entityDesc.IDPSSODescriptor.KeyDescriptors { - if kd.Use == "signing" || kd.Use == "" { - for _, cert := range kd.KeyInfo.X509Data.X509Certificates { - // Clean up whitespace from base64 cert data - certData := strings.TrimSpace(cert.Value) - // Re-encode as PEM if not already - if !strings.HasPrefix(certData, "-----BEGIN CERTIFICATE-----") { - certData = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", certData) - } - metadata.Certificates = append(metadata.Certificates, certData) - } - } - } - - // Extract SLO (Single Logout) endpoints - for _, slo := range entityDesc.IDPSSODescriptor.SingleLogoutServices { - if slo.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" { - metadata.SLORedirectURL = slo.Location - } else if slo.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" { - metadata.SLOPostURL = slo.Location - } - } - } - - if entityDesc.IDPSSODescriptor != nil && metadata.SSOURL == "" { - return nil, errors.New("IdP metadata missing SingleSignOnService URL") - } - - return metadata, nil -} - -// parseSAMLResponseXML parses a SAML Response XML document. -func parseSAMLResponseXML(data []byte) (*SAMLResponseXML, error) { - var response SAMLResponseXML - if err := xml.Unmarshal(data, &response); err != nil { - return nil, fmt.Errorf("failed to unmarshal SAML Response: %w", err) - } - return &response, nil -} - -// validateConditions validates SAML Conditions (time window + audience restriction). -func validateConditions(conditions *SAMLConditionsXML, spEntityID string, now time.Time, driftTolerance time.Duration) error { - if conditions == nil { - // No conditions = no restrictions to validate - return nil - } - - // Validate NotBefore - if conditions.NotBefore != "" { - notBefore, err := parseSAMLTime(conditions.NotBefore) - if err != nil { - return fmt.Errorf("invalid NotBefore timestamp: %w", err) - } - if now.Before(notBefore.Add(-driftTolerance)) { - return fmt.Errorf("assertion not yet valid (now=%s, notBefore=%s)", now.Format(time.RFC3339), conditions.NotBefore) - } - } - - // Validate NotOnOrAfter - if conditions.NotOnOrAfter != "" { - notOnOrAfter, err := parseSAMLTime(conditions.NotOnOrAfter) - if err != nil { - return fmt.Errorf("invalid NotOnOrAfter timestamp: %w", err) - } - if now.Add(driftTolerance).After(notOnOrAfter) || now.Add(driftTolerance).Equal(notOnOrAfter) { - return fmt.Errorf("assertion expired (now=%s, notOnOrAfter=%s)", now.Format(time.RFC3339), conditions.NotOnOrAfter) - } - } - - // Validate AudienceRestriction - if len(conditions.AudienceRestrictions) > 0 { - audienceMatch := false - for _, ar := range conditions.AudienceRestrictions { - for _, aud := range ar.Audiences { - if aud.Value == spEntityID { - audienceMatch = true - break - } - } - if audienceMatch { - break - } - } - if !audienceMatch { - return fmt.Errorf("audience restriction failed (expected=%s)", spEntityID) - } - } - - return nil -} - -// checkAndTrackReplay prevents replay attacks by tracking response IDs in Redis. -// If the response ID was already processed, returns ErrSAMLReplay. -// Otherwise, stores the ID with a TTL matching the assertion validity window. -func checkAndTrackReplay(responseID string, rdb redis.Cmdable) error { - if rdb == nil { - // Without Redis, we can't check replay — log warning - applogger.L().Warn("SAML replay check skipped: Redis not available") - return nil - } - - ctx := context.Background() - key := fmt.Sprintf("saml:replay:%s", responseID) - - // Check if response ID was already processed - exists, err := rdb.Exists(ctx, key).Result() - if err != nil { - return fmt.Errorf("failed to check SAML replay in Redis: %w", err) - } - if exists > 0 { - return ErrSAMLReplay - } - - // Store the response ID with 5 min TTL (should cover assertion validity) - if err := rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil { - return fmt.Errorf("failed to store SAML replay tracking in Redis: %w", err) - } - - return nil -} - -// extractAttributesFromXML extracts all SAML assertion attributes into a map. -func extractAttributesFromXML(statements []SAMLAttributeStatementXML) map[string]string { - attrs := make(map[string]string) - for _, statement := range statements { - for _, attr := range statement.Attributes { - if len(attr.Values) > 0 { - attrs[attr.Name] = attr.Values[0].Value - } - // Also store by FriendlyName if present - if attr.FriendlyName != "" && len(attr.Values) > 0 { - attrs[attr.FriendlyName] = attr.Values[0].Value - } - } - } - return attrs -} - -// getAttributeFromXML retrieves a SAML attribute value by name from the attribute map. -// Checks both Name and FriendlyName matches. -func getAttributeFromXML(attrs map[string]string, attrName string) string { - if v, ok := attrs[attrName]; ok { - return v - } - return "" -} - -// generateSPMetadataXML generates SP metadata XML structure from config. -func generateSPMetadataXML(cfg *config.SAMLConfig) *SPEntityDescriptorXML { - spDesc := &SPEntityDescriptorXML{ - XMLName: xml.Name{Space: MDNamespace, Local: "EntityDescriptor"}, - EntityID: cfg.SPEntityID, - SPSSODescriptor: &SPSSODescriptorXML{ - XMLName: xml.Name{Space: MDNamespace, Local: "SPSSODescriptor"}, - AuthnRequestsSigned: "false", - WantAssertionsSigned: "true", - ProtocolSupportEnum: SamlpNamespace, - NameIDFormats: []SPNameIDFormatXML{ - {Value: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"}, - {Value: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"}, - {Value: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"}, - }, - AssertionConsumerServices: []SPAssertionConsumerServiceXML{ - { - Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", - Location: cfg.ACSURL, - Index: 0, - }, - }, - }, - } - - // Add SP certificate if configured - if cfg.SPCertificate != "" { - // Extract just the base64 data from PEM - certBase64 := extractCertBase64FromPEM(cfg.SPCertificate) - spDesc.SPSSODescriptor.KeyDescriptors = []SPKeyDescriptorXML{ - { - Use: "signing", - KeyInfo: SPKeyInfoXML{ - X509Data: SPX509DataXML{ - X509Certificates: []SPX509CertXML{ - {Value: certBase64}, - }, - }, - }, - }, - } - } - - return spDesc -} - -// deflateAndBase64Encode deflates then base64-encodes a string (SAML redirect binding). -func deflateAndBase64Encode(xmlStr string) (string, error) { - var buf strings.Builder - writer, err := flate.NewWriter(&buf, flate.DefaultCompression) - if err != nil { - return "", err - } - if _, err := writer.Write([]byte(xmlStr)); err != nil { - return "", err - } - if err := writer.Close(); err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString([]byte(buf.String())), nil -} - -// parseSAMLTime parses a SAML timestamp (ISO 8601 format). -func parseSAMLTime(s string) (time.Time, error) { - // SAML uses ISO 8601 format, typically RFC3339 - layouts := []string{ - time.RFC3339, - time.RFC3339Nano, - "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05.000Z", - } - for _, layout := range layouts { - if t, err := time.Parse(layout, s); err == nil { - return t.UTC(), nil - } - } - return time.Time{}, fmt.Errorf("cannot parse SAML timestamp: %s", s) -} - -// generateRandomID creates a random hex string for SAML request IDs. -func generateRandomID() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - // Fallback to timestamp-based ID - return fmt.Sprintf("%d", time.Now().UnixNano()) - } - return fmt.Sprintf("%x", b) -} - -// extractCertBase64FromPEM extracts the base64-encoded certificate data from a PEM block. -func extractCertBase64FromPEM(pemData string) string { - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return pemData // fallback: return raw data - } - return base64.StdEncoding.EncodeToString(block.Bytes) -} - -// parseRSAPrivateKey parses a PEM-encoded RSA private key. -func parseRSAPrivateKey(pemData string) (*rsa.PrivateKey, error) { - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, errors.New("no PEM block found in private key data") - } - - switch block.Type { - case "RSA PRIVATE KEY": - return x509.ParsePKCS1PrivateKey(block.Bytes) - case "PRIVATE KEY": - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - rsaKey, ok := key.(*rsa.PrivateKey) - if !ok { - return nil, errors.New("PKCS8 key is not RSA") - } - return rsaKey, nil - default: - return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type) - } -} - -// parseX509Certificate parses a PEM-encoded X.509 certificate. -func parseX509Certificate(pemData string) (*x509.Certificate, error) { - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, errors.New("no PEM block found in certificate data") - } - if block.Type != "CERTIFICATE" { - return nil, fmt.Errorf("unexpected PEM block type: %s (expected CERTIFICATE)", block.Type) - } - return x509.ParseCertificate(block.Bytes) -} - -// EncodeSAMLRequest base64-encodes a SAML request string -// for use in redirect bindings (helper for URL-based SAML flows). -func EncodeSAMLRequest(xml string) (string, error) { - b64 := base64.StdEncoding.EncodeToString([]byte(xml)) - return b64, nil -} - -// --- SAML Single Logout (SLO) support --- -// Reference: M13 §1 — SAML 2.0 Single Logout (SLO) -// SLO enables enterprise users to terminate all active sessions -// across all accounts when they logout from one application. -// Two flows: (1) SP-initiated SLO — user clicks logout in GoChat, -// we send LogoutRequest to IdP, IdP propagates to all SPs. -// (2) IdP-initiated SLO — IdP sends LogoutRequest to our SLO endpoint, -// we terminate the local session and respond. - -// LogoutRequestXML represents a SAML LogoutRequest element. -type LogoutRequestXML struct { - XMLName xml.Name `xml:"LogoutRequest"` - ID string `xml:"ID,attr"` - Version string `xml:"Version,attr"` - IssueInstant string `xml:"IssueInstant,attr"` - Destination string `xml:"Destination,attr"` - NotOnOrAfter string `xml:"NotOnOrAfter,attr,omitempty"` - Issuer SAMLIssuerXML `xml:"Issuer"` - NameID SAMLNameIDXML `xml:"NameID"` - SessionIndex string `xml:"SessionIndex,omitempty"` -} - -// LogoutResponseXML represents a SAML LogoutResponse element. -type LogoutResponseXML struct { - XMLName xml.Name `xml:"LogoutResponse"` - ID string `xml:"ID,attr"` - InResponseTo string `xml:"InResponseTo,attr"` - Version string `xml:"Version,attr"` - IssueInstant string `xml:"IssueInstant,attr"` - Destination string `xml:"Destination,attr,omitempty"` - Issuer SAMLIssuerXML `xml:"Issuer"` - Status SAMLStatusXML `xml:"Status"` -} - -// SAMLStatusXML represents a SAML Status element. -type SAMLStatusXML struct { - XMLName xml.Name `xml:"Status"` - StatusCode SAMLStatusCodeXML `xml:"StatusCode"` -} - -// SAMLStatusCodeXML represents a SAML StatusCode element. -type SAMLStatusCodeXML struct { - XMLName xml.Name `xml:"StatusCode"` - Value string `xml:"Value,attr"` -} - -// SLOEndpoint holds parsed IdP SLO endpoint info. -type SLOEndpoint struct { - RedirectURL string // HTTP-Redirect binding SLO URL - PostURL string // HTTP-POST binding SLO URL -} - -// InitiateLogout generates a SAML LogoutRequest redirect URL for SP-initiated SLO. -// The returned URL includes SAMLRequest (base64-encoded LogoutRequest) and RelayState. -// Stores InResponseTo in Redis for response validation (5 min TTL). -// After IdP processes the logout, it will propagate to all SPs in the session. -func (s *SAMLService) InitiateLogout(sessionID string, nameID string, sessionIndex string, state string) (string, error) { - if !s.cfg.Enabled || s.idpMetadata == nil { - return "", ErrSAMLEnabled - } - - // Find SLO endpoint from IdP metadata - sloEndpoint, err := getSLOEndpointFromIdP(s.idpMetadata) - if err != nil { - return "", fmt.Errorf("IdP does not support SLO: %w", err) - } - - if sloEndpoint.RedirectURL == "" { - return "", errors.New("IdP SLO endpoint not available (no HTTP-Redirect binding)") - } - - // Generate LogoutRequest - requestID := fmt.Sprintf("id_%s", generateRandomID()) - now := time.Now().UTC().Format(time.RFC3339) - notOnOrAfter := time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339) - - logoutRequest := LogoutRequestXML{ - ID: requestID, - Version: "2.0", - IssueInstant: now, - Destination: sloEndpoint.RedirectURL, - NotOnOrAfter: notOnOrAfter, - Issuer: SAMLIssuerXML{ - Value: s.cfg.SPEntityID, - }, - NameID: SAMLNameIDXML{ - Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - Value: nameID, - }, - SessionIndex: sessionIndex, - } - - // Marshal to XML - xmlBytes, err := xml.Marshal(logoutRequest) - if err != nil { - return "", fmt.Errorf("failed to marshal LogoutRequest: %w", err) - } - - xmlStr := fmt.Sprintf("%s", string(xmlBytes)) - - // Deflate + base64 encode for redirect binding - encoded, err := deflateAndBase64Encode(xmlStr) - if err != nil { - return "", fmt.Errorf("failed to encode LogoutRequest: %w", err) - } - - // Build redirect URL - redirectURL, err := url.Parse(sloEndpoint.RedirectURL) - if err != nil { - return "", fmt.Errorf("invalid IdP SLO URL: %w", err) - } - q := redirectURL.Query() - q.Set("SAMLRequest", encoded) - q.Set("RelayState", state) - redirectURL.RawQuery = q.Encode() - - // Store InResponseTo in Redis for response validation (5 min TTL) - if s.rdb != nil { - ctx := context.Background() - key := fmt.Sprintf("saml:slo:inResponseTo:%s", requestID) - if err := s.rdb.Set(ctx, key, sessionID, 5*time.Minute).Err(); err != nil { - applogger.L().Warnf("Failed to store SAML SLO InResponseTo in Redis: %v", err) - } - } - - applogger.L().Infof("SAML LogoutRequest generated (id=%s, destination=%s, nameID=%s)", requestID, sloEndpoint.RedirectURL, nameID) - return redirectURL.String(), nil -} - -// ProcessLogoutRequest processes an IdP-initiated SAML LogoutRequest. -// Validates the request, terminates all SSO sessions for the user identified -// by NameID, and returns a LogoutResponse for the IdP. -func (s *SAMLService) ProcessLogoutRequest(samlRequest string) (string, error) { - if !s.cfg.Enabled || s.idpMetadata == nil { - return "", ErrSAMLEnabled - } - - // Base64-decode the SAMLRequest - xmlBytes, err := base64.StdEncoding.DecodeString(samlRequest) - if err != nil { - return "", fmt.Errorf("failed to base64 decode LogoutRequest: %v", err) - } - - // Parse XML - var logoutReq LogoutRequestXML - if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil { - return "", fmt.Errorf("failed to parse LogoutRequest XML: %w", err) - } - - // Validate issuer matches our IdP - if logoutReq.Issuer.Value != "" && logoutReq.Issuer.Value != s.idpMetadata.EntityID { - return "", fmt.Errorf("LogoutRequest issuer mismatch (expected=%s, got=%s)", s.idpMetadata.EntityID, logoutReq.Issuer.Value) - } - - // Check replay for the request ID - if err := checkAndTrackReplay(logoutReq.ID, s.rdb); err != nil { - return "", fmt.Errorf("LogoutRequest replay detected: %w", err) - } - - applogger.L().Infof("SAML LogoutRequest received (id=%s, nameID=%s, sessionIndex=%s)", logoutReq.ID, logoutReq.NameID.Value, logoutReq.SessionIndex) - - // Generate LogoutResponse - responseID := fmt.Sprintf("id_%s", generateRandomID()) - now := time.Now().UTC().Format(time.RFC3339) - - sloEndpoint, _ := getSLOEndpointFromIdP(s.idpMetadata) - - logoutResponse := LogoutResponseXML{ - ID: responseID, - InResponseTo: logoutReq.ID, - Version: "2.0", - IssueInstant: now, - Destination: sloEndpoint.RedirectURL, - Issuer: SAMLIssuerXML{ - Value: s.cfg.SPEntityID, - }, - Status: SAMLStatusXML{ - StatusCode: SAMLStatusCodeXML{ - Value: "urn:oasis:names:tc:SAML:2.0:status:Success", - }, - }, - } - - respBytes, err := xml.Marshal(logoutResponse) - if err != nil { - return "", fmt.Errorf("failed to marshal LogoutResponse: %w", err) - } - - respStr := fmt.Sprintf("%s", string(respBytes)) - encodedResp := base64.StdEncoding.EncodeToString([]byte(respStr)) - - applogger.L().Infof("SAML LogoutResponse generated (id=%s, inResponseTo=%s)", responseID, logoutReq.ID) - return encodedResp, nil -} - -// ProcessLogoutResponse validates an IdP LogoutResponse for SP-initiated SLO. -// Checks that the response is valid and matches our original LogoutRequest. -func (s *SAMLService) ProcessLogoutResponse(samlResponse string, relayState string) error { - if !s.cfg.Enabled || s.idpMetadata == nil { - return ErrSAMLEnabled - } - - // Base64-decode the SAMLResponse - xmlBytes, err := base64.StdEncoding.DecodeString(samlResponse) - if err != nil { - return fmt.Errorf("failed to base64 decode LogoutResponse: %v", err) - } - - // Parse XML - var logoutResp LogoutResponseXML - if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil { - return fmt.Errorf("failed to parse LogoutResponse XML: %w", err) - } - - // Validate InResponseTo was tracked in Redis - if s.rdb != nil { - ctx := context.Background() - key := fmt.Sprintf("saml:slo:inResponseTo:%s", logoutResp.InResponseTo) - storedState, err := s.rdb.Get(ctx, key).Result() - if err == redis.Nil { - return fmt.Errorf("LogoutResponse InResponseTo not found or expired (inResponseTo=%s)", logoutResp.InResponseTo) - } - if err != nil { - return fmt.Errorf("failed to validate LogoutResponse InResponseTo: %w", err) - } - // Clean up the stored state - s.rdb.Del(ctx, key) - _ = storedState // session ID from Redis, already processed - } - - // Check response status - if logoutResp.Status.StatusCode.Value != "urn:oasis:names:tc:SAML:2.0:status:Success" { - return fmt.Errorf("LogoutResponse status not success: %s", logoutResp.Status.StatusCode.Value) - } - - // Check replay for the response ID - if err := checkAndTrackReplay(logoutResp.ID, s.rdb); err != nil { - return fmt.Errorf("LogoutResponse replay detected: %w", err) - } - - applogger.L().Infof("SAML LogoutResponse validated (id=%s, inResponseTo=%s, relayState=%s)", logoutResp.ID, logoutResp.InResponseTo, relayState) - return nil -} - -// getSLOEndpointFromIdP extracts SLO endpoint URLs from IdP metadata. -// Returns SLOEndpoint with redirect and/or post binding URLs. -func getSLOEndpointFromIdP(metadata *IdPMetadata) (*SLOEndpoint, error) { - if metadata == nil { - return nil, errors.New("no IdP metadata available") - } - - slo := &SLOEndpoint{ - RedirectURL: metadata.SLORedirectURL, - PostURL: metadata.SLOPostURL, - } - - if slo.RedirectURL == "" && slo.PostURL == "" { - return nil, errors.New("IdP metadata does not contain SingleLogoutService endpoints") - } - - return slo, nil -} \ No newline at end of file diff --git a/backend/internal/auth/saml_test.go b/backend/internal/auth/saml_test.go deleted file mode 100644 index c3f61390..00000000 --- a/backend/internal/auth/saml_test.go +++ /dev/null @@ -1,762 +0,0 @@ -package auth - -import ( - "encoding/base64" - "encoding/json" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" - - "github.com/gochat/gochat/internal/config" - "github.com/gochat/gochat/internal/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -// --- Helpers for SAML tests --- - -func newTestSAMLDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - require.NoError(t, err) - err = db.AutoMigrate(&model.AccountSamlSettings{}, &model.User{}, &model.Account{}, &model.AccountUser{}) - require.NoError(t, err) - return db -} - -func setupTestRedis(t *testing.T) (*miniredis.Miniredis, redis.Cmdable) { - t.Helper() - mr := miniredis.RunT(t) - rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - t.Cleanup(func() { rdb.Close() }) - return mr, rdb -} - -func newTestSAMLConfig(enabled bool) *config.SAMLConfig { - return &config.SAMLConfig{ - Enabled: enabled, - SPEntityID: "https://sp.example.com/saml", - ACSURL: "https://sp.example.com/saml/acs", - IdPMetadataURL: "", - IdPMetadataXML: "", - SPPrivateKey: "", - SPCertificate: "", - AttributeMap: config.SAMLAttributeMap{Email: "email", DisplayName: "displayName", FirstName: "firstName", LastName: "lastName"}, - ClockDriftTolerance: 180, - } -} - -// Sample IdP metadata XML for testing -const testIdPMetadataXML = ` - - - - - - - - - MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUA - - - - -` - -// --- NewSAMLService tests --- - -func TestNewSAMLService_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - assert.NoError(t, err, "disabled SAML should not error") - assert.NotNil(t, svc) -} - -func TestNewSAMLService_EnabledMissingSPEntityID(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.SPEntityID = "" - _, err := NewSAMLService(cfg, rdb, db) - assert.ErrorIs(t, err, ErrSAMLInvalidConfig, "missing SPEntityID should return ErrSAMLInvalidConfig") -} - -func TestNewSAMLService_EnabledMissingACSURL(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.ACSURL = "" - _, err := NewSAMLService(cfg, rdb, db) - assert.ErrorIs(t, err, ErrSAMLInvalidConfig, "missing ACSURL should return ErrSAMLInvalidConfig") -} - -func TestNewSAMLService_EnabledNoMetadataSource(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataURL = "" - cfg.IdPMetadataXML = "" - _, err := NewSAMLService(cfg, rdb, db) - assert.ErrorIs(t, err, ErrSAMLIdPMetadata, "no IdP metadata source should return ErrSAMLIdPMetadata") -} - -func TestNewSAMLService_EnabledWithInlineMetadata(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - assert.NoError(t, err, "valid inline metadata should not error") - assert.NotNil(t, svc) - assert.Equal(t, "https://idp.example.com/saml", svc.GetIdPEntityID()) -} - -// --- GetIdPEntityID tests --- - -func TestGetIdPEntityID_WithMetadata(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - entityID := svc.GetIdPEntityID() - assert.Equal(t, "https://idp.example.com/saml", entityID) -} - -func TestGetIdPEntityID_NoMetadata(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - entityID := svc.GetIdPEntityID() - assert.Equal(t, "", entityID, "no metadata should return empty entityID") -} - -// --- parseIdPMetadataXML tests --- - -func TestParseIdPMetadataXML_Valid(t *testing.T) { - metadata, err := parseIdPMetadataXML([]byte(testIdPMetadataXML)) - require.NoError(t, err) - - assert.Equal(t, "https://idp.example.com/saml", metadata.EntityID) - assert.Equal(t, "https://idp.example.com/saml/sso", metadata.SSOURL, "should prefer HTTP-Redirect binding") - assert.Equal(t, "https://idp.example.com/saml/slo", metadata.SLORedirectURL) - assert.Equal(t, "https://idp.example.com/saml/slo/post", metadata.SLOPostURL) - assert.NotEmpty(t, metadata.Certificates, "should extract signing certificates") -} - -func TestParseIdPMetadataXML_MissingEntityID(t *testing.T) { - xml := ` - - - -` - _, err := parseIdPMetadataXML([]byte(xml)) - assert.Error(t, err, "missing entityID should error") - assert.Contains(t, err.Error(), "entityID") -} - -func TestParseIdPMetadataXML_InvalidXML(t *testing.T) { - _, err := parseIdPMetadataXML([]byte("not valid xml")) - assert.Error(t, err, "invalid XML should error") -} - -func TestParseIdPMetadataXML_NoIDPSSODescriptor(t *testing.T) { - xml := ` -` - metadata, err := parseIdPMetadataXML([]byte(xml)) - require.NoError(t, err) - assert.Equal(t, "https://idp.example.com/saml", metadata.EntityID) - assert.Empty(t, metadata.SSOURL, "no IDPSSODescriptor means no SSO URL") - assert.Empty(t, metadata.Certificates) -} - -func TestParseIdPMetadataXML_FallbackSSOBinding(t *testing.T) { - // Only POST binding, no Redirect binding — should fallback to first SSO URL - xml := ` - - - -` - metadata, err := parseIdPMetadataXML([]byte(xml)) - require.NoError(t, err) - assert.Equal(t, "https://idp.example.com/saml/sso/post", metadata.SSOURL, "should fallback to POST binding when no Redirect") -} - -func TestParseIdPMetadataXML_CertificatePEMFormatting(t *testing.T) { - xml := ` - - - - - - MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUA - - - - -` - metadata, err := parseIdPMetadataXML([]byte(xml)) - require.NoError(t, err) - require.NotEmpty(t, metadata.Certificates) - // Certificate should be PEM-formatted - assert.Contains(t, metadata.Certificates[0], "-----BEGIN CERTIFICATE-----") -} - -// --- extractAttributesFromXML tests --- - -func TestExtractAttributesFromXML(t *testing.T) { - statements := []SAMLAttributeStatementXML{ - { - Attributes: []SAMLAttributeXML{ - { - Name: "email", - FriendlyName: "EmailAddress", - Values: []SAMLAttributeValueXML{{Value: "user@example.com"}}, - }, - { - Name: "displayName", - Values: []SAMLAttributeValueXML{{Value: "Test User"}}, - }, - }, - }, - { - Attributes: []SAMLAttributeXML{ - { - Name: "firstName", - Values: []SAMLAttributeValueXML{{Value: "Test"}, {Value: "Extra"}}, - }, - }, - }, - } - - attrs := extractAttributesFromXML(statements) - assert.Equal(t, "user@example.com", attrs["email"]) - assert.Equal(t, "user@example.com", attrs["EmailAddress"], "should also store by FriendlyName") - assert.Equal(t, "Test User", attrs["displayName"]) - assert.Equal(t, "Test", attrs["firstName"], "should use first value for multi-valued attributes") -} - -func TestExtractAttributesFromXML_Empty(t *testing.T) { - attrs := extractAttributesFromXML(nil) - assert.Empty(t, attrs) - - attrs = extractAttributesFromXML([]SAMLAttributeStatementXML{}) - assert.Empty(t, attrs) -} - -func TestExtractAttributesFromXML_NoValues(t *testing.T) { - statements := []SAMLAttributeStatementXML{ - { - Attributes: []SAMLAttributeXML{ - {Name: "email", Values: []SAMLAttributeValueXML{}}, - }, - }, - } - attrs := extractAttributesFromXML(statements) - assert.NotContains(t, attrs, "email", "attribute with no values should not appear in map") -} - -// --- getAttributeFromXML tests --- - -func TestGetAttributeFromXML(t *testing.T) { - attrs := map[string]string{"email": "user@example.com", "name": "Test"} - - assert.Equal(t, "user@example.com", getAttributeFromXML(attrs, "email")) - assert.Equal(t, "Test", getAttributeFromXML(attrs, "name")) - assert.Equal(t, "", getAttributeFromXML(attrs, "missing"), "missing attribute should return empty string") -} - -// --- parseSAMLTime tests --- - -func TestParseSAMLTime(t *testing.T) { - // RFC3339 - ts, err := parseSAMLTime("2024-01-15T10:30:00Z") - require.NoError(t, err) - assert.Equal(t, time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), ts) - - // RFC3339Nano - ts, err = parseSAMLTime("2024-01-15T10:30:00.123456789Z") - require.NoError(t, err) - assert.True(t, ts.Year() == 2024) - - // Simple UTC format - ts, err = parseSAMLTime("2024-01-15T10:30:00Z") - require.NoError(t, err) - assert.Equal(t, time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), ts) - - // Invalid format - _, err = parseSAMLTime("not-a-time") - assert.Error(t, err, "invalid time format should error") -} - -func TestParseSAMLTime_WithOffset(t *testing.T) { - ts, err := parseSAMLTime("2024-01-15T10:30:00+05:00") - require.NoError(t, err) - // Should normalize to UTC - assert.Equal(t, time.Date(2024, 1, 15, 5, 30, 0, 0, time.UTC), ts.UTC()) -} - -// --- deflateAndBase64Encode tests --- - -func TestDeflateAndBase64Encode(t *testing.T) { - input := "" - encoded, err := deflateAndBase64Encode(input) - require.NoError(t, err) - assert.NotEmpty(t, encoded, "encoded result should not be empty") -} - -func TestDeflateAndBase64Encode_Empty(t *testing.T) { - encoded, err := deflateAndBase64Encode("") - require.NoError(t, err) - assert.NotEmpty(t, encoded, "even empty input produces encoded output") -} - -// --- EncodeSAMLRequest tests --- - -func TestEncodeSAMLRequest(t *testing.T) { - xml := "" - encoded, err := EncodeSAMLRequest(xml) - require.NoError(t, err) - assert.NotEmpty(t, encoded) - // Simple base64 — verify it can be decoded - decoded, err := base64.StdEncoding.DecodeString(encoded) - require.NoError(t, err) - assert.Equal(t, xml, string(decoded)) -} - -// --- generateRandomID tests --- - -func TestGenerateRandomID(t *testing.T) { - id1 := generateRandomID() - id2 := generateRandomID() - - assert.NotEmpty(t, id1, "random ID should not be empty") - assert.NotEmpty(t, id2, "random ID should not be empty") - assert.NotEqual(t, id1, id2, "two random IDs should differ (extremely unlikely collision)") -} - -// --- extractCertBase64FromPEM tests --- - -func TestExtractCertBase64FromPEM(t *testing.T) { - pemData := "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUA\n-----END CERTIFICATE-----" - result := extractCertBase64FromPEM(pemData) - assert.NotEmpty(t, result, "should extract base64 data from PEM") -} - -func TestExtractCertBase64FromPEM_NoPEMBlock(t *testing.T) { - rawData := "not-a-pem-block" - result := extractCertBase64FromPEM(rawData) - assert.Equal(t, rawData, result, "fallback should return raw data when no PEM block found") -} - -// --- SAMLConfig ClockDriftDuration tests (in config package) --- - -func TestSAMLConfigClockDriftDuration(t *testing.T) { - cfg := config.SAMLConfig{ClockDriftTolerance: 300} - assert.Equal(t, 300*time.Second, cfg.ClockDriftDuration()) - - cfg = config.SAMLConfig{ClockDriftTolerance: 0} - assert.Equal(t, 180*time.Second, cfg.ClockDriftDuration(), "default should be 180s") - - cfg = config.SAMLConfig{ClockDriftTolerance: -1} - assert.Equal(t, 180*time.Second, cfg.ClockDriftDuration(), "negative should use default 180s") -} - -// --- SAML InitiateLogin tests (disabled service) --- - -func TestSAMLService_InitiateLogin_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.InitiateLogin("test-state") - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject login") -} - -// --- SAML ProcessResponse tests (disabled service) --- - -func TestSAMLService_ProcessResponse_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.ProcessResponse("some-response") - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject processing") -} - -// --- SAML FindOrCreateUser tests --- - -func TestSAMLService_FindOrCreateUser_NewUser(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - userInfo := &SAMLUserInfo{ - NameID: "user123@idp.example.com", - Email: "samluser@example.com", - DisplayName: "SAML User", - FirstName: "SAML", - LastName: "User", - Attributes: map[string]string{"email": "samluser@example.com"}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.NotNil(t, user) - assert.Equal(t, "samluser@example.com", user.Email) - assert.Equal(t, "SAML User", user.Name) - assert.Equal(t, "saml", user.Provider) - assert.Equal(t, "user123@idp.example.com", user.UID) -} - -func TestSAMLService_FindOrCreateUser_ExistingUserByEmail(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - // Create a user first - existingUser := &model.User{ - Name: "Existing", - Email: "existing@example.com", - Password: "hashed", - Provider: "email", - Active: true, - } - require.NoError(t, db.Create(existingUser).Error) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - userInfo := &SAMLUserInfo{ - NameID: "existing@idp.example.com", - Email: "existing@example.com", - DisplayName: "Existing Updated", - Attributes: map[string]string{"email": "existing@example.com"}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.NotNil(t, user) - assert.Equal(t, existingUser.ID, user.ID, "should find existing user by email and link to SAML") - assert.Equal(t, "saml", user.Provider, "should update provider to saml") - assert.Equal(t, "existing@idp.example.com", user.UID, "should set UID to SAML NameID") -} - -func TestSAMLService_FindOrCreateUser_ExistingSAMLUser(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - // Create a SAML user first - existingUser := &model.User{ - Name: "SAML Existing", - Email: "saml.existing@example.com", - Password: "hashed", - Provider: "saml", - UID: "saml123@idp.example.com", - Active: true, - } - require.NoError(t, db.Create(existingUser).Error) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - userInfo := &SAMLUserInfo{ - NameID: "saml123@idp.example.com", - Email: "saml.existing@example.com", - DisplayName: "SAML Existing Updated", - Attributes: map[string]string{"email": "saml.existing@example.com"}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.NotNil(t, user) - assert.Equal(t, existingUser.ID, user.ID, "should find existing SAML user by provider+UID") - assert.Equal(t, "SAML Existing Updated", user.Name, "should update display name") -} - -func TestSAMLService_FindOrCreateUser_NoDB(t *testing.T) { - _, rdb := setupTestRedis(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, nil) - require.NoError(t, err) - - userInfo := &SAMLUserInfo{ - NameID: "nodb@idp.example.com", - Email: "nodb@example.com", - DisplayName: "No DB User", - } - - _, err = svc.FindOrCreateUser(userInfo) - assert.Error(t, err, "nil DB should error") - assert.Contains(t, err.Error(), "database not available") -} - -// --- SAML GetSPMetadata tests --- - -func TestSAMLService_GetSPMetadata(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - metadata, err := svc.GetSPMetadata() - require.NoError(t, err) - assert.NotEmpty(t, metadata, "SP metadata should not be empty") -} - -func TestSAMLService_GetSPMetadata_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.GetSPMetadata() - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject SP metadata request") -} - -// --- SAML Logout tests (disabled) --- - -func TestSAMLService_InitiateLogout_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.InitiateLogout("session1", "nameid1", "index1", "state1") - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject logout initiation") -} - -func TestSAMLService_ProcessLogoutResponse_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - err = svc.ProcessLogoutResponse("response", "relayState") - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject logout response") -} - -func TestSAMLService_ProcessLogoutRequest_Disabled(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - cfg := newTestSAMLConfig(false) - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - - _, err = svc.ProcessLogoutRequest("request") - assert.ErrorIs(t, err, ErrSAMLEnabled, "disabled service should reject logout request") -} - -// --- SAML error sentinel tests --- - -func TestSAMLErrorSentinels(t *testing.T) { - assert.Equal(t, "saml authentication is not enabled", ErrSAMLEnabled.Error()) - assert.Equal(t, "saml configuration is invalid", ErrSAMLInvalidConfig.Error()) - assert.Equal(t, "saml response validation failed", ErrSAMLInvalidResponse.Error()) - assert.Equal(t, "saml response replay detected", ErrSAMLReplay.Error()) - assert.Equal(t, "saml response missing NameID", ErrSAMLMissingNameID.Error()) - assert.Equal(t, "saml response missing email attribute", ErrSAMLMissingEmail.Error()) - assert.Equal(t, "failed to load IdP metadata", ErrSAMLIdPMetadata.Error()) -} - -// --- SAML attribute mapping tests --- - -func TestSAMLAttributeMapping_DefaultConfig(t *testing.T) { - cfg := newTestSAMLConfig(true) - assert.Equal(t, "email", cfg.AttributeMap.Email) - assert.Equal(t, "displayName", cfg.AttributeMap.DisplayName) - assert.Equal(t, "firstName", cfg.AttributeMap.FirstName) - assert.Equal(t, "lastName", cfg.AttributeMap.LastName) -} - -// --- validateConditions tests --- - -func TestValidateConditions_ValidConditions(t *testing.T) { - now := time.Now().UTC() - drift := 180 * time.Second - - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(-60 * time.Second).Format(time.RFC3339), - NotOnOrAfter: now.Add(5 * time.Minute).Format(time.RFC3339), - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{{Value: "https://sp.example.com/saml"}}}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.NoError(t, err, "valid conditions should pass") -} - -func TestValidateConditions_ExpiredConditions(t *testing.T) { - now := time.Now().UTC() - drift := 180 * time.Second - - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(-10 * time.Minute).Format(time.RFC3339), - NotOnOrAfter: now.Add(-1 * time.Minute).Format(time.RFC3339), // already expired - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{{Value: "https://sp.example.com/saml"}}}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.Error(t, err, "expired conditions should fail") -} - -func TestValidateConditions_WrongAudience(t *testing.T) { - now := time.Now().UTC() - drift := 180 * time.Second - - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(-60 * time.Second).Format(time.RFC3339), - NotOnOrAfter: now.Add(5 * time.Minute).Format(time.RFC3339), - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{{Value: "https://wrong-sp.example.com/saml"}}}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.Error(t, err, "wrong audience should fail") -} - -func TestValidateConditions_ClockDrift(t *testing.T) { - now := time.Now().UTC() - drift := 180 * time.Second - - // NotBefore is slightly in the future (within drift tolerance) - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(60 * time.Second).Format(time.RFC3339), // 60s in future - NotOnOrAfter: now.Add(5 * time.Minute).Format(time.RFC3339), - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{{Value: "https://sp.example.com/saml"}}}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.NoError(t, err, "conditions within drift tolerance should pass") -} - -func TestValidateConditions_ExcessiveClockDrift(t *testing.T) { - now := time.Now().UTC() - drift := 30 * time.Second - - // NotBefore is 60s in future, drift tolerance only 30s - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(60 * time.Second).Format(time.RFC3339), - NotOnOrAfter: now.Add(5 * time.Minute).Format(time.RFC3339), - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{{Value: "https://sp.example.com/saml"}}}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.Error(t, err, "conditions exceeding drift tolerance should fail") -} - -func TestValidateConditions_MultipleAudiences(t *testing.T) { - now := time.Now().UTC() - drift := 180 * time.Second - - conditions := &SAMLConditionsXML{ - NotBefore: now.Add(-60 * time.Second).Format(time.RFC3339), - NotOnOrAfter: now.Add(5 * time.Minute).Format(time.RFC3339), - AudienceRestrictions: []SAMLAudienceRestrictionXML{ - {Audiences: []SAMLAudienceXML{ - {Value: "https://other-sp.example.com/saml"}, - {Value: "https://sp.example.com/saml"}, - }}, - }, - } - - err := validateConditions(conditions, "https://sp.example.com/saml", now, drift) - assert.NoError(t, err, "should pass when SP is among multiple audiences") -} - -// --- SAML per-account settings tests --- - -func TestSAMLService_WithPerAccountSettings(t *testing.T) { - _, rdb := setupTestRedis(t) - db := newTestSAMLDB(t) - - // Create an account and SAML settings for it - account := &model.Account{Name: "Test Corp", Active: true} - require.NoError(t, db.Create(account).Error) - - settings := &model.AccountSamlSettings{ - AccountID: account.ID, - IdpEntityID: "https://corp-idp.example.com/saml", - IdpSsoTargetURL: "https://corp-idp.example.com/saml/sso", - IdpCertificate: "MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiIMA0GCSqGSIb3DQEBCwUA", - SpEntityID: "https://sp.example.com/saml", - Active: true, - } - require.NoError(t, db.Create(settings).Error) - - cfg := newTestSAMLConfig(true) - cfg.IdPMetadataXML = testIdPMetadataXML - svc, err := NewSAMLService(cfg, rdb, db) - require.NoError(t, err) - assert.NotNil(t, svc) -} - -// --- Role mapping via SAML settings (JSON RoleMappings) --- - -func TestSAMLRoleMappings_JSONParsing(t *testing.T) { - mappings := json.RawMessage(`{"admins": "administrator", "developers": "agent", "managers": "supervisor"}`) - settings := &model.AccountSamlSettings{ - RoleMappings: mappings, - } - assert.NotNil(t, settings.RoleMappings) - - var parsed map[string]string - err := json.Unmarshal(settings.RoleMappings, &parsed) - assert.NoError(t, err) - assert.Equal(t, "administrator", parsed["admins"]) - assert.Equal(t, "agent", parsed["developers"]) -} \ No newline at end of file diff --git a/backend/internal/auth/saml_test.go_BAK b/backend/internal/auth/saml_test.go_BAK deleted file mode 100644 index c0136d25..00000000 --- a/backend/internal/auth/saml_test.go_BAK +++ /dev/null @@ -1,492 +0,0 @@ -package auth - -// Reference: P2E §1.6 — SAML 2.0 Service Provider integration tests -// Tests cover: config validation, service initialization (enabled/disabled), -// attribute mapping, user find/create, replay prevention helpers. - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - "math/big" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/gochat/gochat/internal/config" - "github.com/gochat/gochat/internal/model" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// --- Test helpers --- - -func newTestSAMLConfig() *config.SAMLConfig { - return &config.SAMLConfig{ - Enabled: false, // disabled by default for most tests - IdPMetadataURL: "", - IdPMetadataXML: testIdPMetadataXML(), - SPEntityID: "https://gochat.test/saml", - ACSURL: "https://gochat.test/api/v1/saml/acs", - SPPrivateKey: "", - SPCertificate: "", - ClockDriftTolerance: 180, - AttributeMap: config.SAMLAttributeMap{ - Email: "email", - DisplayName: "displayName", - FirstName: "firstName", - LastName: "lastName", - }, - } -} - -func newTestSAMLConfigEnabled() *config.SAMLConfig { - cfg := newTestSAMLConfig() - cfg.Enabled = true - cfg.SPPrivateKey = generateTestPrivateKeyPEM() - cfg.SPCertificate = generateTestCertificatePEM() - return cfg -} - -func newTestDB(t *testing.T) *gorm.DB { - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - require.NoError(t, err) - err = db.AutoMigrate(&model.User{}) - require.NoError(t, err) - return db -} - -// Generate a self-signed RSA key + cert for testing -func generateTestRSAPair() (*rsa.PrivateKey, *x509.Certificate) { - key, _ := rsa.GenerateKey(rand.Reader, 2048) - cert := &x509.Certificate{ - SerialNumber: big.NewInt(1), - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - IsCA: true, - BasicConstraintsValid: true, - } - return key, cert -} - -func generateTestPrivateKeyPEM() string { - key, _ := rsa.GenerateKey(rand.Reader, 2048) - keyBytes := x509.MarshalPKCS1PrivateKey(key) - return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes})) -} - -func generateTestCertificatePEM() string { - key, cert := generateTestRSAPair() - certBytes, _ := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) - return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})) -} - -// Minimal IdP metadata XML for testing -func testIdPMetadataXML() string { - return ` - - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - - -` -} - -// --- Config Tests --- - -func TestSAMLConfig_ClockDriftDuration(t *testing.T) { - cfg := newTestSAMLConfig() - - // Default (180 seconds) - assert.Equal(t, 180*time.Second, cfg.ClockDriftDuration()) - - // Explicit value - cfg.ClockDriftTolerance = 300 - assert.Equal(t, 300*time.Second, cfg.ClockDriftDuration()) - - // Zero value → default 180s - cfg.ClockDriftTolerance = 0 - assert.Equal(t, 180*time.Second, cfg.ClockDriftDuration()) -} - -// --- Service Initialization Tests --- - -func TestNewSAMLService_Disabled(t *testing.T) { - cfg := newTestSAMLConfig() - svc, err := NewSAMLService(cfg, nil, nil) - require.NoError(t, err) - assert.NotNil(t, svc) - assert.False(t, svc.cfg.Enabled) - assert.Nil(t, svc.idpMetadata) // no IdP metadata when disabled -} - -func TestNewSAMLService_Enabled_InvalidConfig(t *testing.T) { - tests := []struct { - name string - modify func(cfg *config.SAMLConfig) - wantErr error - }{ - { - name: "missing sp_entity_id", - modify: func(cfg *config.SAMLConfig) { cfg.SPEntityID = "" }, - wantErr: ErrSAMLInvalidConfig, - }, - { - name: "missing acs_url", - modify: func(cfg *config.SAMLConfig) { cfg.ACSURL = "" }, - wantErr: ErrSAMLInvalidConfig, - }, - { - name: "missing both idp metadata sources", - modify: func(cfg *config.SAMLConfig) { cfg.IdPMetadataURL = ""; cfg.IdPMetadataXML = "" }, - wantErr: ErrSAMLIdPMetadata, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := newTestSAMLConfigEnabled() - tt.modify(cfg) - svc, err := NewSAMLService(cfg, nil, nil) - assert.Nil(t, svc) - assert.ErrorIs(t, err, tt.wantErr) - }) - } -} - -func TestNewSAMLService_Enabled_ValidConfig(t *testing.T) { - cfg := newTestSAMLConfigEnabled() - svc, err := NewSAMLService(cfg, nil, nil) - require.NoError(t, err) - assert.NotNil(t, svc) - assert.True(t, svc.cfg.Enabled) - assert.NotNil(t, svc.idpMetadata) -} - -// --- Operation Tests (disabled service should reject) --- - -func TestSAMLService_Disabled_InitiateLogin(t *testing.T) { - cfg := newTestSAMLConfig() - svc, _ := NewSAMLService(cfg, nil, nil) - - _, err := svc.InitiateLogin("test-state") - assert.ErrorIs(t, err, ErrSAMLEnabled) -} - -func TestSAMLService_Disabled_ProcessResponse(t *testing.T) { - cfg := newTestSAMLConfig() - svc, _ := NewSAMLService(cfg, nil, nil) - - _, err := svc.ProcessResponse("fake-response") - assert.ErrorIs(t, err, ErrSAMLEnabled) -} - -func TestSAMLService_Disabled_GetSPMetadata(t *testing.T) { - cfg := newTestSAMLConfig() - svc, _ := NewSAMLService(cfg, nil, nil) - - _, err := svc.GetSPMetadata() - assert.ErrorIs(t, err, ErrSAMLEnabled) -} - -// --- Attribute Extraction Tests --- - -func TestExtractAttributes(t *testing.T) { - statements := []SAMLAttributeStatementXML{ - { - Attributes: []SAMLAttributeXML{ - { - Name: "email", - FriendlyName: "Email Address", - Values: []SAMLAttributeValueXML{{Value: "user@test.com"}}, - }, - { - Name: "displayName", - Values: []SAMLAttributeValueXML{{Value: "Test User"}}, - }, - { - Name: "firstName", - Values: []SAMLAttributeValueXML{{Value: "Test"}}, - }, - { - Name: "lastName", - Values: []SAMLAttributeValueXML{{Value: "User"}}, - }, - { - Name: "orgRole", - FriendlyName: "Organization Role", - Values: []SAMLAttributeValueXML{{Value: "admin"}}, - }, - }, - }, - } - - attrs := extractAttributesFromXML(statements) - assert.Equal(t, "user@test.com", attrs["email"]) - assert.Equal(t, "user@test.com", attrs["Email Address"]) // FriendlyName too - assert.Equal(t, "Test User", attrs["displayName"]) - assert.Equal(t, "Test", attrs["firstName"]) - assert.Equal(t, "User", attrs["lastName"]) - assert.Equal(t, "admin", attrs["orgRole"]) - assert.Equal(t, "admin", attrs["Organization Role"]) -} - -func TestGetAttribute(t *testing.T) { - statements := []SAMLAttributeStatementXML{ - { - Attributes: []SAMLAttributeXML{ - { - Name: "email", - FriendlyName: "mail", - Values: []SAMLAttributeValueXML{{Value: "admin@corp.com"}}, - }, - }, - }, - } - - attrs := extractAttributesFromXML(statements) - // Lookup by Name - assert.Equal(t, "admin@corp.com", getAttributeFromXML(attrs, "email")) - // Lookup by FriendlyName - assert.Equal(t, "admin@corp.com", getAttributeFromXML(attrs, "mail")) - // Missing attribute - assert.Equal(t, "", getAttributeFromXML(attrs, "phone")) -} - -func TestGetAttribute_MultipleStatements(t *testing.T) { - statements := []SAMLAttributeStatementXML{ - { - Attributes: []SAMLAttributeXML{ - { - Name: "firstName", - Values: []SAMLAttributeValueXML{{Value: "Alice"}}, - }, - }, - }, - { - Attributes: []SAMLAttributeXML{ - { - Name: "lastName", - Values: []SAMLAttributeValueXML{{Value: "Smith"}}, - }, - }, - }, - } - - attrs := extractAttributesFromXML(statements) - assert.Equal(t, "Alice", getAttributeFromXML(attrs, "firstName")) - assert.Equal(t, "Smith", getAttributeFromXML(attrs, "lastName")) -} - -// --- PEM Parsing Tests --- - -func TestParseRSAPrivateKey_PKCS1(t *testing.T) { - pemData := generateTestPrivateKeyPEM() - key, err := parseRSAPrivateKey(pemData) - require.NoError(t, err) - assert.NotNil(t, key) - assert.Equal(t, 2048, key.N.BitLen()) -} - -func TestParseRSAPrivateKey_PKCS8(t *testing.T) { - key, _ := rsa.GenerateKey(rand.Reader, 2048) - keyBytes, _ := x509.MarshalPKCS8PrivateKey(key) - pemData := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})) - - parsedKey, err := parseRSAPrivateKey(pemData) - require.NoError(t, err) - assert.NotNil(t, parsedKey) -} - -func TestParseRSAPrivateKey_Invalid(t *testing.T) { - _, err := parseRSAPrivateKey("not-a-pem") - assert.Error(t, err) -} - -func TestParseX509Certificate_Valid(t *testing.T) { - pemData := generateTestCertificatePEM() - cert, err := parseX509Certificate(pemData) - require.NoError(t, err) - assert.NotNil(t, cert) -} - -func TestParseX509Certificate_Invalid(t *testing.T) { - _, err := parseX509Certificate("not-a-pem") - assert.Error(t, err) -} - -// --- IdP Metadata Loading Tests --- - -func TestLoadIdPMetadata_InlineXML(t *testing.T) { - cfg := &config.SAMLConfig{ - IdPMetadataXML: testIdPMetadataXML(), - } - metadata, err := loadIdPMetadata(cfg) - require.NoError(t, err) - assert.NotNil(t, metadata) - assert.Equal(t, "https://idp.test/saml", metadata.EntityID) -} - -func TestLoadIdPMetadata_NoSource(t *testing.T) { - cfg := &config.SAMLConfig{ - IdPMetadataURL: "", - IdPMetadataXML: "", - } - _, err := loadIdPMetadata(cfg) - assert.Error(t, err) -} - -func TestLoadIdPMetadata_InvalidXML(t *testing.T) { - cfg := &config.SAMLConfig{ - IdPMetadataXML: "not valid xml at all", - } - _, err := loadIdPMetadata(cfg) - assert.Error(t, err) -} - -// --- User FindOrCreate Tests --- - -func TestSAMLService_FindOrCreateUser_NewUser(t *testing.T) { - db := newTestDB(t) - cfg := newTestSAMLConfig() // disabled, but FindOrCreateUser only needs db - svc := &SAMLService{cfg: cfg, db: db} - - userInfo := &SAMLUserInfo{ - NameID: "alice@saml.test", - Email: "alice@example.com", - DisplayName: "Alice Smith", - FirstName: "Alice", - LastName: "Smith", - Attributes: map[string]string{"email": "alice@example.com"}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.NotZero(t, user.ID) - assert.Equal(t, "alice@saml.test", user.UID) - assert.Equal(t, "alice@example.com", user.Email) - assert.Equal(t, "Alice Smith", user.Name) - assert.Equal(t, "saml", user.Provider) - assert.Equal(t, "agent", user.Role) - assert.True(t, user.Active) -} - -func TestSAMLService_FindOrCreateUser_ExistingByUID(t *testing.T) { - db := newTestDB(t) - - // Create an existing SAML user - existing := &model.User{ - Name: "Old Name", - Email: "bob@example.com", - Provider: "saml", - UID: "bob@saml.test", - Role: "agent", - Active: true, - } - require.NoError(t, db.Create(existing).Error) - - cfg := newTestSAMLConfig() - svc := &SAMLService{cfg: cfg, db: db} - - userInfo := &SAMLUserInfo{ - NameID: "bob@saml.test", - Email: "bob@example.com", - DisplayName: "Bob Updated", - Attributes: map[string]string{}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.Equal(t, existing.ID, user.ID) // same user - assert.Equal(t, "Bob Updated", user.Name) // name updated -} - -func TestSAMLService_FindOrCreateUser_LinkExistingEmail(t *testing.T) { - db := newTestDB(t) - - // Create an email-authenticated user (no SAML yet) - existing := &model.User{ - Name: "Charlie Email", - Email: "charlie@example.com", - Provider: "email", - Role: "agent", - Active: true, - } - require.NoError(t, db.Create(existing).Error) - - cfg := newTestSAMLConfig() - svc := &SAMLService{cfg: cfg, db: db} - - userInfo := &SAMLUserInfo{ - NameID: "charlie@saml.test", - Email: "charlie@example.com", - DisplayName: "Charlie SAML", - Attributes: map[string]string{}, - } - - user, err := svc.FindOrCreateUser(userInfo) - require.NoError(t, err) - assert.Equal(t, existing.ID, user.ID) // linked same user - assert.Equal(t, "saml", user.Provider) // provider updated to saml - assert.Equal(t, "charlie@saml.test", user.UID) // UID set -} - -func TestSAMLService_FindOrCreateUser_NoDB(t *testing.T) { - cfg := newTestSAMLConfig() - svc := &SAMLService{cfg: cfg, db: nil} - - userInfo := &SAMLUserInfo{ - NameID: "nodb@test.com", - Email: "nodb@test.com", - } - - _, err := svc.FindOrCreateUser(userInfo) - assert.Error(t, err) - assert.Contains(t, err.Error(), "database not available") -} - -// --- SAMLUserInfo Tests --- - -func TestSAMLUserInfo_DisplayNameFallback(t *testing.T) { - // When displayName attribute is missing, compose from firstName + lastName - userInfo := &SAMLUserInfo{ - NameID: "fallback@test.com", - FirstName: "John", - LastName: "Doe", - DisplayName: "", // empty — should be composed - } - // DisplayName composition happens in ProcessResponse, not in the struct itself - // But let's verify the logic separately - name := "" - if userInfo.DisplayName == "" && (userInfo.FirstName != "" || userInfo.LastName != "") { - name = fmt.Sprintf("%s %s", userInfo.FirstName, userInfo.LastName) - } - assert.Equal(t, "John Doe", name) -} - -// --- EncodeSAMLRequest helper test --- - -func TestEncodeSAMLRequest(t *testing.T) { - encoded, err := EncodeSAMLRequest("") - require.NoError(t, err) - assert.NotEmpty(t, encoded) -} - -// --- SP Metadata Generation Test (with enabled service) --- - -func TestSAMLService_Enabled_GetSPMetadata(t *testing.T) { - cfg := newTestSAMLConfigEnabled() - svc, err := NewSAMLService(cfg, nil, nil) - require.NoError(t, err) - - xml, err := svc.GetSPMetadata() - require.NoError(t, err) - assert.NotEmpty(t, xml) - assert.Contains(t, string(xml), "EntityDescriptor") - assert.Contains(t, string(xml), cfg.SPEntityID) -} \ No newline at end of file diff --git a/backend/internal/auth/sso_middleware.go b/backend/internal/auth/sso_middleware.go index 2574cfda..12f667e1 100644 --- a/backend/internal/auth/sso_middleware.go +++ b/backend/internal/auth/sso_middleware.go @@ -2,18 +2,17 @@ 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. +// 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. 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. +// 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 (SAML, LDAP, OIDC) -// 3. Middleware delegates to the appropriate service (SAMLService, LDAPService, OIDCService) +// 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 @@ -21,11 +20,8 @@ package auth // 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 +// - Override via query param: ?provider=oidc import ( "context" @@ -49,8 +45,6 @@ import ( type SSOProviderType string const ( - SSOProviderSAML SSOProviderType = "saml" - SSOProviderLDAP SSOProviderType = "ldap" SSOProviderOIDC SSOProviderType = "oidc" ) @@ -74,8 +68,6 @@ type SSOMiddleware struct { db *gorm.DB rdb redis.Cmdable cfg *config.Config - samlService *SAMLService - ldapService *LDAPService oidcService *OIDCService jwtSecret string jwtExpiry time.Duration @@ -86,8 +78,6 @@ 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 @@ -99,8 +89,6 @@ func NewSSOMiddleware( db: db, rdb: rdb, cfg: cfg, - samlService: samlSvc, - ldapService: ldapSvc, oidcService: oidcSvc, jwtSecret: cfg.JWT.Secret, jwtExpiry: jwtExpiry, @@ -124,8 +112,8 @@ func (m *SSOMiddleware) ResolveProvider(ctx context.Context, accountID uint, pro return pt, nil } - // Priority: SAML > OIDC > LDAP (SAML is the most mature enterprise SSO protocol) - providers := []SSOProviderType{SSOProviderSAML, SSOProviderOIDC, SSOProviderLDAP} + // Priority: OIDC (only enterprise SSO protocol supported) + providers := []SSOProviderType{SSOProviderOIDC} for _, pt := range providers { active, err := m.isProviderActive(ctx, accountID, pt) if err != nil { @@ -143,34 +131,6 @@ func (m *SSOMiddleware) ResolveProvider(ctx context.Context, accountID uint, pro // 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 @@ -190,50 +150,6 @@ func (m *SSOMiddleware) isProviderActive(ctx context.Context, accountID uint, pr } } -// 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) { @@ -391,24 +307,6 @@ func (m *SSOMiddleware) SSOSessionValidator() gin.HandlerFunc { // --- 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) { @@ -432,11 +330,6 @@ func (m *SSOMiddleware) findOrCreateUser(ctx context.Context, accountID uint, em // 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) @@ -444,10 +337,6 @@ func (m *SSOMiddleware) findOrCreateUser(ctx context.Context, accountID uint, em 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 { diff --git a/backend/internal/auth/sso_middleware_test.go b/backend/internal/auth/sso_middleware_test.go index 3eafce71..014372ef 100644 --- a/backend/internal/auth/sso_middleware_test.go +++ b/backend/internal/auth/sso_middleware_test.go @@ -32,8 +32,6 @@ func newSSOTestDB(t *testing.T) *gorm.DB { }) require.NoError(t, err) err = db.AutoMigrate( - &model.AccountSamlSettings{}, - &model.AccountLDAPSettings{}, &model.AccountOIDCSettings{}, &model.User{}, &model.Account{}, @@ -51,40 +49,16 @@ func newSSOTestRedis(t *testing.T) (*miniredis.Miniredis, redis.Cmdable) { return mr, rdb } -func newSSOTestConfig(samlEnabled, ldapEnabled, oidcEnabled bool) *config.Config { +func newSSOTestConfig(oidcEnabled bool) *config.Config { return &config.Config{ JWT: config.JWTConfig{ - Secret: "test-sso-jwt-secret", - ExpiryHours: 24, - Issuer: "gochat-test", + Secret: "test-sso-jwt-secret", + ExpiryHours: 24, + Issuer: "gochat-test", }, Session: config.SessionConfig{ ExpirySeconds: 86400, }, - SAML: config.SAMLConfig{ - Enabled: samlEnabled, - SPEntityID: "https://sp.example.com/saml", - ACSURL: "https://sp.example.com/saml/acs", - IdPMetadataURL: "", - IdPMetadataXML: "", - SPPrivateKey: "", - SPCertificate: "", - AttributeMap: config.SAMLAttributeMap{Email: "email", DisplayName: "displayName", FirstName: "firstName", LastName: "lastName"}, - }, - LDAP: config.LDAPConfig{ - Enabled: ldapEnabled, - DefaultHost: "localhost", - DefaultPort: 389, - DefaultUseTLS: false, - DefaultBaseDN: "dc=example,dc=com", - DefaultBindDN: "cn=admin,dc=example,dc=com", - DefaultBindPassword: "adminpassword", - DefaultUserFilter: "(uid=%s)", - DefaultEmailAttribute: "mail", - DefaultNameAttribute: "cn", - DefaultGroupAttribute: "memberOf", - SyncInterval: 300, - }, OIDC: config.OIDCConfig{ Enabled: oidcEnabled, DefaultClientID: "test-client-id", @@ -98,54 +72,7 @@ func newSSOTestConfig(samlEnabled, ldapEnabled, oidcEnabled bool) *config.Config func newSSOMiddlewareForTest(t *testing.T, db *gorm.DB, rdb redis.Cmdable, cfg *config.Config) *SSOMiddleware { t.Helper() - return NewSSOMiddleware(db, rdb, cfg, nil, nil, nil) -} - -func seedAccountSAMLSettings(t *testing.T, db *gorm.DB, accountID uint, active bool) { - t.Helper() - settings := model.AccountSamlSettings{ - AccountID: accountID, - IdpEntityID: "https://idp.example.com/saml", - IdpSsoTargetURL: "https://idp.example.com/saml/sso", - IdpCertificate: "MIIC...", - SpEntityID: "https://sp.example.com/saml", - // Use true on Create so GORM includes it (zero-value bools are skipped). - Active: true, - } - require.NoError(t, db.Create(&settings).Error) - // Now update to the desired value using map (bypasses GORM zero-value skipping). - if !active { - require.NoError(t, db.Model(&settings).Update("active", false).Error) - } -} - -func seedAccountLDAPSettings(t *testing.T, db *gorm.DB, accountID uint, active bool, autoProvision bool) { - t.Helper() - settings := model.AccountLDAPSettings{ - AccountID: accountID, - Host: "ldap.example.com", - Port: 389, - BaseDN: "dc=example,dc=com", - UserFilter: "(uid=%s)", - EmailAttribute: "mail", - NameAttribute: "cn", - GroupAttribute: "memberOf", - // Use true for both bools on Create so GORM includes them (zero-value bools are skipped). - AutoProvision: true, - Active: true, - } - require.NoError(t, db.Create(&settings).Error) - // Now update to the desired values using map (bypasses GORM zero-value skipping). - updates := map[string]interface{}{} - if !autoProvision { - updates["auto_provision"] = false - } - if !active { - updates["active"] = false - } - if len(updates) > 0 { - require.NoError(t, db.Model(&settings).Updates(updates).Error) - } + return NewSSOMiddleware(db, rdb, cfg, nil) } func seedAccountOIDCSettings(t *testing.T, db *gorm.DB, accountID uint, active bool, autoProvision bool) { @@ -158,7 +85,7 @@ func seedAccountOIDCSettings(t *testing.T, db *gorm.DB, accountID uint, active b IssuerURL: "https://oidc.example.com", // Use true for both bools on Create so GORM includes them (zero-value bools are skipped). AutoProvision: true, - Active: true, + Active: true, } require.NoError(t, db.Create(&settings).Error) // Now update to the desired values using map (bypasses GORM zero-value skipping). @@ -196,9 +123,9 @@ func seedAccountAndUser(t *testing.T, db *gorm.DB, accountName, userName, userEm func TestNewSSOMiddleware_Constructor(t *testing.T) { mr, rdb := newSSOTestRedis(t) db := newSSOTestDB(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) - mw := NewSSOMiddleware(db, rdb, cfg, nil, nil, nil) + mw := NewSSOMiddleware(db, rdb, cfg, nil) require.NotNil(t, mw) assert.Equal(t, db, mw.db) @@ -208,8 +135,6 @@ func TestNewSSOMiddleware_Constructor(t *testing.T) { // Session.ExpirySeconds=86400 => jwtExpiry = 86400s = 24h assert.Equal(t, 24*time.Hour, mw.jwtExpiry) // Services are nil since we passed nil - assert.Nil(t, mw.samlService) - assert.Nil(t, mw.ldapService) assert.Nil(t, mw.oidcService) // Verify miniredis is accessible @@ -221,11 +146,11 @@ func TestNewSSOMiddleware_DefaultJWTExpiry(t *testing.T) { db := newSSOTestDB(t) // No Session.ExpirySeconds => defaults to 24h cfg := &config.Config{ - JWT: config.JWTConfig{Secret: "secret"}, + JWT: config.JWTConfig{Secret: "secret"}, Session: config.SessionConfig{ExpirySeconds: 0}, // zero means use default } - mw := NewSSOMiddleware(db, rdb, cfg, nil, nil, nil) + mw := NewSSOMiddleware(db, rdb, cfg, nil) assert.Equal(t, 24*time.Hour, mw.jwtExpiry) } @@ -233,48 +158,20 @@ func TestNewSSOMiddleware_CustomJWTExpiry(t *testing.T) { _, rdb := newSSOTestRedis(t) db := newSSOTestDB(t) cfg := &config.Config{ - JWT: config.JWTConfig{Secret: "secret"}, + JWT: config.JWTConfig{Secret: "secret"}, Session: config.SessionConfig{ExpirySeconds: 7200}, // 2 hours } - mw := NewSSOMiddleware(db, rdb, cfg, nil, nil, nil) + mw := NewSSOMiddleware(db, rdb, cfg, nil) assert.Equal(t, 7200*time.Second, mw.jwtExpiry) } // ========== Test 2: ResolveProvider with explicit hint ========== -func TestResolveProvider_ExplicitHintSAML(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // Seed SAML settings for account 1 - seedAccountSAMLSettings(t, db, 1, true) - - provider, err := mw.ResolveProvider(context.Background(), 1, "saml") - require.NoError(t, err) - assert.Equal(t, SSOProviderSAML, provider) -} - -func TestResolveProvider_ExplicitHintLDAP(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // Seed LDAP settings for account 1 - seedAccountLDAPSettings(t, db, 1, true, true) - - provider, err := mw.ResolveProvider(context.Background(), 1, "ldap") - require.NoError(t, err) - assert.Equal(t, SSOProviderLDAP, provider) -} - func TestResolveProvider_ExplicitHintOIDC(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Seed OIDC settings for account 1 @@ -288,38 +185,38 @@ func TestResolveProvider_ExplicitHintOIDC(t *testing.T) { func TestResolveProvider_ExplicitHintCaseInsensitive(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - seedAccountLDAPSettings(t, db, 1, true, true) + seedAccountOIDCSettings(t, db, 1, true, true) - provider, err := mw.ResolveProvider(context.Background(), 1, "LDAP") + provider, err := mw.ResolveProvider(context.Background(), 1, "OIDC") require.NoError(t, err) - assert.Equal(t, SSOProviderLDAP, provider) + assert.Equal(t, SSOProviderOIDC, provider) } func TestResolveProvider_ExplicitHintNotActive(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - // LDAP is globally enabled, but no per-account LDAP settings - cfg := newSSOTestConfig(false, true, false) + // OIDC is globally enabled, but no per-account OIDC settings + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - // No per-account LDAP settings seeded for account 99 - // isProviderActive for LDAP: global enabled + no per-account => falls back to global enabled = true - provider, err := mw.ResolveProvider(context.Background(), 99, "ldap") + // No per-account OIDC settings seeded for account 99 + // isProviderActive for OIDC: global enabled + no per-account => falls back to global enabled = true + provider, err := mw.ResolveProvider(context.Background(), 99, "oidc") require.NoError(t, err) - assert.Equal(t, SSOProviderLDAP, provider) + assert.Equal(t, SSOProviderOIDC, provider) } func TestResolveProvider_ExplicitHintDisabledGlobally(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - // All providers globally disabled - cfg := newSSOTestConfig(false, false, false) + // OIDC globally disabled + cfg := newSSOTestConfig(false) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - _, err := mw.ResolveProvider(context.Background(), 1, "saml") + _, err := mw.ResolveProvider(context.Background(), 1, "oidc") require.Error(t, err) assert.Contains(t, err.Error(), "not active") } @@ -327,7 +224,7 @@ func TestResolveProvider_ExplicitHintDisabledGlobally(t *testing.T) { func TestResolveProvider_ExplicitHintUnknownProvider(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) + cfg := newSSOTestConfig(false) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) _, err := mw.ResolveProvider(context.Background(), 1, "unknown_provider") @@ -337,71 +234,30 @@ func TestResolveProvider_ExplicitHintUnknownProvider(t *testing.T) { // ========== Test 3: ResolveProvider with auto-detection ========== -func TestResolveProvider_AutoDetection_SAMLPriority(t *testing.T) { +func TestResolveProvider_AutoDetection_OIDC(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - // Seed all three provider settings for account 1 - seedAccountSAMLSettings(t, db, 1, true) - seedAccountOIDCSettings(t, db, 1, true, true) - seedAccountLDAPSettings(t, db, 1, true, true) - - // SAML should win (priority: SAML > OIDC > LDAP) - provider, err := mw.ResolveProvider(context.Background(), 1, "") - require.NoError(t, err) - assert.Equal(t, SSOProviderSAML, provider) -} - -func TestResolveProvider_AutoDetection_OIDCWhenNoSAML(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) - _ = newSSOMiddlewareForTest(t, db, rdb, cfg) - - // SAML globally enabled but no per-account SAML => fallback to global - // Since SAML is globally enabled but no per-account record, it falls back to cfg.SAML.Enabled=true - // Actually, isProviderActive checks: if cfg.SAML.Enabled, then look for per-account settings - // If no per-account record (ErrRecordNotFound), falls back to cfg.SAML.Enabled = true - // So SAML would still be detected as active via fallback - // Let's make SAML globally disabled to test OIDC priority - - cfg2 := newSSOTestConfig(false, true, true) - mw2 := newSSOMiddlewareForTest(t, db, rdb, cfg2) - seedAccountOIDCSettings(t, db, 2, true, true) - seedAccountLDAPSettings(t, db, 2, true, true) - provider, err := mw2.ResolveProvider(context.Background(), 2, "") + provider, err := mw.ResolveProvider(context.Background(), 2, "") require.NoError(t, err) assert.Equal(t, SSOProviderOIDC, provider) } -func TestResolveProvider_AutoDetection_LDAPWhenNoOthers(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - seedAccountLDAPSettings(t, db, 3, true, true) - - provider, err := mw.ResolveProvider(context.Background(), 3, "") - require.NoError(t, err) - assert.Equal(t, SSOProviderLDAP, provider) -} - func TestResolveProvider_AutoDetection_GlobalFallback(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - // SAML globally enabled, no per-account settings => falls back to global config - cfg := newSSOTestConfig(true, false, false) + // OIDC globally enabled, no per-account settings => falls back to global config + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - // No per-account settings seeded — should fall back to global SAML.Enabled=true + // No per-account settings seeded — should fall back to global OIDC.Enabled=true provider, err := mw.ResolveProvider(context.Background(), 99, "") require.NoError(t, err) - assert.Equal(t, SSOProviderSAML, provider) + assert.Equal(t, SSOProviderOIDC, provider) } // ========== Test 4: ResolveProvider when no provider is configured ========== @@ -409,7 +265,7 @@ func TestResolveProvider_AutoDetection_GlobalFallback(t *testing.T) { func TestResolveProvider_NoProviderConfigured(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) + cfg := newSSOTestConfig(false) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) _, err := mw.ResolveProvider(context.Background(), 1, "") @@ -417,101 +273,29 @@ func TestResolveProvider_NoProviderConfigured(t *testing.T) { assert.Contains(t, err.Error(), "no SSO provider configured") } -func TestResolveProvider_PerAccountSAMLInactive(t *testing.T) { +func TestResolveProvider_PerAccountOIDCInactive(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, false, false) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - // Seed inactive SAML settings — active=false - seedAccountSAMLSettings(t, db, 1, false) + // Seed inactive OIDC settings — active=false + seedAccountOIDCSettings(t, db, 1, false, true) // Per-account settings exist but are inactive, so isProviderActive won't find active=true record - // ErrRecordNotFound for "active=true" query, falls back to global SAML.Enabled=true + // ErrRecordNotFound for "active=true" query, falls back to global OIDC.Enabled=true provider, err := mw.ResolveProvider(context.Background(), 1, "") require.NoError(t, err) // Falls back to global enabled - assert.Equal(t, SSOProviderSAML, provider) + assert.Equal(t, SSOProviderOIDC, provider) } // ========== Test 5: isProviderActive ========== -func TestIsProviderActive_SAML_EnabledWithPerAccount(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - seedAccountSAMLSettings(t, db, 1, true) - - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderSAML) - require.NoError(t, err) - assert.True(t, active) -} - -func TestIsProviderActive_SAML_EnabledNoPerAccount(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // No per-account settings — falls back to global Enabled=true - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderSAML) - require.NoError(t, err) - assert.True(t, active) -} - -func TestIsProviderActive_SAML_DisabledGlobally(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderSAML) - require.NoError(t, err) - assert.False(t, active) -} - -func TestIsProviderActive_LDAP_EnabledWithPerAccount(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - seedAccountLDAPSettings(t, db, 1, true, true) - - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderLDAP) - require.NoError(t, err) - assert.True(t, active) -} - -func TestIsProviderActive_LDAP_EnabledNoPerAccount(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // Falls back to global Enabled=true - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderLDAP) - require.NoError(t, err) - assert.True(t, active) -} - -func TestIsProviderActive_LDAP_DisabledGlobally(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - active, err := mw.isProviderActive(context.Background(), 1, SSOProviderLDAP) - require.NoError(t, err) - assert.False(t, active) -} - func TestIsProviderActive_OIDC_EnabledWithPerAccount(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) seedAccountOIDCSettings(t, db, 1, true, true) @@ -524,7 +308,7 @@ func TestIsProviderActive_OIDC_EnabledWithPerAccount(t *testing.T) { func TestIsProviderActive_OIDC_EnabledNoPerAccount(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Falls back to global Enabled=true @@ -536,7 +320,7 @@ func TestIsProviderActive_OIDC_EnabledNoPerAccount(t *testing.T) { func TestIsProviderActive_OIDC_DisabledGlobally(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) + cfg := newSSOTestConfig(false) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) active, err := mw.isProviderActive(context.Background(), 1, SSOProviderOIDC) @@ -547,7 +331,7 @@ func TestIsProviderActive_OIDC_DisabledGlobally(t *testing.T) { func TestIsProviderActive_UnknownProvider(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, false) + cfg := newSSOTestConfig(false) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) active, err := mw.isProviderActive(context.Background(), 1, SSOProviderType("unknown")) @@ -559,13 +343,9 @@ func TestIsProviderActive_UnknownProvider(t *testing.T) { // ========== Test 6: SSOProviderType constants ========== func TestSSOProviderType_Constants(t *testing.T) { - assert.Equal(t, SSOProviderType("saml"), SSOProviderSAML) - assert.Equal(t, SSOProviderType("ldap"), SSOProviderLDAP) assert.Equal(t, SSOProviderType("oidc"), SSOProviderOIDC) // Verify string representation - assert.Equal(t, "saml", string(SSOProviderSAML)) - assert.Equal(t, "ldap", string(SSOProviderLDAP)) assert.Equal(t, "oidc", string(SSOProviderOIDC)) } @@ -573,7 +353,7 @@ func TestSSOProviderType_Constants(t *testing.T) { func TestSSOAuthResult_Fields(t *testing.T) { result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 42, AccountID: 1, Email: "user@example.com", @@ -586,7 +366,7 @@ func TestSSOAuthResult_Fields(t *testing.T) { AutoProvision: false, } - assert.Equal(t, SSOProviderSAML, result.Provider) + assert.Equal(t, SSOProviderOIDC, result.Provider) assert.Equal(t, uint(42), result.UserID) assert.Equal(t, uint(1), result.AccountID) assert.Equal(t, "user@example.com", result.Email) @@ -616,12 +396,6 @@ func TestSSOAuthResult_DefaultValues(t *testing.T) { } func TestSSOAuthResult_ProviderTypeVariants(t *testing.T) { - samlResult := &SSOAuthResult{Provider: SSOProviderSAML} - assert.Equal(t, "saml", string(samlResult.Provider)) - - ldapResult := &SSOAuthResult{Provider: SSOProviderLDAP} - assert.Equal(t, "ldap", string(ldapResult.Provider)) - oidcResult := &SSOAuthResult{Provider: SSOProviderOIDC} assert.Equal(t, "oidc", string(oidcResult.Provider)) } @@ -631,11 +405,11 @@ func TestSSOAuthResult_ProviderTypeVariants(t *testing.T) { func TestIssueJWT_ValidResult(t *testing.T) { db := newSSOTestDB(t) mr, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 42, AccountID: 1, Email: "user@example.com", @@ -664,7 +438,7 @@ func TestIssueJWT_ValidResult(t *testing.T) { assert.Equal(t, float64(42), claims["user_id"]) assert.Equal(t, float64(1), claims["account_id"]) assert.Equal(t, "administrator", claims["role"]) - assert.Equal(t, "saml", claims["provider"]) + assert.Equal(t, "oidc", claims["provider"]) assert.Equal(t, "nameid-123", claims["subject"]) assert.Equal(t, "user@example.com", claims["email"]) @@ -679,7 +453,7 @@ func TestIssueJWT_ValidResult(t *testing.T) { func TestIssueJWT_OIDCResult(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ @@ -711,11 +485,11 @@ func TestIssueJWT_OIDCResult(t *testing.T) { func TestIssueJWT_ZeroUserID(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 0, // zero — unprovisioned AccountID: 1, Email: "newuser@example.com", @@ -730,7 +504,7 @@ func TestIssueJWT_ZeroUserID(t *testing.T) { func TestIssueJWT_EmptyProvider(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Even with empty provider, IssueJWT should succeed if UserID > 0 @@ -759,16 +533,16 @@ func TestIssueJWT_EmptyProvider(t *testing.T) { func TestIssueJWT_EmptyEmail(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // IssueJWT doesn't validate email; it just puts whatever fields are there in claims result := &SSOAuthResult{ - Provider: SSOProviderLDAP, + Provider: SSOProviderOIDC, UserID: 10, AccountID: 1, Email: "", - Subject: "cn=user,dc=example", + Subject: "sub-oidc", Role: "agent", } @@ -789,12 +563,11 @@ func TestIssueJWT_EmptyEmail(t *testing.T) { func TestSSOSessionValidator_ValidSession(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - // Create an SSO session in Redis manually result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 42, AccountID: 1, Email: "user@example.com", @@ -827,12 +600,12 @@ func TestSSOSessionValidator_ValidSession(t *testing.T) { assert.Equal(t, sessionID, sessionData.SessionID) assert.Equal(t, uint(42), sessionData.UserID) assert.Equal(t, uint(1), sessionData.AccountID) - assert.Equal(t, "saml", sessionData.Provider) + assert.Equal(t, "oidc", sessionData.Provider) assert.Equal(t, "administrator", sessionData.Role) ssoProvider, exists := c.Get("sso_provider") assert.True(t, exists) - assert.Equal(t, "saml", ssoProvider) + assert.Equal(t, "oidc", ssoProvider) userID, exists := c.Get("user_id") assert.True(t, exists) @@ -850,7 +623,7 @@ func TestSSOSessionValidator_ValidSession(t *testing.T) { func TestSSOSessionValidator_QueryParam(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ @@ -882,7 +655,7 @@ func TestSSOSessionValidator_QueryParam(t *testing.T) { func TestSSOSessionValidator_NoSessionProvided(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) gin.SetMode(gin.TestMode) @@ -901,11 +674,11 @@ func TestSSOSessionValidator_NoSessionProvided(t *testing.T) { func TestSSOSessionValidator_ExpiredSession(t *testing.T) { db := newSSOTestDB(t) mr, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 42, AccountID: 1, Email: "user@example.com", @@ -935,7 +708,7 @@ func TestSSOSessionValidator_ExpiredSession(t *testing.T) { func TestSSOSessionValidator_CorruptSessionData(t *testing.T) { db := newSSOTestDB(t) mr, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Put corrupt JSON in Redis @@ -961,14 +734,14 @@ func TestSSOSessionValidator_CorruptSessionData(t *testing.T) { func TestFindOrCreateUser_FindExisting(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Pre-create a user - account, user := seedAccountAndUser(t, db, "Test Account", "Existing User", "existing@example.com", "saml") + account, user := seedAccountAndUser(t, db, "Test Account", "Existing User", "existing@example.com", "oidc") // findOrCreateUser should find the existing user - foundUser, err := mw.findOrCreateUser(context.Background(), account.ID, "existing@example.com", "Existing User", "nameid-1", "saml", "agent") + foundUser, err := mw.findOrCreateUser(context.Background(), account.ID, "existing@example.com", "Existing User", "nameid-1", "oidc", "agent") require.NoError(t, err) assert.Equal(t, user.ID, foundUser.ID) assert.Equal(t, "existing@example.com", foundUser.Email) @@ -978,7 +751,7 @@ func TestFindOrCreateUser_FindExisting(t *testing.T) { func TestFindOrCreateUser_CreateNewUser(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // Create an account @@ -986,12 +759,12 @@ func TestFindOrCreateUser_CreateNewUser(t *testing.T) { require.NoError(t, db.Create(account).Error) // findOrCreateUser should create a new user - newUser, err := mw.findOrCreateUser(context.Background(), account.ID, "newuser@example.com", "New User", "uid-new", "ldap", "agent") + newUser, err := mw.findOrCreateUser(context.Background(), account.ID, "newuser@example.com", "New User", "uid-new", "oidc", "agent") require.NoError(t, err) assert.NotEqual(t, uint(0), newUser.ID) assert.Equal(t, "newuser@example.com", newUser.Email) assert.Equal(t, "New User", newUser.Name) - assert.Equal(t, "ldap", newUser.Provider) + assert.Equal(t, "oidc", newUser.Provider) assert.Equal(t, "uid-new", newUser.UID) // Verify account membership was created @@ -1001,22 +774,6 @@ func TestFindOrCreateUser_CreateNewUser(t *testing.T) { assert.Equal(t, "agent", au.Role) } -func TestFindOrCreateUser_LDAPAutoProvisionDisabled(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // Create account with LDAP settings where AutoProvision=false - account := &model.Account{Name: "LDAP No Provision"} - require.NoError(t, db.Create(account).Error) - seedAccountLDAPSettings(t, db, account.ID, true, false) // autoProvision=false - - _, err := mw.findOrCreateUser(context.Background(), account.ID, "newldap@example.com", "LDAP User", "cn=user", "ldap", "agent") - require.Error(t, err) - assert.Contains(t, err.Error(), "auto-provision is disabled") -} - func TestFindOrCreateUser_OIDCAutoProvisionDisabled(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) @@ -1026,7 +783,7 @@ func TestFindOrCreateUser_OIDCAutoProvisionDisabled(t *testing.T) { // When oidcSettings is nil, autoProvision defaults to true. // We'll test that default behavior here. - cfg := newSSOTestConfig(false, false, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // oidcService is nil account := &model.Account{Name: "OIDC Account"} @@ -1041,66 +798,25 @@ func TestFindOrCreateUser_OIDCAutoProvisionDisabled(t *testing.T) { func TestFindOrCreateUser_DBNil(t *testing.T) { _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) // SSOMiddleware with nil DB - mw := NewSSOMiddleware(nil, rdb, cfg, nil, nil, nil) + mw := NewSSOMiddleware(nil, rdb, cfg, nil) - _, err := mw.findOrCreateUser(context.Background(), 1, "test@example.com", "Test", "uid-1", "saml", "agent") + _, err := mw.findOrCreateUser(context.Background(), 1, "test@example.com", "Test", "uid-1", "oidc", "agent") require.Error(t, err) assert.Contains(t, err.Error(), "database not available") } -func TestFindOrCreateUser_SAMLProvider(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, false, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - account := &model.Account{Name: "SAML Account"} - require.NoError(t, db.Create(account).Error) - - // SAML always auto-provisions (no AutoProvision field on AccountSamlSettings) - newUser, err := mw.findOrCreateUser(context.Background(), account.ID, "samluser@example.com", "SAML User", "nameid-saml", "saml", "administrator") - require.NoError(t, err) - assert.NotEqual(t, uint(0), newUser.ID) - assert.Equal(t, "samluser@example.com", newUser.Email) - assert.Equal(t, "saml", newUser.Provider) - - // Verify account membership with administrator role - var au model.AccountUser - err = db.Where("account_id = ? AND user_id = ?", account.ID, newUser.ID).First(&au).Error - require.NoError(t, err) - assert.Equal(t, "administrator", au.Role) -} - -func TestFindOrCreateUser_LDAPMapsProviderInfo(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - account := &model.Account{Name: "LDAP Account"} - require.NoError(t, db.Create(account).Error) - seedAccountLDAPSettings(t, db, account.ID, true, true) - - newUser, err := mw.findOrCreateUser(context.Background(), account.ID, "ldapuser@example.com", "LDAP User", "cn=ldapuser,dc=example,dc=com", "ldap", "agent") - require.NoError(t, err) - assert.Equal(t, "ldapuser@example.com", newUser.Email) - assert.Equal(t, "LDAP User", newUser.Name) - assert.Equal(t, "ldap", newUser.Provider) - assert.Equal(t, "cn=ldapuser,dc=example,dc=com", newUser.UID) -} - // ========== Additional tests: CreateSSOSession ========== func TestCreateSSOSession_StoresInRedis(t *testing.T) { db := newSSOTestDB(t) mr, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ - Provider: SSOProviderSAML, + Provider: SSOProviderOIDC, UserID: 42, AccountID: 1, Email: "user@example.com", @@ -1124,15 +840,15 @@ func TestCreateSSOSession_StoresInRedis(t *testing.T) { func TestCreateSSOSession_SessionDataContent(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) result := &SSOAuthResult{ - Provider: SSOProviderLDAP, + Provider: SSOProviderOIDC, UserID: 99, AccountID: 5, - Email: "ldap@example.com", - Subject: "cn=user,dc=example", + Email: "oidc@example.com", + Subject: "sub-oidc", Role: "agent", } @@ -1150,32 +866,19 @@ func TestCreateSSOSession_SessionDataContent(t *testing.T) { assert.Equal(t, sessionID, sessionData.SessionID) assert.Equal(t, uint(99), sessionData.UserID) assert.Equal(t, uint(5), sessionData.AccountID) - assert.Equal(t, "ldap", sessionData.Provider) - assert.Equal(t, "cn=user,dc=example", sessionData.NameID) + assert.Equal(t, "oidc", sessionData.Provider) + assert.Equal(t, "sub-oidc", sessionData.NameID) assert.Equal(t, "agent", sessionData.Role) assert.NotZero(t, sessionData.CreatedAt) assert.NotZero(t, sessionData.ExpiresAt) } -// ========== Additional tests: AuthenticateLDAP with nil service ========== - -func TestAuthenticateLDAP_NilService(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // ldapService is nil - - _, err := mw.AuthenticateLDAP(context.Background(), 1, "user", "password") - require.Error(t, err) - assert.Contains(t, err.Error(), "LDAP service not initialized") -} - // ========== Additional tests: AuthenticateOIDC with nil service ========== func TestAuthenticateOIDC_NilService(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, false, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) // oidcService is nil _, err := mw.AuthenticateOIDC(context.Background(), "state", "code") @@ -1188,7 +891,7 @@ func TestAuthenticateOIDC_NilService(t *testing.T) { func TestEnsureAccountMembership_NewMembership(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) account := &model.Account{Name: "Membership Account"} @@ -1213,7 +916,7 @@ func TestEnsureAccountMembership_NewMembership(t *testing.T) { func TestEnsureAccountMembership_UpdateRole(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(true, true, true) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) account := &model.Account{Name: "Role Update Account"} @@ -1245,7 +948,7 @@ func TestSSOSessionData_JSONRoundTrip(t *testing.T) { session := SSOSessionData{ SessionID: "session-abc-123", UserID: 42, - Provider: "saml", + Provider: "oidc", IdPEntityID: "https://idp.example.com", NameID: "nameid-456", AccountID: 1, @@ -1271,80 +974,30 @@ func TestSSOSessionData_JSONRoundTrip(t *testing.T) { assert.Equal(t, session.ExpiresAt, decoded.ExpiresAt) } -// ========== Additional: getLDAPSettings ========== +// ========== Comprehensive integration: full SSO flow with OIDC ========== -func TestGetLDAPSettings_Active(t *testing.T) { +func TestSSOFlow_OIDCFindOrCreateThenSession(t *testing.T) { db := newSSOTestDB(t) _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - seedAccountLDAPSettings(t, db, 1, true, true) - - settings := mw.getLDAPSettings(1) - require.NotNil(t, settings) - assert.Equal(t, "ldap.example.com", settings.Host) - assert.Equal(t, uint(1), settings.AccountID) - assert.True(t, settings.Active) -} - -func TestGetLDAPSettings_Inactive(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - // Seed inactive settings — getLDAPSettings only looks for active=true - seedAccountLDAPSettings(t, db, 1, false, true) - - settings := mw.getLDAPSettings(1) - assert.Nil(t, settings) // inactive settings not found -} - -func TestGetLDAPSettings_NoSettings(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := newSSOMiddlewareForTest(t, db, rdb, cfg) - - settings := mw.getLDAPSettings(99) - assert.Nil(t, settings) // no settings for account 99 -} - -func TestGetLDAPSettings_NilDB(t *testing.T) { - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) - mw := NewSSOMiddleware(nil, rdb, cfg, nil, nil, nil) - - settings := mw.getLDAPSettings(1) - assert.Nil(t, settings) -} - -// ========== Comprehensive integration: full SSO flow with LDAP ========== - -func TestSSOFlow_LDAPFindOrCreateThenSession(t *testing.T) { - db := newSSOTestDB(t) - _, rdb := newSSOTestRedis(t) - cfg := newSSOTestConfig(false, true, false) + cfg := newSSOTestConfig(true) mw := newSSOMiddlewareForTest(t, db, rdb, cfg) account := &model.Account{Name: "Flow Account"} require.NoError(t, db.Create(account).Error) - seedAccountLDAPSettings(t, db, account.ID, true, true) // Step 1: Find/create user - user, err := mw.findOrCreateUser(context.Background(), account.ID, "flowuser@example.com", "Flow User", "cn=flowuser", "ldap", "agent") + user, err := mw.findOrCreateUser(context.Background(), account.ID, "flowuser@example.com", "Flow User", "sub-flowuser", "oidc", "agent") require.NoError(t, err) assert.NotEqual(t, uint(0), user.ID) // Step 2: Issue JWT result := &SSOAuthResult{ - Provider: SSOProviderLDAP, + Provider: SSOProviderOIDC, UserID: user.ID, AccountID: account.ID, Email: "flowuser@example.com", Name: "Flow User", - Subject: "cn=flowuser", + Subject: "sub-flowuser", Role: "agent", } @@ -1359,7 +1012,7 @@ func TestSSOFlow_LDAPFindOrCreateThenSession(t *testing.T) { require.NoError(t, err) claims := token.Claims.(jwt.MapClaims) assert.Equal(t, float64(user.ID), claims["user_id"]) - assert.Equal(t, "ldap", claims["provider"]) + assert.Equal(t, "oidc", claims["provider"]) // Step 4: Create SSO session sessionID, err := mw.CreateSSOSession(context.Background(), result) @@ -1376,4 +1029,4 @@ func TestSSOFlow_LDAPFindOrCreateThenSession(t *testing.T) { handler := mw.SSOSessionValidator() handler(c) assert.False(t, c.IsAborted()) -} \ No newline at end of file +} diff --git a/backend/internal/auth/sso_session_store.go b/backend/internal/auth/sso_session_store.go index cf5d01aa..4a62c3b7 100644 --- a/backend/internal/auth/sso_session_store.go +++ b/backend/internal/auth/sso_session_store.go @@ -11,7 +11,7 @@ import ( ) // SSOSessionData represents an SSO session for SSO/SLO support. -// Reference: M13 §5 — SSO session tracking for SAML/OIDC SLO +// Reference: M13 §5 — SSO session tracking for OIDC SLO type SSOSessionData struct { SessionID string `json:"session_id"` UserID uint `json:"user_id"` diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a3a131bd..df717a66 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -31,8 +31,6 @@ type Config struct { Log LogConfig `mapstructure:"log"` Worker WorkerConfig `mapstructure:"worker"` OAuth OAuthConfig `mapstructure:"oauth"` - SAML SAMLConfig `mapstructure:"saml"` - LDAP LDAPConfig `mapstructure:"ldap"` OIDC OIDCConfig `mapstructure:"oidc"` Push PushConfig `mapstructure:"push"` Notification NotificationConfig `mapstructure:"notification"` @@ -166,55 +164,9 @@ type LogConfig struct { Format string `mapstructure:"format"` // json, text } -// SAMLConfig holds SAML 2.0 Service Provider configuration. -// Reference: P2E §1.6 — SAML SP integration for enterprise SSO. -type SAMLConfig struct { - Enabled bool `mapstructure:"enabled"` - IdPMetadataURL string `mapstructure:"idp_metadata_url"` // URL to fetch IdP metadata XML - IdPMetadataXML string `mapstructure:"idp_metadata_xml"` // Inline IdP metadata XML (alternative to URL) - SPEntityID string `mapstructure:"sp_entity_id"` // Our SP entity ID - ACSURL string `mapstructure:"acs_url"` // Assertion Consumer Service URL - SPPrivateKey string `mapstructure:"sp_private_key"` // PEM-encoded SP private key - SPCertificate string `mapstructure:"sp_certificate"` // PEM-encoded SP certificate - AttributeMap SAMLAttributeMap `mapstructure:"attribute_map"` // SAML attribute → GoChat field mapping - ClockDriftTolerance int `mapstructure:"clock_drift_tolerance"` // seconds of allowed clock drift -} + // SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO. -// SAMLAttributeMap maps SAML assertion attributes to GoChat user fields. -type SAMLAttributeMap struct { - Email string `mapstructure:"email"` // SAML attribute name for email - DisplayName string `mapstructure:"display_name"` // SAML attribute name for display name - FirstName string `mapstructure:"first_name"` // SAML attribute name for first name - LastName string `mapstructure:"last_name"` // SAML attribute name for last name -} - -// ClockDriftDuration returns clock drift tolerance as a time.Duration. -func (c SAMLConfig) ClockDriftDuration() time.Duration { - if c.ClockDriftTolerance <= 0 { - return 180 * time.Second // default 3 minutes - } - return time.Duration(c.ClockDriftTolerance) * time.Second -} - -// LDAPConfig holds LDAP authentication configuration. -// Reference: M13 §4.4 — LDAP/Active Directory integration for enterprise authentication. -// Per-account LDAP settings override these defaults (stored in DB). -type LDAPConfig struct { - Enabled bool `mapstructure:"enabled"` - DefaultHost string `mapstructure:"default_host"` // default LDAP server host (e.g. ldap.example.com) - DefaultPort int `mapstructure:"default_port"` // default port (389 for LDAP, 636 for LDAPS) - DefaultUseTLS bool `mapstructure:"default_use_tls"` // use StartTLS on LDAP connection - DefaultBaseDN string `mapstructure:"default_base_dn"` // default search base DN (e.g. dc=example,dc=com) - DefaultBindDN string `mapstructure:"default_bind_dn"` // default bind DN for service account - DefaultBindPassword string `mapstructure:"default_bind_password"` // default bind password - DefaultUserFilter string `mapstructure:"default_user_filter"` // default LDAP user search filter - DefaultEmailAttribute string `mapstructure:"default_email_attribute"` // default email attribute (mail) - DefaultNameAttribute string `mapstructure:"default_name_attribute"` // default name attribute (cn) - DefaultGroupAttribute string `mapstructure:"default_group_attribute"` // default group attribute (memberOf) - SyncInterval int `mapstructure:"sync_interval"` // group sync interval in seconds (default: 3600) -} - -// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration. + // OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration. // Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration. // Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP. // Per-account OIDC settings override these defaults (stored in DB). @@ -307,24 +259,6 @@ func Load() (*Config, error) { viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() - // Set defaults for SAML - viper.SetDefault("saml.enabled", false) - viper.SetDefault("saml.clock_drift_tolerance", 180) - viper.SetDefault("saml.attribute_map.email", "email") - viper.SetDefault("saml.attribute_map.display_name", "displayName") - viper.SetDefault("saml.attribute_map.first_name", "firstName") - viper.SetDefault("saml.attribute_map.last_name", "lastName") - - // Set defaults for LDAP (M13) - viper.SetDefault("ldap.enabled", false) - viper.SetDefault("ldap.default_port", 389) - viper.SetDefault("ldap.default_use_tls", false) - viper.SetDefault("ldap.default_user_filter", "(objectClass=person)") - viper.SetDefault("ldap.default_email_attribute", "mail") - viper.SetDefault("ldap.default_name_attribute", "cn") - viper.SetDefault("ldap.default_group_attribute", "memberOf") - viper.SetDefault("ldap.sync_interval", 3600) - // Set defaults for OIDC (M13) viper.SetDefault("oidc.enabled", false) viper.SetDefault("oidc.default_scopes", []string{"openid", "profile", "email"}) @@ -499,7 +433,7 @@ func (r *ConfigReloader) applyReloadableFields(newCfg *Config) { // CORS allowed origins — safe to update whitelist at runtime r.cfg.Server.CORS = newCfg.Server.CORS - // NOTE: Database, Redis, JWT, SAML, OAuth secrets are NOT hot-reloaded. + // NOTE: Database, Redis, JWT, OAuth secrets are NOT hot-reloaded. // Changing these requires a full application restart. } @@ -776,13 +710,6 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.cors.max_age", 86400) v.SetDefault("server.cors.allow_credentials", false) - v.SetDefault("saml.enabled", false) - v.SetDefault("saml.clock_drift_tolerance", 180) - v.SetDefault("saml.attribute_map.email", "email") - v.SetDefault("saml.attribute_map.display_name", "displayName") - v.SetDefault("saml.attribute_map.first_name", "firstName") - v.SetDefault("saml.attribute_map.last_name", "lastName") - // CSRF defaults v.SetDefault("csrf.enabled", true) v.SetDefault("csrf.cookie_name", "_gochat_csrf") @@ -846,7 +773,7 @@ func applyZeroDefaults(cfg *Config) { cfg.CSRF.CookieSameSite = "Strict" } if len(cfg.CSRF.SkipPaths) == 0 { - cfg.CSRF.SkipPaths = []string{"/api/v1/auth/", "/health", "/api/v1/saml/", "/api/v1/ldap/", "/api/v1/oidc/"} + cfg.CSRF.SkipPaths = []string{"/api/v1/auth/", "/health", "/api/v1/oidc/"} } // Session defaults if cfg.Session.ExpirySeconds == 0 { diff --git a/backend/internal/handler/api/v1/account_saml_settings_handler.go b/backend/internal/handler/api/v1/account_saml_settings_handler.go deleted file mode 100644 index 7d10a07f..00000000 --- a/backend/internal/handler/api/v1/account_saml_settings_handler.go +++ /dev/null @@ -1,462 +0,0 @@ -package v1 - -// Reference: M13 §2 — Account-scoped SAML config admin API -// CRUD endpoints for enterprise administrators to configure SAML SSO for their accounts. -// Pattern follows Chatwoot AccountSamlSettings API (super_admin scoped). - -import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/internal/repository" - applogger "github.com/gochat/gochat/pkg/logger" - "github.com/gochat/gochat/pkg/response" -) - -// AccountSamlSettingsHandler handles account-scoped SAML configuration endpoints. -// Only accessible to account administrators (role: administrator or super_admin). -type AccountSamlSettingsHandler struct { - repo *repository.AccountSamlSettingsRepo -} - -// NewAccountSamlSettingsHandler creates a new AccountSamlSettings handler. -func NewAccountSamlSettingsHandler(repo *repository.AccountSamlSettingsRepo) *AccountSamlSettingsHandler { - return &AccountSamlSettingsHandler{repo: repo} -} - -// Get retrieves SAML settings for an account. -// GET /api/v1/accounts/:account_id/saml_settings -func (h *AccountSamlSettingsHandler) Get(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid account ID", - }, - }) - return - } - - settings, err := h.repo.GetByAccount(uint(accountID)) - if err != nil { - applogger.L().Errorf("Get SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SAML settings") - return - } - - if settings == nil { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML settings not found for this account", - }, - }) - return - } - - c.JSON(http.StatusOK, serializeSamlSettings(settings)) -} - -// Create creates SAML settings for an account. -// POST /api/v1/accounts/:account_id/saml_settings -// Body: JSON with IdP configuration fields. -func (h *AccountSamlSettingsHandler) Create(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid account ID", - }, - }) - return - } - - req, err := bindCreateSamlSettingsRequest(c) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid request body", - Detail: err.Error(), - }, - }) - return - } - - // Check if settings already exist for this account - existing, err := h.repo.GetByAccount(uint(accountID)) - if err != nil { - applogger.L().Errorf("Check existing SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to check existing settings") - return - } - if existing != nil { - c.JSON(http.StatusConflict, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "SAML settings already exist for this account", - }, - }) - return - } - - settings := req.ToModel(uint(accountID)) - if err := h.repo.Create(&settings); err != nil { - applogger.L().Errorf("Create SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create SAML settings") - return - } - - applogger.L().Infof("SAML settings created for account %d", accountID) - c.JSON(http.StatusCreated, serializeSamlSettings(&settings)) -} - -// Update updates SAML settings for an account. -// PUT /api/v1/accounts/:account_id/saml_settings -// Body: JSON with fields to update (partial update supported). -func (h *AccountSamlSettingsHandler) Update(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid account ID", - }, - }) - return - } - - req, err := bindUpdateSamlSettingsRequest(c) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid request body", - Detail: err.Error(), - }, - }) - return - } - - // Verify settings exist - existing, err := h.repo.GetByAccount(uint(accountID)) - if err != nil { - applogger.L().Errorf("Get SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get SAML settings") - return - } - if existing == nil { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML settings not found for this account", - }, - }) - return - } - - // Build updates map - updates := req.ToUpdatesMap() - if len(updates) == 0 { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "No fields to update", - }, - }) - return - } - - if err := h.repo.UpdateFields(uint(accountID), updates); err != nil { - applogger.L().Errorf("Update SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update SAML settings") - return - } - - // Return updated settings - settings, _ := h.repo.GetByAccount(uint(accountID)) - applogger.L().Infof("SAML settings updated for account %d", accountID) - c.JSON(http.StatusOK, serializeSamlSettings(settings)) -} - -// Delete removes SAML settings for an account. -// DELETE /api/v1/accounts/:account_id/saml_settings -func (h *AccountSamlSettingsHandler) Delete(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid account ID", - }, - }) - return - } - - if err := h.repo.Delete(uint(accountID)); err != nil { - applogger.L().Errorf("Delete SAML settings for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete SAML settings") - return - } - - applogger.L().Infof("SAML settings deleted for account %d", accountID) - c.JSON(http.StatusOK, response.APIResponse{ - Success: true, - Data: map[string]string{ - "message": "SAML settings deleted", - }, - }) -} - -// ToggleActive enables or disables SAML SSO for an account. -// POST /api/v1/accounts/:account_id/saml_settings/toggle_active -// Body: { "active": true/false } -func (h *AccountSamlSettingsHandler) ToggleActive(c *gin.Context) { - accountID, err := strconv.ParseUint(c.Param("account_id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Invalid account ID", - }, - }) - return - } - - var req ToggleActiveRequest - 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 - } - - if err := h.repo.SetActive(uint(accountID), req.Active); err != nil { - applogger.L().Errorf("Toggle SAML active for account %d: %v", accountID, err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to toggle SAML active status") - return - } - - status := "disabled" - if req.Active { - status = "enabled" - } - applogger.L().Infof("SAML SSO %s for account %d", status, accountID) - c.JSON(http.StatusOK, response.APIResponse{ - Success: true, - Data: map[string]interface{}{ - "account_id": accountID, - "active": req.Active, - "status": status, - }, - }) -} - -// --- Request/Response types --- - -// CreateSamlSettingsRequest is the request body for creating SAML settings. -type CreateSamlSettingsRequest struct { - SsoURL string `json:"sso_url"` - Certificate string `json:"certificate"` - IdpEntityID string `json:"idp_entity_id" binding:"required"` - IdpSsoTargetURL string `json:"idp_sso_target_url" binding:"required"` - IdpSloTargetURL string `json:"idp_slo_target_url"` - IdpCertificate string `json:"idp_certificate" binding:"required"` - SpEntityID string `json:"sp_entity_id"` - SpX509Certificate string `json:"sp_x509_certificate"` - SpPrivateKey string `json:"sp_private_key"` - RoleMappings json.RawMessage `json:"role_mappings"` - Active bool `json:"active"` -} - -// ToModel converts a CreateSamlSettingsRequest to an AccountSamlSettings model. -func (req *CreateSamlSettingsRequest) ToModel(accountID uint) model.AccountSamlSettings { - req.normalizeChatwootAliases() - return model.AccountSamlSettings{ - AccountID: accountID, - IdpEntityID: req.IdpEntityID, - IdpSsoTargetURL: req.IdpSsoTargetURL, - IdpSloTargetURL: req.IdpSloTargetURL, - IdpCertificate: req.IdpCertificate, - SpEntityID: req.SpEntityID, - SpX509Certificate: req.SpX509Certificate, - SpPrivateKey: req.SpPrivateKey, - RoleMappings: req.RoleMappings, - Active: req.Active, - } -} - -func (req *CreateSamlSettingsRequest) normalizeChatwootAliases() { - if req.IdpSsoTargetURL == "" { - req.IdpSsoTargetURL = req.SsoURL - } - if req.IdpCertificate == "" { - req.IdpCertificate = req.Certificate - } -} - -// UpdateSamlSettingsRequest is the request body for updating SAML settings. -// All fields are optional — only non-nil/non-zero fields will be updated. -type UpdateSamlSettingsRequest struct { - SsoURL *string `json:"sso_url"` - Certificate *string `json:"certificate"` - IdpEntityID *string `json:"idp_entity_id"` - IdpSsoTargetURL *string `json:"idp_sso_target_url"` - IdpSloTargetURL *string `json:"idp_slo_target_url"` - IdpCertificate *string `json:"idp_certificate"` - SpEntityID *string `json:"sp_entity_id"` - SpX509Certificate *string `json:"sp_x509_certificate"` - SpPrivateKey *string `json:"sp_private_key"` - RoleMappings json.RawMessage `json:"role_mappings"` - Active *bool `json:"active"` -} - -// ToUpdatesMap converts an UpdateSamlSettingsRequest to a map of fields to update. -func (req *UpdateSamlSettingsRequest) ToUpdatesMap() map[string]interface{} { - updates := make(map[string]interface{}) - if req.IdpSsoTargetURL == nil { - req.IdpSsoTargetURL = req.SsoURL - } - if req.IdpCertificate == nil { - req.IdpCertificate = req.Certificate - } - if req.IdpEntityID != nil { - updates["idp_entity_id"] = *req.IdpEntityID - } - if req.IdpSsoTargetURL != nil { - updates["idp_sso_target_url"] = *req.IdpSsoTargetURL - } - if req.IdpSloTargetURL != nil { - updates["idp_slo_target_url"] = *req.IdpSloTargetURL - } - if req.IdpCertificate != nil { - updates["idp_certificate"] = *req.IdpCertificate - } - if req.SpEntityID != nil { - updates["sp_entity_id"] = *req.SpEntityID - } - if req.SpX509Certificate != nil { - updates["sp_x509_certificate"] = *req.SpX509Certificate - } - if req.SpPrivateKey != nil { - updates["sp_private_key"] = *req.SpPrivateKey - } - if req.RoleMappings != nil { - updates["role_mappings"] = req.RoleMappings - } - if req.Active != nil { - updates["active"] = *req.Active - } - return updates -} - -// ToggleActiveRequest toggles the active status of SAML settings. -type ToggleActiveRequest struct { - Active bool `json:"active"` -} - -// RegisterAccountSamlSettingsRoutes maps account-scoped SAML config admin routes. -// Only accessible to account administrators (enforced by AccountScope middleware in router). -// Reference: Chatwoot AccountSamlSettings API — enterprise SSO configuration -func RegisterAccountSamlSettingsRoutes(g *gin.RouterGroup, h *AccountSamlSettingsHandler) { - g.GET("", h.Get) // Chatwoot frontend: fetch account SAML config - g.POST("", h.Create) // Chatwoot frontend: create account SAML config - g.PUT("", h.Update) // Chatwoot frontend: update account SAML config - g.DELETE("", h.Delete) // Chatwoot frontend: delete account SAML config - g.POST("/", h.Create) // Backward compatibility for trailing slash clients - g.GET("/:id", h.Get) // Backward compatibility for id-based callers - g.PUT("/:id", h.Update) // Backward compatibility for id-based callers - g.DELETE("/:id", h.Delete) // Backward compatibility for id-based callers - g.POST("/:id/toggle_active", h.ToggleActive) // Enable/disable SAML for a config -} - -func bindCreateSamlSettingsRequest(c *gin.Context) (CreateSamlSettingsRequest, error) { - req := CreateSamlSettingsRequest{} - if err := bindSamlSettingsPayload(c, &req); err != nil { - return req, err - } - req.normalizeChatwootAliases() - if req.IdpEntityID == "" || req.IdpSsoTargetURL == "" || req.IdpCertificate == "" { - return req, errors.New("idp_entity_id, idp_sso_target_url, and idp_certificate are required") - } - return req, nil -} - -func bindUpdateSamlSettingsRequest(c *gin.Context) (UpdateSamlSettingsRequest, error) { - req := UpdateSamlSettingsRequest{} - return req, bindSamlSettingsPayload(c, &req) -} - -func bindSamlSettingsPayload(c *gin.Context, req interface{}) error { - var payload map[string]json.RawMessage - if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil { - return err - } - - if nested, ok := payload["saml_settings"]; ok { - return json.Unmarshal(nested, req) - } - - flat, err := json.Marshal(payload) - if err != nil { - return err - } - return json.Unmarshal(flat, req) -} - -func serializeSamlSettings(settings *model.AccountSamlSettings) gin.H { - if settings == nil { - return gin.H{} - } - - return gin.H{ - "id": settings.ID, - "account_id": settings.AccountID, - "idp_entity_id": settings.IdpEntityID, - "idp_sso_target_url": settings.IdpSsoTargetURL, - "idp_slo_target_url": settings.IdpSloTargetURL, - "idp_certificate": settings.IdpCertificate, - "sso_url": settings.IdpSsoTargetURL, - "certificate": settings.IdpCertificate, - "sp_entity_id": settings.SpEntityID, - "sp_x509_certificate": settings.SpX509Certificate, - "role_mappings": settings.RoleMappings, - "active": settings.Active, - "fingerprint": samlCertificateFingerprint(settings.IdpCertificate), - "created_at": settings.CreatedAt, - "updated_at": settings.UpdatedAt, - } -} - -func samlCertificateFingerprint(certificate string) string { - if certificate == "" { - return "" - } - sum := sha1.Sum([]byte(certificate)) - return hex.EncodeToString(sum[:]) -} diff --git a/backend/internal/handler/api/v1/account_saml_settings_handler_test.go b/backend/internal/handler/api/v1/account_saml_settings_handler_test.go deleted file mode 100644 index b40284d9..00000000 --- a/backend/internal/handler/api/v1/account_saml_settings_handler_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package v1 - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - - "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/internal/repository" -) - -type AccountSamlSettingsHandlerTestSuite struct { - suite.Suite - db *gorm.DB - handler *AccountSamlSettingsHandler - router *gin.Engine - account *model.Account -} - -func (s *AccountSamlSettingsHandlerTestSuite) SetupSuite() { - s.db, _ = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - s.db.AutoMigrate(&model.Account{}, &model.AccountSamlSettings{}) - - repo := repository.NewAccountSamlSettingsRepo(s.db) - s.handler = NewAccountSamlSettingsHandler(repo) - - gin.SetMode(gin.TestMode) - r := gin.New() - RegisterAccountSamlSettingsRoutes(r.Group("/api/v1/accounts/:account_id/saml_settings"), s.handler) - s.router = r - - s.account = &model.Account{Name: "TestAccount"} - s.db.Create(s.account) -} - -func (s *AccountSamlSettingsHandlerTestSuite) SetupTest() { - s.db.Exec("DELETE FROM account_saml_settings") -} - -func TestAccountSamlSettingsHandlerTestSuite(t *testing.T) { - suite.Run(t, new(AccountSamlSettingsHandlerTestSuite)) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestGet_InvalidAccountID() { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/abc/saml_settings", nil) - s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestGet_NotFound() { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) - s.router.ServeHTTP(w, req) - s.Equal(http.StatusNotFound, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestGet_Success() { - settings := &model.AccountSamlSettings{ - AccountID: s.account.ID, - IdpEntityID: "entity-id", - IdpSsoTargetURL: "https://sso.example.com", - } - s.db.Create(settings) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) - s.router.ServeHTTP(w, req) - s.Equal(http.StatusOK, w.Code) - - var payload map[string]interface{} - s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) - s.Equal("https://sso.example.com", payload["sso_url"]) - s.Equal("https://sso.example.com", payload["idp_sso_target_url"]) - s.Equal("entity-id", payload["idp_entity_id"]) - s.NotContains(payload, "data") -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestChatwootFrontendCollectionCRUDPayloads() { - createBody := bytes.NewBufferString(`{"saml_settings":{"sso_url":"https://idp.example.com/saml","certificate":"-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----","idp_entity_id":"chatwoot-idp","role_mappings":{}}}`) - createReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), createBody) - createReq.Header.Set("Content-Type", "application/json") - createRecorder := httptest.NewRecorder() - s.router.ServeHTTP(createRecorder, createReq) - s.Equal(http.StatusCreated, createRecorder.Code) - - var created map[string]interface{} - s.Require().NoError(json.Unmarshal(createRecorder.Body.Bytes(), &created)) - s.NotZero(created["id"]) - s.Equal("https://idp.example.com/saml", created["sso_url"]) - s.Equal("https://idp.example.com/saml", created["idp_sso_target_url"]) - s.Equal("-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----", created["certificate"]) - s.Equal("-----BEGIN CERTIFICATE-----chatwoot-----END CERTIFICATE-----", created["idp_certificate"]) - s.Equal("chatwoot-idp", created["idp_entity_id"]) - s.NotEmpty(created["fingerprint"]) - s.NotContains(created, "data") - s.NotContains(created, "success") - - getReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) - getRecorder := httptest.NewRecorder() - s.router.ServeHTTP(getRecorder, getReq) - s.Equal(http.StatusOK, getRecorder.Code) - - var fetched map[string]interface{} - s.Require().NoError(json.Unmarshal(getRecorder.Body.Bytes(), &fetched)) - s.Equal(created["id"], fetched["id"]) - s.Equal("https://idp.example.com/saml", fetched["sso_url"]) - - updateBody := bytes.NewBufferString(`{"saml_settings":{"sso_url":"https://idp.example.com/updated","certificate":"updated-certificate","idp_entity_id":"updated-idp"}}`) - updateReq := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), updateBody) - updateReq.Header.Set("Content-Type", "application/json") - updateRecorder := httptest.NewRecorder() - s.router.ServeHTTP(updateRecorder, updateReq) - s.Equal(http.StatusOK, updateRecorder.Code) - - var updated map[string]interface{} - s.Require().NoError(json.Unmarshal(updateRecorder.Body.Bytes(), &updated)) - s.Equal(created["id"], updated["id"]) - s.Equal("https://idp.example.com/updated", updated["sso_url"]) - s.Equal("https://idp.example.com/updated", updated["idp_sso_target_url"]) - s.Equal("updated-certificate", updated["certificate"]) - s.Equal("updated-certificate", updated["idp_certificate"]) - s.Equal("updated-idp", updated["idp_entity_id"]) - s.NotContains(updated, "data") - - deleteReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) - deleteRecorder := httptest.NewRecorder() - s.router.ServeHTTP(deleteRecorder, deleteReq) - s.Equal(http.StatusOK, deleteRecorder.Code) - - afterDeleteReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), nil) - afterDeleteRecorder := httptest.NewRecorder() - s.router.ServeHTTP(afterDeleteRecorder, afterDeleteReq) - s.Equal(http.StatusNotFound, afterDeleteRecorder.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestCreate_InvalidAccountID() { - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"idp_entity_id":"eid","idp_sso_target_url":"https://sso.example.com"}`) - req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/abc/saml_settings", body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestCreate_Success() { - w := httptest.NewRecorder() - body := bytes.NewBufferString(fmt.Sprintf(`{"account_id":%d,"idp_entity_id":"eid","idp_sso_target_url":"https://sso.example.com","idp_certificate":"cert"}`, s.account.ID)) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestCreate_MissingRequiredFields() { - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"account_id":0}`) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/saml_settings", s.account.ID), body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.True(w.Code == http.StatusBadRequest || w.Code == http.StatusUnprocessableEntity, "expected 400 or 500, got %d", w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_InvalidAccountID() { - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"idp_entity_id":"updated-eid"}`) - req := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/abc/saml_settings", body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_Success() { - settings := &model.AccountSamlSettings{ - AccountID: s.account.ID, - IdpEntityID: "entity-id", - IdpSsoTargetURL: "https://sso.example.com", - } - s.db.Create(settings) - - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"idp_entity_id":"updated-eid"}`) - req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/saml_settings/%d", s.account.ID, settings.ID), body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusOK, w.Code) - - var payload map[string]interface{} - s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) - s.Equal("updated-eid", payload["idp_entity_id"]) - s.NotContains(payload, "data") -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestUpdate_NotFound() { - // account_id valid, but no settings exist for this account → repo returns "record not found" → 404 - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"idp_entity_id":"updated-eid"}`) - req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/saml_settings/999", s.account.ID), body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusNotFound, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestDelete_InvalidAccountID() { - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodDelete, "/api/v1/accounts/abc/saml_settings", nil) - s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestDelete_Success() { - settings := &model.AccountSamlSettings{ - AccountID: s.account.ID, - IdpEntityID: "entity-id", - IdpSsoTargetURL: "https://sso.example.com", - } - s.db.Create(settings) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/saml_settings/%d", s.account.ID, settings.ID), nil) - s.router.ServeHTTP(w, req) - // Handler returns 200 OK with message, not 204 NoContent - s.Equal(http.StatusOK, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestToggleActive_InvalidAccountID() { - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"active":true}`) - req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/abc/saml_settings/1/toggle_active", body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusBadRequest, w.Code) -} - -func (s *AccountSamlSettingsHandlerTestSuite) TestToggleActive_Success() { - settings := &model.AccountSamlSettings{ - AccountID: s.account.ID, - IdpEntityID: "entity-id", - IdpSsoTargetURL: "https://sso.example.com", - Active: false, - } - s.db.Create(settings) - - w := httptest.NewRecorder() - body := bytes.NewBufferString(`{"active":true}`) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/saml_settings/%d/toggle_active", s.account.ID, settings.ID), body) - req.Header.Set("Content-Type", "application/json") - s.router.ServeHTTP(w, req) - s.Equal(http.StatusOK, w.Code) -} diff --git a/backend/internal/handler/api/v1/auth_handler.go b/backend/internal/handler/api/v1/auth_handler.go index 14c6e47a..ba08af1e 100644 --- a/backend/internal/handler/api/v1/auth_handler.go +++ b/backend/internal/handler/api/v1/auth_handler.go @@ -52,12 +52,6 @@ type LoginRequest struct { Password string `json:"password" binding:"required,min=6"` } -// LoginMFAResquest is the JSON body for MFA login verification. -type LoginMFAResquest struct { - UserID uint `json:"user_id" binding:"required"` - TOTPCode string `json:"totp_code" binding:"required"` -} - // RefreshRequest is the JSON body for refresh endpoint. type RefreshRequest struct { RefreshToken string `json:"refresh_token" binding:"required"` @@ -87,7 +81,6 @@ type ConfirmEmailRequest struct { // Login authenticates a user with email/password and returns JWT tokens. // POST /api/v1/auth/login -// If MFA is enabled, returns mfa_required=true with user_id for TOTP verification. func (h *AuthHandler) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -104,15 +97,6 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - if output.MFARequired { - response.OK(c, gin.H{ - "mfa_required": true, - "user_id": output.User.ID, - "message": "MFA verification required, please provide TOTP code", - }) - return - } - response.OK(c, gin.H{ "user": output.User, "access_token": output.TokenPair.AccessToken, @@ -141,13 +125,6 @@ func (h *AuthHandler) ChatwootSignIn(c *gin.Context) { return } - if output.MFARequired { - c.JSON(http.StatusPartialContent, gin.H{ - "mfa_required": true, - "mfa_token": strconv.FormatUint(uint64(output.User.ID), 10), - }) - return - } if err := h.trackChatwootSession(c, output); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") return @@ -213,31 +190,6 @@ func (h *AuthHandler) ChatwootSignOut(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } -// LoginMFA completes login after MFA TOTP code verification. -// POST /api/v1/auth/login/mfa -func (h *AuthHandler) LoginMFA(c *gin.Context) { - var req LoginMFAResquest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) - return - } - - output, err := h.authService.LoginWithMFA(c.Request.Context(), req.UserID, req.TOTPCode) - if err != nil { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error()) - return - } - - response.OK(c, gin.H{ - "user": output.User, - "access_token": output.TokenPair.AccessToken, - "refresh_token": output.TokenPair.RefreshToken, - "expires_at": output.TokenPair.ExpiresAt, - "account_id": output.AccountID, - "role": output.Role, - }) -} - // Refresh rotates a refresh token and returns new JWT pair. // POST /api/v1/auth/refresh // Implements refresh token rotation per P2E §1.4 security requirement. @@ -423,7 +375,6 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) { { // Core auth endpoints authGroup.POST("/login", handler.Login) - authGroup.POST("/login/mfa", handler.LoginMFA) authGroup.POST("/refresh", handler.Refresh) authGroup.DELETE("/logout", handler.Logout) @@ -488,7 +439,7 @@ func extractChatwootAccessToken(c *gin.Context) string { } // generateOAuthState creates a cryptographically random state token for CSRF protection. -// Used by SAML and other auth flows. Production note: state should also be stored +// Used by OIDC and other auth flows. Production note: state should also be stored // server-side (Redis) and validated on callback. func generateOAuthState() string { return "gochat_oauth_" + randomHex(16) diff --git a/backend/internal/handler/api/v1/auth_handler_test.go b/backend/internal/handler/api/v1/auth_handler_test.go index e35eef9a..3e2ef26c 100644 --- a/backend/internal/handler/api/v1/auth_handler_test.go +++ b/backend/internal/handler/api/v1/auth_handler_test.go @@ -56,7 +56,7 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) { jwtCfg := &config.JWTConfig{Secret: "auth-test-secret", ExpiryHours: 1, RefreshExpiryHours: 24} jwtSvc := auth.NewJWTService(jwtCfg) refreshStore := auth.NewRefreshTokenStore(nil, jwtCfg) - authSvc := service.NewAuthService(db, jwtSvc, refreshStore, nil) + authSvc := service.NewAuthService(db, jwtSvc, refreshStore) profileSvc := service.NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db), repository.NewAccessTokenRepo(db)) handler := NewAuthHandler(authSvc, profileSvc) diff --git a/backend/internal/handler/api/v1/ldap_handler.go b/backend/internal/handler/api/v1/ldap_handler.go deleted file mode 100644 index be26274f..00000000 --- a/backend/internal/handler/api/v1/ldap_handler.go +++ /dev/null @@ -1,580 +0,0 @@ -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) - } -} \ No newline at end of file diff --git a/backend/internal/handler/api/v1/mfa_handler.go b/backend/internal/handler/api/v1/mfa_handler.go deleted file mode 100644 index c03e83f6..00000000 --- a/backend/internal/handler/api/v1/mfa_handler.go +++ /dev/null @@ -1,327 +0,0 @@ -package v1 - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/gochat/gochat/internal/auth" - "github.com/gochat/gochat/pkg/response" -) - -// Reference: P2E §1.5 — MFA HTTP handlers -// Maps to Chatwoot enterprise TwoFactorAuthController: -// - enable → POST /api/v1/auth/mfa/enable (generates secret + QR URI) -// - verify → POST /api/v1/auth/mfa/verify (validates TOTP code, enables MFA) -// - disable → POST /api/v1/auth/mfa/disable (disables MFA after code verification) - -// MFAHandler handles MFA (TOTP) HTTP endpoints. -type MFAHandler struct { - mfaService *auth.MFAService -} - -// NewMFAHandler creates a MFA handler with service dependency. -func NewMFAHandler(mfaService *auth.MFAService) *MFAHandler { - return &MFAHandler{ - mfaService: mfaService, - } -} - -// --- Request/Response structs --- - -// EnableMFARequest is the JSON body for MFA enablement initiation. -type EnableMFARequest struct { - // No body required — user_id comes from auth context -} - -// EnableMFAResponse is the JSON response for MFA enablement initiation. -type EnableMFAResponse struct { - TOTPSecret string `json:"totp_secret"` // base32 secret for manual entry - QRURI string `json:"qr_uri"` // otpauth:// URI for QR code generation - Message string `json:"message"` -} - -// VerifyMFARequest is the JSON body for MFA TOTP verification. -type VerifyMFARequest struct { - TOTPSecret string `json:"totp_secret" binding:"required"` // secret from enable step - TOTPCode string `json:"totp_code" binding:"required"` // 6-digit code from authenticator app -} - -// DisableMFARequest is the JSON body for MFA disablement. -type DisableMFARequest struct { - TOTPCode string `json:"totp_code" binding:"required"` // current TOTP code for verification -} - -type profileMFAVerifyRequest struct { - OTPCode string `json:"otp_code"` - TOTPCode string `json:"totp_code"` -} - -type profileMFADisableRequest struct { - Password string `json:"password"` - OTPCode string `json:"otp_code"` - BackupCode string `json:"backup_code"` -} - -// --- Handlers --- - -// EnableMFA initiates MFA setup: generates a TOTP secret and QR URI. -// POST /api/v1/auth/mfa/enable -// Requires authentication — uses user_id from JWT context. -// The user must verify a TOTP code before MFA is actually activated. -func (h *MFAHandler) EnableMFA(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") - return - } - - // Check if MFA is already enabled - enabled, err := h.mfaService.IsMFAEnabled(userID) - if err != nil { - response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error()) - return - } - if enabled { - response.AbortWithStatusError(c, http.StatusConflict, response.ErrConflict, "MFA is already enabled for this user") - return - } - - // Generate new TOTP secret + QR URI - secret, qrURI, err := h.mfaService.GenerateTOTPSecret(userID) - if err != nil { - response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error()) - return - } - - response.OK(c, EnableMFAResponse{ - TOTPSecret: secret, - QRURI: qrURI, - Message: "Scan QR code with your authenticator app, then verify with a TOTP code", - }) -} - -// VerifyMFA completes MFA setup: verifies TOTP code and enables MFA on the user. -// POST /api/v1/auth/mfa/verify -// Requires authentication — uses user_id from JWT context. -// This is the second step: user provides secret + code from authenticator app. -func (h *MFAHandler) VerifyMFA(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") - return - } - - var req VerifyMFARequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) - return - } - - // Validate the TOTP code against the provided secret - cfg := auth.DefaultTOTPConfig() - if !auth.ValidateTOTPCode(req.TOTPSecret, req.TOTPCode, cfg) { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid TOTP code, please try again") - return - } - - // Enable TOTP on the user (stores secret in DB) - if err := h.mfaService.EnableTOTP(userID, req.TOTPSecret); err != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) - return - } - - response.OK(c, gin.H{ - "message": "MFA enabled successfully", - "mfa_enabled": true, - }) -} - -// DisableMFA disables MFA after verifying the current TOTP code. -// POST /api/v1/auth/mfa/disable -// Requires authentication — uses user_id from JWT context. -func (h *MFAHandler) DisableMFA(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") - return - } - - var req DisableMFARequest - if err := c.ShouldBindJSON(&req); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) - return - } - - // Disable TOTP (requires valid current code for security) - if err := h.mfaService.DisableTOTP(userID, req.TOTPCode); err != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) - return - } - - response.OK(c, gin.H{ - "message": "MFA disabled successfully", - "mfa_enabled": false, - }) -} - -// MFAStatus returns the current MFA status for the authenticated user. -// GET /api/v1/auth/mfa/status -func (h *MFAHandler) MFAStatus(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") - return - } - - enabled, err := h.mfaService.IsMFAEnabled(userID) - if err != nil { - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) - return - } - - response.OK(c, gin.H{ - "mfa_enabled": enabled, - }) -} - -// RegisterMFARoutes sets up MFA routes on a Gin router group. -// These routes require authentication (AuthRequired middleware). -func RegisterMFARoutes(rg *gin.RouterGroup, handler *MFAHandler) { - mfaGroup := rg.Group("/auth/mfa") - { - mfaGroup.POST("/enable", handler.EnableMFA) - mfaGroup.POST("/verify", handler.VerifyMFA) - mfaGroup.POST("/disable", handler.DisableMFA) - mfaGroup.GET("/status", handler.MFAStatus) - mfaGroup.POST("/backup_codes", handler.BackupCodes) - } -} - -// ProfileMFAStatus matches Chatwoot Profile::MfaController#show. -func (h *MFAHandler) ProfileMFAStatus(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - enabled, err := h.mfaService.IsMFAEnabled(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - backupCodesGenerated, err := h.mfaService.BackupCodesGenerated(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "feature_available": true, - "enabled": enabled, - "backup_codes_generated": backupCodesGenerated, - }) -} - -// ProfileEnableMFA matches Chatwoot Profile::MfaController#create. -func (h *MFAHandler) ProfileEnableMFA(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - enabled, err := h.mfaService.IsMFAEnabled(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if enabled { - c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "MFA is already enabled"}) - return - } - secret, uri, err := h.mfaService.BeginTOTPSetup(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"provisioning_url": uri, "secret": secret}) -} - -// ProfileVerifyMFA matches Chatwoot Profile::MfaController#verify. -func (h *MFAHandler) ProfileVerifyMFA(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - var req profileMFAVerifyRequest - _ = c.ShouldBindJSON(&req) - code := req.OTPCode - if code == "" { - code = req.TOTPCode - } - backupCodes, err := h.mfaService.VerifyAndActivateTOTP(userID, code) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"enabled": true, "backup_codes": backupCodes}) -} - -// ProfileDisableMFA matches Chatwoot Profile::MfaController#destroy. -func (h *MFAHandler) ProfileDisableMFA(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - var req profileMFADisableRequest - _ = c.ShouldBindJSON(&req) - if err := h.mfaService.DisableTOTPWithPassword(userID, req.Password, req.OTPCode, req.BackupCode); err != nil { - c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"enabled": false}) -} - -// ProfileBackupCodes matches Chatwoot Profile::MfaController#backup_codes. -func (h *MFAHandler) ProfileBackupCodes(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - var req profileMFAVerifyRequest - _ = c.ShouldBindJSON(&req) - code := req.OTPCode - if code == "" { - code = req.TOTPCode - } - valid, err := h.mfaService.VerifyTOTPCode(userID, code) - if err != nil || !valid { - c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid totp code"}) - return - } - codes, err := h.mfaService.GenerateBackupCodes(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"backup_codes": codes}) -} - -// BackupCodes generates one-time MFA backup codes. -// POST /api/v1/profile/mfa/backup_codes or /api/v1/auth/mfa/backup_codes -// Reference: Chatwoot MfaController#backup_codes -func (h *MFAHandler) BackupCodes(c *gin.Context) { - userID := getUserID(c) - if userID == 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) - return - } - codes, err := h.mfaService.GenerateBackupCodes(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"backup_codes": codes}) -} diff --git a/backend/internal/handler/api/v1/mfa_handler_test.go b/backend/internal/handler/api/v1/mfa_handler_test.go deleted file mode 100644 index 7ba75bf8..00000000 --- a/backend/internal/handler/api/v1/mfa_handler_test.go +++ /dev/null @@ -1,723 +0,0 @@ -package v1 - -import ( - "bytes" - "crypto/hmac" - "crypto/sha1" - "encoding/base32" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "github.com/gochat/gochat/internal/auth" - "github.com/gochat/gochat/internal/model" - pkgcrypto "github.com/gochat/gochat/pkg/crypto" - "github.com/gochat/gochat/pkg/response" -) - -// --- MFA Handler Test Suite --- -// Uses real SQLite DB + real MFAService + httptest. - -type MFAHandlerTestSuite struct { - suite.Suite - - db *gorm.DB - router *gin.Engine - handler *MFAHandler - mfaService *auth.MFAService - - user *model.User - account *model.Account - - userID uint - accountID uint -} - -func TestMFAHandlerSuite(t *testing.T) { - suite.Run(t, new(MFAHandlerTestSuite)) -} - -func (s *MFAHandlerTestSuite) SetupSuite() { - gin.SetMode(gin.TestMode) - - // Create in-memory SQLite DB - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - s.Require().NoError(err, "failed to open SQLite test database") - - // Migrate models needed for MFA operations - s.Require().NoError(db.AutoMigrate( - &model.Account{}, - &model.User{}, - )) - - s.db = db - - // Create real MFA service backed by the test DB - s.mfaService = auth.NewMFAService(db) - s.handler = NewMFAHandler(s.mfaService) - - // Create test account - account := &model.Account{Name: "MFATestAccount"} - s.Require().NoError(db.Create(account).Error) - s.account = account - s.accountID = account.ID - - // Create test user belonging to the account - user := &model.User{ - AccountID: account.ID, - Name: "MFA Test User", - Email: "mfatest@example.com", - Password: "hashedpassword123", - Provider: "email", - Role: "agent", - Active: true, - } - s.Require().NoError(db.Create(user).Error) - s.user = user - s.userID = user.ID - - // Setup router with middleware that injects user_id into context - s.setupRouter(s.userID) -} - -func (s *MFAHandlerTestSuite) setupRouter(userID uint) { - r := gin.New() - r.Use(func(c *gin.Context) { - c.Set("user_id", userID) - c.Next() - }) - - mfaGroup := r.Group("/api/v1/auth/mfa") - { - mfaGroup.POST("/enable", s.handler.EnableMFA) - mfaGroup.POST("/verify", s.handler.VerifyMFA) - mfaGroup.POST("/disable", s.handler.DisableMFA) - } - r.GET("/api/v1/profile/mfa", s.handler.ProfileMFAStatus) - r.POST("/api/v1/profile/mfa", s.handler.ProfileEnableMFA) - r.DELETE("/api/v1/profile/mfa", s.handler.ProfileDisableMFA) - r.POST("/api/v1/profile/mfa/verify", s.handler.ProfileVerifyMFA) - r.POST("/api/v1/profile/mfa/backup_codes", s.handler.ProfileBackupCodes) - - s.router = r -} - -func (s *MFAHandlerTestSuite) SetupTest() { - // Hard cleanup: delete all users and accounts, then recreate - s.db.Exec("DELETE FROM users") - s.db.Exec("DELETE FROM accounts") - - // Recreate test data - account := &model.Account{Name: "MFATestAccount"} - s.Require().NoError(s.db.Create(account).Error) - s.account = account - s.accountID = account.ID - - user := &model.User{ - AccountID: account.ID, - Name: "MFA Test User", - Email: "mfatest@example.com", - Password: "hashedpassword123", - Provider: "email", - Role: "agent", - Active: true, - } - s.Require().NoError(s.db.Create(user).Error) - s.user = user - s.userID = user.ID - - // Re-setup router with the new user ID - s.setupRouter(s.userID) -} - -func (s *MFAHandlerTestSuite) TearDownSuite() { - if s.db != nil { - sqlDB, err := s.db.DB() - if err == nil { - sqlDB.Close() - } - } -} - -// --- Helper to make requests and parse responses --- - -func (s *MFAHandlerTestSuite) doRequest(method, path, body string) *httptest.ResponseRecorder { - var reqBody *bytes.Buffer - if body != "" { - reqBody = bytes.NewBufferString(body) - } else { - reqBody = bytes.NewBufferString("") - } - - w := httptest.NewRecorder() - req := httptest.NewRequest(method, path, reqBody) - if body != "" { - req.Header.Set("Content-Type", "application/json") - } - s.router.ServeHTTP(w, req) - return w -} - -func (s *MFAHandlerTestSuite) parseResponse(w *httptest.ResponseRecorder) response.APIResponse { - var resp response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - return resp -} - -// --- Helper to generate a valid TOTP code for a secret --- -// Uses the same algorithm as auth.validateTOTP/generateTOTP to compute a valid code. - -func (s *MFAHandlerTestSuite) generateValidTOTPCode(secret string) string { - cfg := auth.DefaultTOTPConfig() - return computeTOTPCode(secret, cfg) -} - -func computeTOTPCode(secret string, cfg auth.TOTPConfig) string { - key, err := decodeBase32NoPad(secret) - if err != nil { - return "" - } - - now := time.Now().Unix() - period := int64(cfg.Period) - timeCounter := now / period - - return generateTOTPFromKey(key, timeCounter, cfg) -} - -func decodeBase32NoPad(secret string) ([]byte, error) { - return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) -} - -func generateTOTPFromKey(key []byte, timeCounter int64, cfg auth.TOTPConfig) string { - // Encode time counter as 8-byte big-endian - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, uint64(timeCounter)) - - // HMAC-SHA1 - h := hmac.New(sha1.New, key) - h.Write(buf) - hash := h.Sum(nil) - - // Dynamic truncation per RFC 4226 - offset := hash[len(hash)-1] & 0x0f - truncated := (int32(hash[offset]&0x7f) << 24) | - (int32(hash[offset+1]&0xff) << 16) | - (int32(hash[offset+2]&0xff) << 8) | - (int32(hash[offset+3] & 0xff)) - - // Modulo 10^digits - mod := int32(math.Pow10(cfg.Digits)) - code := truncated % mod - - // Format with leading zeros - return fmt.Sprintf("%0*d", cfg.Digits, code) -} - -func jsonBody(data map[string]interface{}) string { - b, err := json.Marshal(data) - if err != nil { - return "" - } - return string(b) -} - -// ============================================================ -// EnableMFA tests -// ============================================================ - -func (s *MFAHandlerTestSuite) TestEnable_Success() { - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{}") - - s.Equal(http.StatusOK, w.Code) - - resp := s.parseResponse(w) - s.True(resp.Success) - - dataMap, ok := resp.Data.(map[string]interface{}) - s.True(ok) - - // Response should contain totp_secret and qr_uri - s.NotEmpty(dataMap["totp_secret"]) - s.NotEmpty(dataMap["qr_uri"]) - s.Contains(dataMap["qr_uri"], "otpauth://totp/") - s.Contains(dataMap["qr_uri"], dataMap["totp_secret"]) -} - -func (s *MFAHandlerTestSuite) TestEnable_NoBody() { - // EnableMFARequest has no required fields, empty body should still work - // since the handler doesn't even call ShouldBindJSON - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "") - - // With empty body and no Content-Type, the handler doesn't bind JSON, - // so it just uses user_id from context → should succeed - s.Equal(http.StatusOK, w.Code) - - resp := s.parseResponse(w) - s.True(resp.Success) -} - -func (s *MFAHandlerTestSuite) TestEnable_InvalidJSON() { - // Enable handler does NOT call ShouldBindJSON at all — it only uses - // c.GetUint("user_id") and service calls. So invalid JSON in the body - // won't cause a binding error. With a valid user_id in context, - // this should succeed regardless of body content. - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{invalid}") - - // The handler ignores the body entirely, so with valid user_id it succeeds - s.Equal(http.StatusOK, w.Code) -} - -func (s *MFAHandlerTestSuite) TestEnable_Unauthorized_NoUserID() { - // Create router without user_id middleware — user_id will be 0 - r := gin.New() - r.POST("/api/v1/auth/mfa/enable", s.handler.EnableMFA) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/enable", bytes.NewBufferString("{}")) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - s.Equal(http.StatusUnauthorized, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.False(respStruct.Success) - s.NotNil(respStruct.Error) - s.Equal(response.ErrUnauthorized, respStruct.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestEnable_UserNotFound() { - // Router that sets a non-existent user ID - r := gin.New() - r.Use(func(c *gin.Context) { - c.Set("user_id", uint(9999)) // non-existent user - c.Next() - }) - r.POST("/api/v1/auth/mfa/enable", s.handler.EnableMFA) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/enable", bytes.NewBufferString("{}")) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - // IsMFAEnabled will fail to find the user → 500 Internal Server Error - s.Equal(http.StatusUnprocessableEntity, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.False(respStruct.Success) - s.NotNil(respStruct.Error) - s.Equal(response.ErrInternal, respStruct.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestEnable_AlreadyEnabled() { - // First enable MFA for the user - user := s.user - user.TOTPSecret = "JBSWY3DPEHPK3PXP" - user.TOTPEnabled = true - s.Require().NoError(s.db.Save(user).Error) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/enable", "{}") - - s.Equal(http.StatusConflict, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrConflict, resp.Error.Code) -} - -// ============================================================ -// VerifyMFA tests -// ============================================================ - -func (s *MFAHandlerTestSuite) TestVerify_Success() { - // Step 1: Generate secret - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - - // Step 2: Compute a valid TOTP code for the secret - code := s.generateValidTOTPCode(secret) - - // Step 3: Verify with secret + code - body := jsonBody(map[string]interface{}{ - "totp_secret": secret, - "totp_code": code, - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) - - s.Equal(http.StatusOK, w.Code) - - resp := s.parseResponse(w) - s.True(resp.Success) - - dataMap, ok := resp.Data.(map[string]interface{}) - s.True(ok) - s.Equal("MFA enabled successfully", dataMap["message"]) - s.Equal(true, dataMap["mfa_enabled"]) - - // Verify that TOTPEnabled is now true in DB - var updatedUser model.User - s.Require().NoError(s.db.First(&updatedUser, s.userID).Error) - s.True(updatedUser.TOTPEnabled) - s.Equal(secret, updatedUser.TOTPSecret) -} - -func (s *MFAHandlerTestSuite) TestVerify_InvalidJSON() { - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", "{invalid}") - - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrValidation, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestVerify_MissingTOTPSecret() { - // When totp_secret is missing from JSON, ShouldBindJSON fails with - // binding:"required" validation error → handler returns ErrValidation - body := jsonBody(map[string]interface{}{ - "totp_code": "123456", - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) - - // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrValidation, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestVerify_MissingTOTPCode() { - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - - // When totp_code is missing from JSON, ShouldBindJSON fails with - // binding:"required" validation error → handler returns ErrValidation - body := jsonBody(map[string]interface{}{ - "totp_secret": secret, - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) - - // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrValidation, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestVerify_InvalidTOTPCode() { - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - - body := jsonBody(map[string]interface{}{ - "totp_secret": secret, - "totp_code": "000000", // definitely wrong code - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/verify", body) - - // ValidateTOTPCode returns false → 400 Bad Request - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrBadRequest, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestVerify_Unauthorized_NoUserID() { - r := gin.New() - r.POST("/api/v1/auth/mfa/verify", s.handler.VerifyMFA) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/verify", bytes.NewBufferString(`{"totp_secret":"abc","totp_code":"123456"}`)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - s.Equal(http.StatusUnauthorized, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.False(respStruct.Success) - s.Equal(response.ErrUnauthorized, respStruct.Error.Code) -} - -// ============================================================ -// DisableMFA tests -// ============================================================ - -func (s *MFAHandlerTestSuite) TestDisable_Success() { - // First, enable MFA for the user so we can disable it - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - - // Enable TOTP via service directly - s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) - - // Now the user has TOTP enabled. Generate a current valid code for disable. - disableCode := s.generateValidTOTPCode(secret) - - body := jsonBody(map[string]interface{}{ - "totp_code": disableCode, - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) - - s.Equal(http.StatusOK, w.Code) - - resp := s.parseResponse(w) - s.True(resp.Success) - - dataMap, ok := resp.Data.(map[string]interface{}) - s.True(ok) - s.Equal("MFA disabled successfully", dataMap["message"]) - s.Equal(false, dataMap["mfa_enabled"]) - - // Verify that TOTPEnabled is now false in DB - var updatedUser model.User - s.Require().NoError(s.db.First(&updatedUser, s.userID).Error) - s.False(updatedUser.TOTPEnabled) - s.Empty(updatedUser.TOTPSecret) -} - -func (s *MFAHandlerTestSuite) TestDisable_InvalidJSON() { - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", "{invalid}") - - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrValidation, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestDisable_MissingTOTPCode() { - // First enable MFA - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) - - // When totp_code is missing from JSON, ShouldBindJSON fails with - // binding:"required" validation error → handler returns ErrValidation - body := jsonBody(map[string]interface{}{}) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) - - // Missing required binding field → ShouldBindJSON error → 400 VALIDATION_ERROR - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrValidation, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestDisable_InvalidTOTPCode() { - // First enable MFA - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) - - body := jsonBody(map[string]interface{}{ - "totp_code": "000000", // definitely wrong - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) - - // DisableTOTP → VerifyTOTPCode → invalid code → error → 400 Bad Request - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrBadRequest, resp.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestDisable_Unauthorized_NoUserID() { - r := gin.New() - r.POST("/api/v1/auth/mfa/disable", s.handler.DisableMFA) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/mfa/disable", bytes.NewBufferString(`{"totp_code":"123456"}`)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - s.Equal(http.StatusUnauthorized, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.False(respStruct.Success) - s.Equal(response.ErrUnauthorized, respStruct.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestDisable_MFANotEnabled() { - // User does not have MFA enabled — DisableTOTP calls VerifyTOTPCode - // which checks user.TOTPEnabled == false → error - body := jsonBody(map[string]interface{}{ - "totp_code": "123456", - }) - - w := s.doRequest(http.MethodPost, "/api/v1/auth/mfa/disable", body) - - // VerifyTOTPCode will return error "mfa not enabled for user" → 400 Bad Request - s.Equal(http.StatusBadRequest, w.Code) - - resp := s.parseResponse(w) - s.False(resp.Success) - s.NotNil(resp.Error) - s.Equal(response.ErrBadRequest, resp.Error.Code) -} - -// ============================================================ -// MFAStatus tests (bonus coverage for the status endpoint) -// ============================================================ - -func (s *MFAHandlerTestSuite) TestStatus_MFADisabled() { - // Setup router with status route - r := gin.New() - r.Use(func(c *gin.Context) { - c.Set("user_id", s.userID) - c.Next() - }) - r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) - r.ServeHTTP(w, req) - - s.Equal(http.StatusOK, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.True(respStruct.Success) - - dataMap, ok := respStruct.Data.(map[string]interface{}) - s.True(ok) - s.Equal(false, dataMap["mfa_enabled"]) -} - -func (s *MFAHandlerTestSuite) TestStatus_MFAEnabled() { - // Enable MFA first - secret, _, err := s.mfaService.GenerateTOTPSecret(s.userID) - s.Require().NoError(err) - s.Require().NoError(s.mfaService.EnableTOTP(s.userID, secret)) - - // Setup router with status route - r := gin.New() - r.Use(func(c *gin.Context) { - c.Set("user_id", s.userID) - c.Next() - }) - r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) - r.ServeHTTP(w, req) - - s.Equal(http.StatusOK, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.True(respStruct.Success) - - dataMap, ok := respStruct.Data.(map[string]interface{}) - s.True(ok) - s.Equal(true, dataMap["mfa_enabled"]) -} - -func (s *MFAHandlerTestSuite) TestStatus_Unauthorized_NoUserID() { - r := gin.New() - r.GET("/api/v1/auth/mfa/status", s.handler.MFAStatus) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/mfa/status", nil) - r.ServeHTTP(w, req) - - s.Equal(http.StatusUnauthorized, w.Code) - - var respStruct response.APIResponse - s.NoError(json.Unmarshal(w.Body.Bytes(), &respStruct)) - s.False(respStruct.Success) - s.Equal(response.ErrUnauthorized, respStruct.Error.Code) -} - -func (s *MFAHandlerTestSuite) TestProfileMFA_StatusUsesChatwootRawPayload() { - w := s.doRequest(http.MethodGet, "/api/v1/profile/mfa", "") - s.Equal(http.StatusOK, w.Code) - - var payload map[string]interface{} - s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) - s.Equal(true, payload["feature_available"]) - s.Equal(false, payload["enabled"]) - s.Equal(false, payload["backup_codes_generated"]) - s.NotContains(payload, "data") -} - -func (s *MFAHandlerTestSuite) TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads() { - passwordHash, err := pkgcrypto.HashPassword("current-password") - s.Require().NoError(err) - s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{ - "password": passwordHash, - "password_digest": passwordHash, - }).Error) - - enableRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa", "") - s.Equal(http.StatusOK, enableRec.Code) - var enablePayload map[string]string - s.Require().NoError(json.Unmarshal(enableRec.Body.Bytes(), &enablePayload)) - s.NotEmpty(enablePayload["secret"]) - s.Contains(enablePayload["provisioning_url"], "otpauth://totp/") - - code := s.generateValidTOTPCode(enablePayload["secret"]) - verifyRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/verify", jsonBody(map[string]interface{}{"otp_code": code})) - s.Equal(http.StatusOK, verifyRec.Code) - var verifyPayload struct { - Enabled bool `json:"enabled"` - BackupCodes []string `json:"backup_codes"` - } - s.Require().NoError(json.Unmarshal(verifyRec.Body.Bytes(), &verifyPayload)) - s.True(verifyPayload.Enabled) - s.Len(verifyPayload.BackupCodes, 10) - - backupRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/backup_codes", jsonBody(map[string]interface{}{"otp_code": code})) - s.Equal(http.StatusOK, backupRec.Code) - var backupPayload struct { - BackupCodes []string `json:"backup_codes"` - } - s.Require().NoError(json.Unmarshal(backupRec.Body.Bytes(), &backupPayload)) - s.Len(backupPayload.BackupCodes, 10) - - disableRec := s.doRequest(http.MethodDelete, "/api/v1/profile/mfa", jsonBody(map[string]interface{}{"password": "current-password", "otp_code": code})) - s.Equal(http.StatusOK, disableRec.Code) - var disablePayload map[string]bool - s.Require().NoError(json.Unmarshal(disableRec.Body.Bytes(), &disablePayload)) - s.False(disablePayload["enabled"]) -} - -// Ensure unused import warning doesn't cause issues -var _ = assert.Equal diff --git a/backend/internal/handler/api/v1/saml_handler.go b/backend/internal/handler/api/v1/saml_handler.go deleted file mode 100644 index e1dbed78..00000000 --- a/backend/internal/handler/api/v1/saml_handler.go +++ /dev/null @@ -1,431 +0,0 @@ -package v1 - -// Reference: P2E §1.6 — SAML 2.0 SP HTTP handlers -// Provides three endpoints for SAML SSO integration: -// - GET /api/v1/saml/metadata → SP metadata (for IdP config import) -// - GET /api/v1/saml/login → Initiate SP-initiated SSO (redirect to IdP) -// - POST /api/v1/saml/acs → ACS endpoint (process IdP response, issue JWT) - -import ( - "errors" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "github.com/gochat/gochat/internal/auth" - "github.com/gochat/gochat/internal/config" - "github.com/gochat/gochat/pkg/response" - applogger "github.com/gochat/gochat/pkg/logger" -) - -// SAMLHandler handles SAML 2.0 authentication HTTP endpoints. -type SAMLHandler struct { - samlService *auth.SAMLService - jwtService *auth.JWTService - refreshStore *auth.RefreshTokenStore - ssoSessionStore *auth.SSOSessionStore - samlCfg *config.SAMLConfig -} - -// NewSAMLHandler creates a SAML handler with service dependencies. -func NewSAMLHandler( - samlService *auth.SAMLService, - jwtService *auth.JWTService, - refreshStore *auth.RefreshTokenStore, - ssoSessionStore *auth.SSOSessionStore, - samlCfg *config.SAMLConfig, -) *SAMLHandler { - return &SAMLHandler{ - samlService: samlService, - jwtService: jwtService, - refreshStore: refreshStore, - ssoSessionStore: ssoSessionStore, - samlCfg: samlCfg, - } -} - -// Metadata returns the SP XML metadata for IdP administrators to import. -// GET /api/v1/saml/metadata -func (h *SAMLHandler) Metadata(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - xml, err := h.samlService.GetSPMetadata() - if err != nil { - applogger.L().Errorf("Failed to generate SAML SP metadata: %v", err) - c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrInternal, - Message: "Failed to generate SP metadata", - }, - }) - return - } - - // Return raw XML with appropriate content type - c.Data(http.StatusOK, "application/samlmetadata+xml", xml) -} - -// Login initiates SP-initiated SSO by redirecting to the IdP. -// GET /api/v1/saml/login -func (h *SAMLHandler) Login(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - // Generate state token for CSRF protection (same pattern as OAuth) - state := generateOAuthState() - - redirectURL, err := h.samlService.InitiateLogin(state) - if err != nil { - applogger.L().Errorf("Failed to initiate SAML login: %v", err) - c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrInternal, - Message: "Failed to initiate SAML login", - }, - }) - return - } - - // Redirect user to IdP - c.Redirect(http.StatusFound, redirectURL) -} - -// ACS (Assertion Consumer Service) processes the SAML Response from the IdP. -// POST /api/v1/saml/acs -// The IdP posts a base64-encoded SAMLResponse + RelayState to this endpoint. -// On success: validates assertion, finds/creates user, issues JWT token pair. -func (h *SAMLHandler) ACS(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - // Extract SAMLResponse from form POST (IdP sends as base64-encoded form param) - samlResponse := c.PostForm("SAMLResponse") - if samlResponse == "" { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Missing SAMLResponse parameter", - }, - }) - return - } - - // Process and validate the SAML response - userInfo, err := h.samlService.ProcessResponse(samlResponse) - if err != nil { - applogger.L().Errorf("SAML ACS validation failed: %v", err) - statusCode := http.StatusUnauthorized - errCode := response.ErrUnauthorized - if errors.Is(err, auth.ErrSAMLReplay) { - statusCode = http.StatusForbidden - errCode = response.ErrForbidden - } else if errors.Is(err, auth.ErrSAMLInvalidResponse) { - statusCode = http.StatusUnauthorized - } - c.JSON(statusCode, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: errCode, - Message: "SAML authentication failed", - Detail: err.Error(), - }, - }) - return - } - - // Find or create user in GoChat - user, err := h.samlService.FindOrCreateUser(userInfo) - if err != nil { - applogger.L().Errorf("SAML user lookup/creation failed: %v", err) - c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrInternal, - Message: "Failed to process SAML user", - Detail: err.Error(), - }, - }) - return - } - - // Issue JWT token pair (same pattern as login flow) - tokenPair, err := h.jwtService.GenerateTokenPair(user, user.AccountID, user.Role) - if err != nil { - applogger.L().Errorf("Failed to generate JWT for SAML user: %v", 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(), user.ID, tokenPair.RefreshToken); err != nil { - applogger.L().Warnf("Failed to store refresh token for SAML user: %v", err) - // Non-fatal: access token is still valid - } - } - - // Create SSO session in Redis (for SLO and session tracking) - // Reference: M13 §4 — SSO session creation in ACS flow - if h.ssoSessionStore != nil { - idpEntityID := h.samlService.GetIdPEntityID() - sessionData := &auth.SSOSessionData{ - UserID: user.ID, - Provider: "saml", - IdPEntityID: idpEntityID, - NameID: userInfo.NameID, - AccountID: user.AccountID, - Role: user.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 SAML user: %v", err) - // Non-fatal: JWT tokens are still valid, SSO session is for tracking/SLO only - } else { - applogger.L().Infof("SSO session %s created for SAML user %d via IdP %s", sessionID, user.ID, idpEntityID) - } - } - - // Return successful auth response (same format as login endpoint) - response.OK(c, gin.H{ - "user": user, - "access_token": tokenPair.AccessToken, - "refresh_token": tokenPair.RefreshToken, - "expires_at": tokenPair.ExpiresAt, - }) -} - -// RegisterSAMLRoutes sets up SAML routes on a Gin router group. -// These routes are PUBLIC — no AuthRequired middleware (SAML flow is external). -func RegisterSAMLRoutes(rg *gin.RouterGroup, handler *SAMLHandler) { - samlGroup := rg.Group("/saml") - { - samlGroup.GET("/metadata", handler.Metadata) - samlGroup.GET("/login", handler.Login) - samlGroup.POST("/acs", handler.ACS) - // SLO (Single Logout) endpoints — M13 §1 - samlGroup.GET("/slo", handler.SPInitiatedSLO) // SP-initiated: redirect user to IdP for logout - samlGroup.POST("/slo", handler.IdPInitiatedSLO) // IdP-initiated: IdP sends LogoutRequest to us - } -} - -// --- SAML Single Logout (SLO) Handlers --- -// Reference: M13 §1 — SAML 2.0 Single Logout (SLO) - -// SPInitiatedSLO handles SP-initiated Single Logout (HTTP-Redirect binding). -// GET /api/v1/saml/slo -// The user clicks logout in GoChat → we generate a SAML LogoutRequest -// and redirect to the IdP's SLO endpoint. The IdP then propagates -// logout to all SPs in the session. -// Query params: -// - session_id: the SSO session ID to terminate -// - state: optional RelayState for post-logout redirect -func (h *SAMLHandler) SPInitiatedSLO(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - sessionID := c.Query("session_id") - if sessionID == "" { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Missing session_id parameter", - }, - }) - return - } - - state := c.Query("state") - if state == "" { - state = "/" // default redirect to home after logout - } - - // In a real flow, we'd look up the SSO session to get NameID + SessionIndex - // from the DB/Redis. For now, use the session_id as both. - // Production note: session store should be backed by Redis/DB for SLO validation. - // Current implementation passes empty IDs as placeholders until SSOSessionRepo is wired. - redirectURL, err := h.samlService.InitiateLogout(sessionID, sessionID, "", state) - if err != nil { - applogger.L().Errorf("SAML SLO initiation failed: %v", err) - statusCode := http.StatusInternalServerError - errCode := response.ErrInternal - if errors.Is(err, auth.ErrSAMLEnabled) { - statusCode = http.StatusNotFound - errCode = response.ErrNotFound - } - c.JSON(statusCode, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: errCode, - Message: "Failed to initiate SAML logout", - Detail: err.Error(), - }, - }) - return - } - - // Redirect user to IdP SLO endpoint - c.Redirect(http.StatusFound, redirectURL) -} - -// IdPInitiatedSLO handles IdP-initiated Single Logout. -// POST /api/v1/saml/slo -// The IdP sends a base64-encoded SAML LogoutRequest to this endpoint. -// We validate the request, terminate all matching SSO sessions, -// and return a LogoutResponse. -func (h *SAMLHandler) IdPInitiatedSLO(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - samlRequest := c.PostForm("SAMLRequest") - if samlRequest == "" { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Missing SAMLRequest parameter", - }, - }) - return - } - - // Process the LogoutRequest from the IdP - logoutResponse, err := h.samlService.ProcessLogoutRequest(samlRequest) - if err != nil { - applogger.L().Errorf("SAML IdP-initiated SLO failed: %v", err) - statusCode := http.StatusInternalServerError - errCode := response.ErrInternal - if errors.Is(err, auth.ErrSAMLEnabled) { - statusCode = http.StatusNotFound - errCode = response.ErrNotFound - } - c.JSON(statusCode, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: errCode, - Message: "Failed to process SAML logout request", - Detail: err.Error(), - }, - }) - return - } - - // Return the LogoutResponse for the IdP (base64-encoded XML) - c.JSON(http.StatusOK, response.APIResponse{ - Success: true, - Data: map[string]string{ - "logout_response": logoutResponse, - }, - }) -} - -// SLOResponse handles the IdP's LogoutResponse for SP-initiated SLO. -// GET /api/v1/saml/slo/response -// After the IdP processes our LogoutRequest, it redirects the user back -// to this endpoint with a SAMLResponse (LogoutResponse) + RelayState. -func (h *SAMLHandler) SLOResponse(c *gin.Context) { - if !h.samlCfg.Enabled { - c.JSON(http.StatusNotFound, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrNotFound, - Message: "SAML is not enabled", - }, - }) - return - } - - samlResponse := c.Query("SAMLResponse") - if samlResponse == "" { - c.JSON(http.StatusBadRequest, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: response.ErrBadRequest, - Message: "Missing SAMLResponse parameter", - }, - }) - return - } - - relayState := c.Query("RelayState") - - err := h.samlService.ProcessLogoutResponse(samlResponse, relayState) - if err != nil { - applogger.L().Errorf("SAML SLO response validation failed: %v", err) - statusCode := http.StatusInternalServerError - errCode := response.ErrInternal - if errors.Is(err, auth.ErrSAMLEnabled) { - statusCode = http.StatusNotFound - errCode = response.ErrNotFound - } - c.JSON(statusCode, response.APIResponse{ - Success: false, - Error: &response.ErrorBody{ - Code: errCode, - Message: "SAML logout response validation failed", - Detail: err.Error(), - }, - }) - return - } - - // SLO successful — redirect user to the RelayState URL (or home) - redirectURL := relayState - if redirectURL == "" { - redirectURL = "/" - } - c.Redirect(http.StatusFound, redirectURL) -} \ No newline at end of file diff --git a/backend/internal/model/account_ldap_settings.go b/backend/internal/model/account_ldap_settings.go deleted file mode 100644 index fb7d7e08..00000000 --- a/backend/internal/model/account_ldap_settings.go +++ /dev/null @@ -1,46 +0,0 @@ -package model - -// Reference: M13 §4.4 — LDAP/Active Directory per-account configuration -// Enables multi-tenant LDAP identity isolation: each account (tenant) can configure -// its own LDAP server, allowing enterprise customers to connect to their existing -// Active Directory or LDAP infrastructure while maintaining complete isolation -// between tenants. -// This is a GoChat enterprise feature that Chatwoot does not offer. - -import ( - "encoding/json" - "time" - - "gorm.io/gorm" -) - -// AccountLDAPSettings stores per-account LDAP configuration for multi-tenant identity isolation. -// Each account can have exactly one active LDAP configuration. -type AccountLDAPSettings struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"not null;uniqueIndex" json:"account_id"` // one active config per account - Host string `gorm:"size:200;not null" json:"host"` // LDAP server hostname - Port int `gorm:"not null;default:389" json:"port"` // LDAP port (389=plain, 636=LDAPS) - UseTLS bool `gorm:"default:false" json:"use_tls"` // use StartTLS on connection - BaseDN string `gorm:"size:200;not null" json:"base_dn"` // search base DN (e.g. dc=example,dc=com) - BindDN string `gorm:"size:200" json:"bind_dn,omitempty"` // service account bind DN - BindPassword string `gorm:"size:200" json:"-"` // bind password (not exposed via API) - UserFilter string `gorm:"size:200;default:'(objectClass=person)'" json:"user_filter"` // LDAP search filter for users - EmailAttribute string `gorm:"size:50;default:'mail'" json:"email_attribute"` // attribute for email - NameAttribute string `gorm:"size:50;default:'cn'" json:"name_attribute"` // attribute for display name - FirstNameAttribute string `gorm:"size:50;default:'givenName'" json:"first_name_attribute"` // attribute for first name - LastNameAttribute string `gorm:"size:50;default:'sn'" json:"last_name_attribute"` // attribute for last name - GroupAttribute string `gorm:"size:50;default:'memberOf'" json:"group_attribute"` // attribute for group membership - GroupFilter string `gorm:"size:200" json:"group_filter,omitempty"` // filter for group search (e.g. (objectClass=group)) - RoleMappings json.RawMessage `gorm:"type:jsonb" json:"role_mappings"` // LDAP group -> GoChat role mapping - AutoProvision bool `gorm:"default:true" json:"auto_provision"` // auto-create GoChat user on first LDAP login - SyncInterval int `gorm:"default:3600" json:"sync_interval"` // group sync interval in seconds - Active bool `gorm:"default:true" json:"active"` // whether this config is active - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` -} - -func (AccountLDAPSettings) TableName() string { return "account_ldap_settings" } diff --git a/backend/internal/model/account_oidc_settings.go b/backend/internal/model/account_oidc_settings.go index a6fdb4ca..eab38f21 100644 --- a/backend/internal/model/account_oidc_settings.go +++ b/backend/internal/model/account_oidc_settings.go @@ -5,7 +5,7 @@ package model // its own OIDC provider (Google Workspace, Auth0, Keycloak, Azure AD, etc.), // allowing enterprise customers to bring their own OIDC IdP while maintaining // complete identity isolation between tenants. -// This is a GoChat enterprise feature that Chatwoot does not offer (Chatwoot only has SAML). +// This is a GoChat enterprise feature that Chatwoot does not offer. import ( "encoding/json" diff --git a/backend/internal/model/account_saml_settings.go b/backend/internal/model/account_saml_settings.go deleted file mode 100644 index 66e478e4..00000000 --- a/backend/internal/model/account_saml_settings.go +++ /dev/null @@ -1,28 +0,0 @@ -package model - -import ( - "encoding/json" - "time" -) - -// AccountSamlSettings stores SAML SSO configuration for an account. -// Reference: Chatwoot enterprise AccountSamlSettings + P2B M1 spec -type AccountSamlSettings struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"uniqueIndex;not null" json:"account_id"` - IdpEntityID string `gorm:"size:512" json:"idp_entity_id"` - IdpSsoTargetURL string `gorm:"size:512" json:"idp_sso_target_url"` - IdpSloTargetURL string `gorm:"size:512" json:"idp_slo_target_url"` - IdpCertificate string `gorm:"type:text" json:"idp_certificate"` - SpEntityID string `gorm:"size:512" json:"sp_entity_id"` - SpX509Certificate string `gorm:"type:text" json:"sp_x509_certificate"` - SpPrivateKey string `gorm:"type:text" json:"-"` - RoleMappings json.RawMessage `gorm:"type:jsonb" json:"role_mappings"` - Active bool `gorm:"default:true" json:"active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` -} - -func (AccountSamlSettings) TableName() string { return "account_saml_settings" } \ No newline at end of file diff --git a/backend/internal/model/account_saml_settings.go.txt b/backend/internal/model/account_saml_settings.go.txt deleted file mode 100644 index 66e478e4..00000000 --- a/backend/internal/model/account_saml_settings.go.txt +++ /dev/null @@ -1,28 +0,0 @@ -package model - -import ( - "encoding/json" - "time" -) - -// AccountSamlSettings stores SAML SSO configuration for an account. -// Reference: Chatwoot enterprise AccountSamlSettings + P2B M1 spec -type AccountSamlSettings struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"uniqueIndex;not null" json:"account_id"` - IdpEntityID string `gorm:"size:512" json:"idp_entity_id"` - IdpSsoTargetURL string `gorm:"size:512" json:"idp_sso_target_url"` - IdpSloTargetURL string `gorm:"size:512" json:"idp_slo_target_url"` - IdpCertificate string `gorm:"type:text" json:"idp_certificate"` - SpEntityID string `gorm:"size:512" json:"sp_entity_id"` - SpX509Certificate string `gorm:"type:text" json:"sp_x509_certificate"` - SpPrivateKey string `gorm:"type:text" json:"-"` - RoleMappings json.RawMessage `gorm:"type:jsonb" json:"role_mappings"` - Active bool `gorm:"default:true" json:"active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` -} - -func (AccountSamlSettings) TableName() string { return "account_saml_settings" } \ No newline at end of file diff --git a/backend/internal/model/saml_idp_config.go b/backend/internal/model/saml_idp_config.go deleted file mode 100644 index 6b3277b4..00000000 --- a/backend/internal/model/saml_idp_config.go +++ /dev/null @@ -1,46 +0,0 @@ -package model - -// Reference: M13 §3 — SAML Identity Provider per-account configuration -// Enables multi-tenant identity isolation: each account (tenant) can configure -// its own SAML IdP, allowing enterprise customers to bring their own IdP -// (Okta, Azure AD, OneLogin, etc.) while maintaining complete identity isolation -// between tenants. -// This mirrors Chatwoot's account-scoped SAML configuration pattern. - -import ( - "time" - - "gorm.io/gorm" -) - -// SAMLIdPConfig stores per-account SAML IdP configuration for multi-tenant identity isolation. -// Each account can have exactly one active SAML IdP configuration. -type SAMLIdPConfig struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"not null;uniqueIndex" json:"account_id"` // one active config per account - IdPEntityID string `gorm:"size:512;not null" json:"idp_entity_id"` // IdP entity ID (e.g. https://idp.example.com/metadata) - IdPMetadataURL string `gorm:"size:1024" json:"idp_metadata_url,omitempty"` // URL to fetch IdP metadata XML - IdPMetadataXML string `gorm:"type:text" json:"idp_metadata_xml,omitempty"` // raw IdP metadata XML (fallback if URL unavailable) - SPEntityID string `gorm:"size:512;not null" json:"sp_entity_id"` // SP entity ID for this account (overrides global default) - ACSURL string `gorm:"size:1024;not null" json:"acs_url"` // ACS URL for this account (overrides global default) - AttributeMapping string `gorm:"type:text" json:"attribute_mapping,omitempty"` // JSON: {"uid":"nameid","email":"email","firstName":"givenName","lastName":"surname"} - ClockDriftTolerance int `gorm:"default:300" json:"clock_drift_tolerance"` // seconds of allowed clock skew - Active bool `gorm:"not null;default:true" json:"active"` // whether this config is active - CreatedBy uint `gorm:"not null" json:"created_by"` // admin user who created this config - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` - - // Relations - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` -} - -func (SAMLIdPConfig) TableName() string { return "saml_idp_configs" } - -// SAMLAttributeMapping defines how SAML assertion attributes map to gochat user fields. -type SAMLAttributeMapping struct { - UID string `json:"uid"` // maps to user identifier (default: NameID) - Email string `json:"email"` // maps to user email - FirstName string `json:"firstName"` // maps to user first_name - LastName string `json:"lastName"` // maps to user last_name -} \ No newline at end of file diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 4d03661d..43e96777 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -17,7 +17,7 @@ type User struct { Email string `gorm:"size:255;uniqueIndex;not null" json:"email"` Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt) PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service - Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, saml + Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, oidc UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers AvatarURL string `gorm:"size:512" json:"avatar_url"` DisplayName string `gorm:"size:255" json:"display_name"` @@ -29,6 +29,8 @@ type User struct { Type string `gorm:"size:50;default:user" json:"type"` Active bool `gorm:"default:true" json:"active"` Available bool `gorm:"default:false" json:"available"` + // Deprecated: MFA/TOTP functionality has been removed. These fields are kept + // only to avoid GORM schema mismatch with existing DB columns. TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"` TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"` CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"` diff --git a/backend/internal/repository/account_ldap_settings_repo.go b/backend/internal/repository/account_ldap_settings_repo.go deleted file mode 100644 index e6e89b77..00000000 --- a/backend/internal/repository/account_ldap_settings_repo.go +++ /dev/null @@ -1,99 +0,0 @@ -package repository - -// Reference: M13 §4.4 — Account LDAP settings repository -// Per-account LDAP configuration CRUD, following AccountSamlSettingsRepo pattern. -// Enables enterprise administrators to configure LDAP SSO for their accounts -// with full multi-tenant isolation. - -import ( - "encoding/json" - - "gorm.io/gorm" - - "github.com/gochat/gochat/internal/model" -) - -// AccountLDAPSettingsRepo manages per-account LDAP SSO settings in the database. -type AccountLDAPSettingsRepo struct { - db *gorm.DB -} - -// NewAccountLDAPSettingsRepo creates a new AccountLDAPSettings repository. -func NewAccountLDAPSettingsRepo(db *gorm.DB) *AccountLDAPSettingsRepo { - return &AccountLDAPSettingsRepo{db: db} -} - -// GetByAccount retrieves the LDAP settings for an account. -// Returns nil if no settings exist for the account. -func (r *AccountLDAPSettingsRepo) GetByAccount(accountID uint) (*model.AccountLDAPSettings, error) { - var settings model.AccountLDAPSettings - err := r.db.Where("account_id = ?", accountID).First(&settings).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &settings, nil -} - -// GetActiveByAccount retrieves the active LDAP settings for an account. -// Returns nil if no active settings exist. -func (r *AccountLDAPSettingsRepo) GetActiveByAccount(accountID uint) (*model.AccountLDAPSettings, error) { - var settings model.AccountLDAPSettings - err := r.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &settings, nil -} - -// Create creates new LDAP settings for an account. -// Returns an error if settings already exist for the account (unique constraint). -func (r *AccountLDAPSettingsRepo) Create(settings *model.AccountLDAPSettings) error { - return r.db.Create(settings).Error -} - -// Update updates existing LDAP settings for an account. -func (r *AccountLDAPSettingsRepo) Update(settings *model.AccountLDAPSettings) error { - return r.db.Save(settings).Error -} - -// UpdateFields updates specific fields of the LDAP settings. -// Only non-zero fields in the updates map will be changed. -func (r *AccountLDAPSettingsRepo) UpdateFields(accountID uint, updates map[string]interface{}) error { - // Handle RoleMappings separately — it needs JSON serialization - if roleMappings, ok := updates["role_mappings"]; ok { - switch v := roleMappings.(type) { - case json.RawMessage: - updates["role_mappings"] = v - case string: - updates["role_mappings"] = json.RawMessage(v) - case map[string]interface{}: - data, err := json.Marshal(v) - if err != nil { - return err - } - updates["role_mappings"] = json.RawMessage(data) - } - } - - return r.db.Model(&model.AccountLDAPSettings{}). - Where("account_id = ?", accountID). - Updates(updates).Error -} - -// Delete removes LDAP settings for an account (soft delete via DeletedAt). -func (r *AccountLDAPSettingsRepo) Delete(accountID uint) error { - return r.db.Where("account_id = ?", accountID).Delete(&model.AccountLDAPSettings{}).Error -} - -// SetActive toggles the active status of LDAP settings for an account. -func (r *AccountLDAPSettingsRepo) SetActive(accountID uint, active bool) error { - return r.db.Model(&model.AccountLDAPSettings{}). - Where("account_id = ?", accountID). - Update("active", active).Error -} \ No newline at end of file diff --git a/backend/internal/repository/account_oidc_settings_repo.go b/backend/internal/repository/account_oidc_settings_repo.go index ac2fee5a..58703f57 100644 --- a/backend/internal/repository/account_oidc_settings_repo.go +++ b/backend/internal/repository/account_oidc_settings_repo.go @@ -1,7 +1,7 @@ package repository // Reference: M13 §4.5 — Account OIDC settings repository -// Per-account OIDC configuration CRUD, following AccountSamlSettingsRepo pattern. +// Per-account OIDC configuration CRUD. // Enables enterprise administrators to configure OIDC/OAuth SSO for their accounts // with full multi-tenant isolation. diff --git a/backend/internal/repository/account_saml_settings_repo.go b/backend/internal/repository/account_saml_settings_repo.go deleted file mode 100644 index 8ae1987c..00000000 --- a/backend/internal/repository/account_saml_settings_repo.go +++ /dev/null @@ -1,99 +0,0 @@ -package repository - -// Reference: M13 §2 — Account SAML settings repository -// Per-account SAML configuration CRUD, following the pattern of SAMLIdPConfigRepo. -// Enables enterprise administrators to configure SAML SSO for their accounts -// without touching global IdP configuration. - -import ( - "encoding/json" - - "gorm.io/gorm" - - "github.com/gochat/gochat/internal/model" -) - -// AccountSamlSettingsRepo manages per-account SAML SSO settings in the database. -type AccountSamlSettingsRepo struct { - db *gorm.DB -} - -// NewAccountSamlSettingsRepo creates a new AccountSamlSettings repository. -func NewAccountSamlSettingsRepo(db *gorm.DB) *AccountSamlSettingsRepo { - return &AccountSamlSettingsRepo{db: db} -} - -// GetByAccount retrieves the SAML settings for an account. -// Returns nil if no settings exist for the account. -func (r *AccountSamlSettingsRepo) GetByAccount(accountID uint) (*model.AccountSamlSettings, error) { - var settings model.AccountSamlSettings - err := r.db.Where("account_id = ?", accountID).First(&settings).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &settings, nil -} - -// GetActiveByAccount retrieves the active SAML settings for an account. -// Returns nil if no active settings exist. -func (r *AccountSamlSettingsRepo) GetActiveByAccount(accountID uint) (*model.AccountSamlSettings, error) { - var settings model.AccountSamlSettings - err := r.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &settings, nil -} - -// Create creates new SAML settings for an account. -// Returns an error if settings already exist for the account (unique constraint). -func (r *AccountSamlSettingsRepo) Create(settings *model.AccountSamlSettings) error { - return r.db.Create(settings).Error -} - -// Update updates existing SAML settings for an account. -func (r *AccountSamlSettingsRepo) Update(settings *model.AccountSamlSettings) error { - return r.db.Save(settings).Error -} - -// UpdateFields updates specific fields of the SAML settings. -// Only non-zero fields in the updates map will be changed. -func (r *AccountSamlSettingsRepo) UpdateFields(accountID uint, updates map[string]interface{}) error { - // Handle RoleMappings separately — it needs JSON serialization - if roleMappings, ok := updates["role_mappings"]; ok { - switch v := roleMappings.(type) { - case json.RawMessage: - updates["role_mappings"] = v - case string: - updates["role_mappings"] = json.RawMessage(v) - case map[string]interface{}: - data, err := json.Marshal(v) - if err != nil { - return err - } - updates["role_mappings"] = json.RawMessage(data) - } - } - - return r.db.Model(&model.AccountSamlSettings{}). - Where("account_id = ?", accountID). - Updates(updates).Error -} - -// Delete removes SAML settings for an account. -func (r *AccountSamlSettingsRepo) Delete(accountID uint) error { - return r.db.Where("account_id = ?", accountID).Delete(&model.AccountSamlSettings{}).Error -} - -// SetActive toggles the active status of SAML settings for an account. -func (r *AccountSamlSettingsRepo) SetActive(accountID uint, active bool) error { - return r.db.Model(&model.AccountSamlSettings{}). - Where("account_id = ?", accountID). - Update("active", active).Error -} \ No newline at end of file diff --git a/backend/internal/repository/saml_idp_config_repo.go b/backend/internal/repository/saml_idp_config_repo.go deleted file mode 100644 index 8b1cdd73..00000000 --- a/backend/internal/repository/saml_idp_config_repo.go +++ /dev/null @@ -1,92 +0,0 @@ -package repository - -// Reference: M13 §5 — SAML IdP configuration repository -// Per-account SAML IdP configuration for multi-tenant identity isolation. -// Each account (tenant) can configure its own SAML IdP, enabling enterprise -// customers to bring their own IdP while maintaining identity isolation. - -import ( - "encoding/json" - - "gorm.io/gorm" - - "github.com/gochat/gochat/internal/model" -) - -// SAMLIdPConfigRepo manages per-account SAML IdP configuration in the database. -type SAMLIdPConfigRepo struct { - db *gorm.DB -} - -// NewSAMLIdPConfigRepo creates a SAML IdP configuration repository. -func NewSAMLIdPConfigRepo(db *gorm.DB) *SAMLIdPConfigRepo { - return &SAMLIdPConfigRepo{db: db} -} - -// GetActiveByAccount retrieves the active SAML IdP config for an account. -// Returns nil if no active config exists for the account. -func (r *SAMLIdPConfigRepo) GetActiveByAccount(accountID uint) (*model.SAMLIdPConfig, error) { - var config model.SAMLIdPConfig - err := r.db.Where("account_id = ? AND active = ?", accountID, true).First(&config).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &config, nil -} - -// GetByAccount retrieves any SAML IdP config for an account (including inactive). -func (r *SAMLIdPConfigRepo) GetByAccount(accountID uint) (*model.SAMLIdPConfig, error) { - var config model.SAMLIdPConfig - err := r.db.Where("account_id = ?", accountID).First(&config).Error - if err == gorm.ErrRecordNotFound { - return nil, nil - } - if err != nil { - return nil, err - } - return &config, nil -} - -// Create creates a new SAML IdP config for an account. -func (r *SAMLIdPConfigRepo) Create(config *model.SAMLIdPConfig) error { - return r.db.Create(config).Error -} - -// Update updates an existing SAML IdP config. -func (r *SAMLIdPConfigRepo) Update(config *model.SAMLIdPConfig) error { - return r.db.Save(config).Error -} - -// Delete soft-deletes an SAML IdP config. -func (r *SAMLIdPConfigRepo) Delete(accountID uint) error { - return r.db.Where("account_id = ?", accountID).Delete(&model.SAMLIdPConfig{}).Error -} - -// ListByAccount retrieves all SAML IdP configs for an account (including history). -func (r *SAMLIdPConfigRepo) ListByAccount(accountID uint) ([]model.SAMLIdPConfig, error) { - var configs []model.SAMLIdPConfig - err := r.db.Where("account_id = ?", accountID).Find(&configs).Error - return configs, err -} - -// GetAttributeMapping parses the JSON attribute mapping string into a struct. -func (r *SAMLIdPConfigRepo) GetAttributeMapping(config *model.SAMLIdPConfig) (*model.SAMLAttributeMapping, error) { - if config.AttributeMapping == "" { - // Default mapping - return &model.SAMLAttributeMapping{ - UID: "nameid", - Email: "email", - FirstName: "givenName", - LastName: "surname", - }, nil - } - - var mapping model.SAMLAttributeMapping - if err := json.Unmarshal([]byte(config.AttributeMapping), &mapping); err != nil { - return nil, err - } - return &mapping, nil -} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 696e2423..01a592ce 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -40,8 +40,6 @@ var startTime = time.Now() type Handlers struct { RBAC middleware.RBACLookup Auth *v1.AuthHandler - MFA *v1.MFAHandler - SAML *v1.SAMLHandler Account *v1.AccountHandler EnterpriseAccount *v1.EnterpriseAccountHandler Contact *v1.ContactHandler @@ -102,13 +100,11 @@ type Handlers struct { Search *v1.SearchHandler Campaign *v1.CampaignHandler Widget *widget.WidgetHandler - // M13: SSO/SAML enterprise authentication handlers - AccountSamlSettings *v1.AccountSamlSettingsHandler - SSOSession *v1.SSOSessionHandler - // M13: LDAP/OIDC enterprise authentication handlers - LDAP *v1.LDAPHandler - OIDC *v1.OIDCHandler - SSOMiddleware *auth.SSOMiddleware + // M13: SSO enterprise authentication handlers + SSOSession *v1.SSOSessionHandler + // M13: OIDC enterprise authentication handler + OIDC *v1.OIDCHandler + SSOMiddleware *auth.SSOMiddleware InstagramChannel *v1.InstagramChannelHandler FacebookChannel *v1.FacebookChannelHandler TwitterChannel *v1.TwitterChannelHandler @@ -236,20 +232,9 @@ func RegisterRoutes( v1.RegisterAuthRoutes(engine.Group("/api/v1"), handlers.Auth) v1.RegisterChatwootAuthRoutes(engine.Group("/auth"), handlers.Auth) - // SAML routes — PUBLIC, no AuthRequired middleware (SAML flow is external) - v1.RegisterSAMLRoutes(engine.Group("/api/v1"), handlers.SAML) - - // LDAP routes — PUBLIC, no AuthRequired middleware (LDAP bind is external) - v1.RegisterLDAPRoutes(engine.Group("/api/v1"), handlers.LDAP) - // OIDC routes — mixed: public auth flow + admin config routes (OIDC flow is external) v1.RegisterOIDCRoutes(engine.Group("/api/v1"), handlers.OIDC, middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) - // MFA routes — require authentication for enable/verify/disable - mfaPublic := engine.Group("/api/v1") - mfaPublic.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) - v1.RegisterMFARoutes(mfaPublic, handlers.MFA) - // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") apiV1.Use(middleware.AuthMiddlewareWithServiceAndDB(jwtService, db)) @@ -646,21 +631,6 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { g.GET("/profile/sessions", h.Profile.ListSessions) g.DELETE("/profile/sessions/:id", h.Profile.RevokeSession) - // MFA routes under profile scope (Chatwoot: scope module: 'profile' do resource :mfa) - // GET /profile/mfa — show status, POST /profile/mfa — create (enable), DELETE /profile/mfa — destroy (disable) - // POST /profile/mfa/verify — verify TOTP code, POST /profile/mfa/backup_codes — generate backup codes - g.GET("/profile/mfa", h.MFA.ProfileMFAStatus) - g.POST("/profile/mfa", h.MFA.ProfileEnableMFA) - g.DELETE("/profile/mfa", h.MFA.ProfileDisableMFA) - profileMfa := g.Group("/profile/mfa") - { - profileMfa.GET("/", h.MFA.ProfileMFAStatus) - profileMfa.POST("/", h.MFA.ProfileEnableMFA) - profileMfa.DELETE("/", h.MFA.ProfileDisableMFA) - profileMfa.POST("/verify", h.MFA.ProfileVerifyMFA) - profileMfa.POST("/backup_codes", h.MFA.ProfileBackupCodes) - } - // Notification routes — user-scoped, not account-scoped // Reference: Chatwoot also has user-scoped notification routes (not account-scoped) g.GET("/notifications", h.Notification.List) @@ -1967,11 +1937,6 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { accountScoped.GET("/search/contacts", h.Search.SearchContacts) accountScoped.GET("/search/articles", h.Search.SearchArticles) - // Account SAML settings — account-scoped SAML config admin API (M13) - // Only accessible to account administrators. - // Reference: Chatwoot AccountSamlSettings — enterprise SSO configuration - v1.RegisterAccountSamlSettingsRoutes(accountScoped.Group("/saml_settings"), h.AccountSamlSettings) - // Custom Attribute Definitions — CRUD for attribute schema definitions // Reference: Chatwoot custom_attribute_definitions_controller.rb customAttrDefs := accountScoped.Group("/custom_attribute_definitions") diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index dd07df82..ec5b8ac5 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -29,7 +29,6 @@ type AuthService struct { db *gorm.DB jwtService *auth.JWTService refreshStore *auth.RefreshTokenStore - mfaService *auth.MFAService } // NewAuthService creates an auth service with all required dependencies. @@ -37,13 +36,11 @@ func NewAuthService( db *gorm.DB, jwtService *auth.JWTService, refreshStore *auth.RefreshTokenStore, - mfaService *auth.MFAService, ) *AuthService { return &AuthService{ db: db, jwtService: jwtService, refreshStore: refreshStore, - mfaService: mfaService, } } @@ -57,12 +54,11 @@ type LoginInput struct { // LoginOutput holds login response data. type LoginOutput struct { - User *model.User - TokenPair *auth.TokenPair - AccountID uint - Role string - MFARequired bool - ClientID string + User *model.User + TokenPair *auth.TokenPair + AccountID uint + Role string + ClientID string } func (s *AuthService) TrackChatwootSession(ctx context.Context, output *LoginOutput, requestedClientID, ipAddress, userAgent string) error { @@ -148,8 +144,7 @@ func chatwootSessionUserAgent(userAgent string) (browserName, browserVersion, de } // Login authenticates a user by email+password. -// Flow: verify credentials → check MFA → generate JWT pair. -// If MFA is enabled, returns MFARequired=true without tokens; client must verify TOTP first. +// Flow: verify credentials → generate JWT pair. func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutput, error) { email := strings.TrimSpace(strings.ToLower(input.Email)) // Find user by email @@ -181,14 +176,6 @@ func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutpu return nil, fmt.Errorf("email not confirmed, please verify your email first") } - // Check MFA requirement - if user.TOTPEnabled { - return &LoginOutput{ - User: &user, - MFARequired: true, - }, nil - } - // Get user's first active account (Chatwoot: AccountUser join) accountID, role, err := s.getUserDefaultAccount(&user) if err != nil { @@ -258,53 +245,6 @@ func (s *AuthService) ValidateAccessToken(ctx context.Context, accessToken strin return &LoginOutput{User: &user, AccountID: accountID, Role: role, ClientID: claims.ClientID}, nil } -// LoginWithMFA completes login after MFA verification. -// Called after user provides valid TOTP code. -func (s *AuthService) LoginWithMFA(ctx context.Context, userID uint, totpCode string) (*LoginOutput, error) { - // Verify TOTP code - valid, err := s.mfaService.VerifyTOTPCode(userID, totpCode) - if err != nil { - return nil, fmt.Errorf("mfa verification failed: %w", err) - } - if !valid { - return nil, fmt.Errorf("invalid totp code") - } - - // Find user - var user model.User - if err := s.db.First(&user, userID).Error; err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - - // Get account and generate tokens (same flow as Login) - accountID, role, err := s.getUserDefaultAccount(&user) - if err != nil { - return nil, fmt.Errorf("failed to get user account: %w", err) - } - - tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role) - if err != nil { - return nil, fmt.Errorf("failed to generate tokens: %w", err) - } - - if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil { - return nil, fmt.Errorf("failed to store refresh token: %w", err) - } - - user.SignInCount++ - now := time.Now() - user.LastSignInAt = user.CurrentSignInAt - user.CurrentSignInAt = &now - s.db.Save(&user) - - return &LoginOutput{ - User: &user, - TokenPair: tokenPair, - AccountID: accountID, - Role: role, - }, nil -} - // --- Token Refresh / Rotation --- // RefreshInput holds refresh token request parameters. diff --git a/backend/internal/service/auth_service_test.go b/backend/internal/service/auth_service_test.go index 4ebe7991..26304857 100644 --- a/backend/internal/service/auth_service_test.go +++ b/backend/internal/service/auth_service_test.go @@ -29,7 +29,7 @@ func setupAuthServiceTest(t *testing.T) (*AuthService, *gorm.DB, *model.User) { require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) jwtCfg := &config.JWTConfig{Secret: "auth-service-secret", ExpiryHours: 1, RefreshExpiryHours: 24} - return NewAuthService(db, auth.NewJWTService(jwtCfg), auth.NewRefreshTokenStore(nil, jwtCfg), nil), db, user + return NewAuthService(db, auth.NewJWTService(jwtCfg), auth.NewRefreshTokenStore(nil, jwtCfg)), db, user } func TestAuthService_ResetPasswordStoresDigestToken(t *testing.T) { diff --git a/backend/internal/service/enterprise_billing_worker.go b/backend/internal/service/enterprise_billing_worker.go index dae95950..23c1d942 100644 --- a/backend/internal/service/enterprise_billing_worker.go +++ b/backend/internal/service/enterprise_billing_worker.go @@ -318,7 +318,7 @@ func reconcileCloudPlanFeatures(account *model.Account, planName, defaultPlanNam _ = json.Unmarshal([]byte(account.FeatureFlags), &flags) startup := []string{"inbound_emails", "help_center", "campaigns", "team_management", "channel_facebook", "channel_email", "channel_instagram", "channel_tiktok", "captain_integration", "captain_document_auto_sync", "advanced_search_indexing", "advanced_search", "linear_integration", "channel_voice"} business := []string{"sla", "custom_roles", "csat_review_notes", "conversation_required_attributes", "advanced_assignment", "custom_tools", "companies"} - enterprise := []string{"audit_logs", "disable_branding", "saml"} + enterprise := []string{"audit_logs", "disable_branding", "oidc"} for _, feature := range append(append(append([]string{}, startup...), business...), enterprise...) { flags[feature] = false } diff --git a/backend/tests/e2e/e2e_test.go b/backend/tests/e2e/e2e_test.go index 44fc8cf5..c86f8697 100644 --- a/backend/tests/e2e/e2e_test.go +++ b/backend/tests/e2e/e2e_test.go @@ -104,8 +104,7 @@ func (s *E2ETestSuite) SetupSuite() { _ = redisClient // keep miniredis alive for test duration refreshTokenStore := auth.NewRefreshTokenStore(redisClient, &cfg.JWT) - mfaService := auth.NewMFAService(db) - authService := service.NewAuthService(db, jwtService, refreshTokenStore, mfaService) + authService := service.NewAuthService(db, jwtService, refreshTokenStore) authHandler := handler.NewAuthHandler(authService) // Register auth routes using helper function diff --git a/backend/tests/e2e/session_e2e_test.go b/backend/tests/e2e/session_e2e_test.go index b2087dfc..af784b5a 100644 --- a/backend/tests/e2e/session_e2e_test.go +++ b/backend/tests/e2e/session_e2e_test.go @@ -69,7 +69,7 @@ func (s *SessionE2ETestSuite) TestSessionGetNotFound() { } func (s *SessionE2ETestSuite) TestSessionDelete() { - session, err := s.store.Create(3, 300, "agent", "saml") + session, err := s.store.Create(3, 300, "agent", "oidc") assert.NoError(s.T(), err) err = s.store.Delete(session.ID) diff --git a/frontend/app/javascript/dashboard/api/mfa.js b/frontend/app/javascript/dashboard/api/mfa.js deleted file mode 100644 index 38cb9381..00000000 --- a/frontend/app/javascript/dashboard/api/mfa.js +++ /dev/null @@ -1,28 +0,0 @@ -/* global axios */ -import ApiClient from './ApiClient'; - -class MfaAPI extends ApiClient { - constructor() { - super('profile/mfa', { accountScoped: false }); - } - - enable() { - return axios.post(`${this.url}`); - } - - verify(otpCode) { - return axios.post(`${this.url}/verify`, { otp_code: otpCode }); - } - - disable(password, { otpCode, backupCode } = {}) { - return axios.delete(this.url, { - data: { password, otp_code: otpCode, backup_code: backupCode }, - }); - } - - regenerateBackupCodes(otpCode) { - return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode }); - } -} - -export default new MfaAPI(); diff --git a/frontend/app/javascript/dashboard/api/samlSettings.js b/frontend/app/javascript/dashboard/api/samlSettings.js deleted file mode 100644 index 7c0f5b26..00000000 --- a/frontend/app/javascript/dashboard/api/samlSettings.js +++ /dev/null @@ -1,26 +0,0 @@ -/* global axios */ -import ApiClient from './ApiClient'; - -class SamlSettingsAPI extends ApiClient { - constructor() { - super('saml_settings', { accountScoped: true }); - } - - get() { - return axios.get(this.url); - } - - create(data) { - return axios.post(this.url, { saml_settings: data }); - } - - update(data) { - return axios.put(this.url, { saml_settings: data }); - } - - delete() { - return axios.delete(this.url); - } -} - -export default new SamlSettingsAPI(); diff --git a/frontend/app/javascript/dashboard/components/auth/MfaVerification.vue b/frontend/app/javascript/dashboard/components/auth/MfaVerification.vue deleted file mode 100644 index 2f17d790..00000000 --- a/frontend/app/javascript/dashboard/components/auth/MfaVerification.vue +++ /dev/null @@ -1,328 +0,0 @@ - - - diff --git a/frontend/app/javascript/dashboard/featureFlags.js b/frontend/app/javascript/dashboard/featureFlags.js index 00a79763..fa670797 100644 --- a/frontend/app/javascript/dashboard/featureFlags.js +++ b/frontend/app/javascript/dashboard/featureFlags.js @@ -42,7 +42,6 @@ export const FEATURE_FLAGS = { CAPTAIN_V2: 'captain_integration_v2', CAPTAIN_TASKS: 'captain_tasks', CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync', - SAML: 'saml', COMPANIES: 'companies', ADVANCED_SEARCH: 'advanced_search', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', @@ -56,7 +55,6 @@ export const PREMIUM_FEATURES = [ FEATURE_FLAGS.CUSTOM_ROLES, FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.HELP_CENTER, - FEATURE_FLAGS.SAML, FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES, FEATURE_FLAGS.ADVANCED_ASSIGNMENT, ]; diff --git a/frontend/app/javascript/dashboard/helper/featureHelper.js b/frontend/app/javascript/dashboard/helper/featureHelper.js index c90ec15d..03e11445 100644 --- a/frontend/app/javascript/dashboard/helper/featureHelper.js +++ b/frontend/app/javascript/dashboard/helper/featureHelper.js @@ -18,7 +18,6 @@ const FEATURE_HELP_URLS = { team_management: 'https://chwt.app/hc/teams', webhook: 'https://chwt.app/hc/webhooks', billing: 'https://chwt.app/pricing', - saml: 'https://chwt.app/hc/saml', captain_billing: 'https://chwt.app/hc/captain_billing', }; diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/index.js b/frontend/app/javascript/dashboard/i18n/locale/en/index.js index 31486a24..6246a1a6 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/en/index.js +++ b/frontend/app/javascript/dashboard/i18n/locale/en/index.js @@ -38,7 +38,6 @@ import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; import contentTemplates from './contentTemplates.json'; -import mfa from './mfa.json'; import onboarding from './onboarding.json'; import yearInReview from './yearInReview.json'; @@ -83,7 +82,6 @@ export default { ...teamsSettings, ...whatsappTemplates, ...contentTemplates, - ...mfa, ...onboarding, ...yearInReview, }; diff --git a/frontend/app/javascript/dashboard/i18n/locale/en/mfa.json b/frontend/app/javascript/dashboard/i18n/locale/en/mfa.json deleted file mode 100644 index 8e356aad..00000000 --- a/frontend/app/javascript/dashboard/i18n/locale/en/mfa.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "MFA_SETTINGS": { - "TITLE": "Two-Factor Authentication", - "SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.", - "DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)", - "STATUS_TITLE": "Authentication Status", - "STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes", - "ENABLED": "Enabled", - "DISABLED": "Disabled", - "STATUS_ENABLED": "Two-factor authentication is active", - "STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security", - "ENABLE_BUTTON": "Enable Two-Factor Authentication", - "ENHANCE_SECURITY": "Enhance Your Account Security", - "ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.", - "SETUP": { - "STEP_NUMBER_1": "1", - "STEP_NUMBER_2": "2", - "STEP1_TITLE": "Scan QR Code with Your Authenticator App", - "STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app", - "LOADING_QR": "Loading...", - "MANUAL_ENTRY": "Can't scan? Enter code manually", - "SECRET_KEY": "Secret Key", - "COPY": "Copy", - "ENTER_CODE": "Enter the 6-digit code from your authenticator app", - "ENTER_CODE_PLACEHOLDER": "000000", - "VERIFY_BUTTON": "Verify & Continue", - "CANCEL": "Cancel", - "ERROR_STARTING": "MFA not enabled. Please contact administrator.", - "INVALID_CODE": "Invalid verification code", - "SECRET_COPIED": "Secret key copied to clipboard", - "SUCCESS": "Two-factor authentication has been enabled successfully" - }, - "BACKUP": { - "TITLE": "Save Your Backup Codes", - "DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator", - "IMPORTANT": "Important:", - "IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.", - "DOWNLOAD": "Download", - "COPY_ALL": "Copy All", - "CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again", - "COMPLETE_SETUP": "Complete Setup", - "CODES_COPIED": "Backup codes copied to clipboard" - }, - "MANAGEMENT": { - "BACKUP_CODES": "Backup Codes", - "BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones", - "REGENERATE": "Regenerate Backup Codes", - "DISABLE_MFA": "Disable 2FA", - "DISABLE_MFA_DESC": "Remove two-factor authentication from your account", - "DISABLE_BUTTON": "Disable Two-Factor Authentication" - }, - "DISABLE": { - "TITLE": "Disable Two-Factor Authentication", - "DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.", - "PASSWORD": "Password", - "OTP_CODE": "Verification Code", - "OTP_CODE_PLACEHOLDER": "000000", - "BACKUP_CODE": "Backup Code", - "BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes", - "USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead", - "USE_OTP_CODE": "Use a verification code from your authenticator app", - "CONFIRM": "Disable 2FA", - "CANCEL": "Cancel", - "SUCCESS": "Two-factor authentication has been disabled", - "ERROR": "Failed to disable MFA. Please check your credentials." - }, - "REGENERATE": { - "TITLE": "Regenerate Backup Codes", - "DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.", - "OTP_CODE": "Verification Code", - "OTP_CODE_PLACEHOLDER": "000000", - "CONFIRM": "Generate New Codes", - "CANCEL": "Cancel", - "NEW_CODES_TITLE": "New Backup Codes Generated", - "NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.", - "CODES_IMPORTANT": "Important:", - "CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.", - "DOWNLOAD_CODES": "Download Codes", - "COPY_ALL_CODES": "Copy All Codes", - "CODES_SAVED": "I've Saved My Codes", - "SUCCESS": "New backup codes have been generated", - "ERROR": "Failed to regenerate backup codes" - } - }, - "MFA_VERIFICATION": { - "TITLE": "Two-Factor Authentication", - "DESCRIPTION": "Enter your verification code to continue", - "AUTHENTICATOR_APP": "Authenticator App", - "BACKUP_CODE": "Backup Code", - "ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app", - "ENTER_BACKUP_CODE": "Enter one of your backup codes", - "BACKUP_CODE_PLACEHOLDER": "000000", - "VERIFY_BUTTON": "Verify", - "TRY_ANOTHER_METHOD": "Try another verification method", - "CANCEL_LOGIN": "Cancel and return to login", - "HELP_TEXT": "Having trouble signing in?", - "LEARN_MORE": "Learn more about 2FA", - "HELP_MODAL": { - "TITLE": "Two-Factor Authentication Help", - "AUTHENTICATOR_TITLE": "Using an Authenticator App", - "AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.", - "BACKUP_TITLE": "Using a Backup Code", - "BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.", - "CONTACT_TITLE": "Need More Help?", - "CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.", - "CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance." - }, - "VERIFICATION_FAILED": "Verification failed. Please try again." - } -} diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/index.js b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/index.js index 785b1e0b..3837393b 100644 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/index.js +++ b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/index.js @@ -27,7 +27,6 @@ import integrations from './integrations.json'; import labelsMgmt from './labelsMgmt.json'; import login from './login.json'; import macros from './macros.json'; -import mfa from './mfa.json'; import onboarding from './onboarding.json'; import report from './report.json'; import resetPassword from './resetPassword.json'; @@ -69,7 +68,6 @@ export default { ...labelsMgmt, ...login, ...macros, - ...mfa, ...onboarding, ...report, ...resetPassword, diff --git a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/mfa.json b/frontend/app/javascript/dashboard/i18n/locale/zh_CN/mfa.json deleted file mode 100644 index 87541f57..00000000 --- a/frontend/app/javascript/dashboard/i18n/locale/zh_CN/mfa.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "MFA_SETTINGS": { - "TITLE": "两步验证", - "SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.", - "DESCRIPTION": "使用基于时间的一次性密码(TOTP)为您的帐户添加额外的一层安全保护", - "STATUS_TITLE": "验证状态", - "STATUS_DESCRIPTION": "管理您的二步验证设置和备份码", - "ENABLED": "已启用", - "DISABLED": "已禁用", - "STATUS_ENABLED": "两步验证已启用", - "STATUS_ENABLED_DESC": "您的帐户受到额外的安全层保护", - "ENABLE_BUTTON": "启用两步验证", - "ENHANCE_SECURITY": "增强您的帐户安全", - "ENHANCE_SECURITY_DESC": "两步验证除了您的密码外还需要额外的身份验证程序的验证码,从而增加了额外的安全层次。", - "SETUP": { - "STEP_NUMBER_1": "1", - "STEP_NUMBER_2": "2", - "STEP1_TITLE": "使用您的身份验证器应用程序扫描二维码", - "STEP1_DESCRIPTION": "使用 Google 身份验证器、Authy 或者任何 TOTP 兼容应用程序", - "LOADING_QR": "加载中...", - "MANUAL_ENTRY": "无法扫描?手动输入代码", - "SECRET_KEY": "密钥", - "COPY": "复制", - "ENTER_CODE": "从您的身份验证程序中输入6位数字代码", - "ENTER_CODE_PLACEHOLDER": "000000", - "VERIFY_BUTTON": "验证并继续", - "CANCEL": "取消", - "ERROR_STARTING": "MFA 未启用。请与管理员联系。", - "INVALID_CODE": "无效的验证码", - "SECRET_COPIED": "密钥已复制到剪贴板", - "SUCCESS": "已成功启用两步验证" - }, - "BACKUP": { - "TITLE": "保存您的备份代码", - "DESCRIPTION": "妥善保管这些备份代码,如果您无法访问身份验证器,每个代码可以使用一次", - "IMPORTANT": "重要:", - "IMPORTANT_NOTE": " 将这些代码保存到一个安全的位置。您将无法再次看到它们。", - "DOWNLOAD": "下载", - "COPY_ALL": "复制全部", - "CONFIRM": "我已经将我的备份代码保存在一个安全的位置,并且知道我将无法再次看到它们。", - "COMPLETE_SETUP": "完成设置", - "CODES_COPIED": "备份代码已复制到剪贴板" - }, - "MANAGEMENT": { - "BACKUP_CODES": "备份代码", - "BACKUP_CODES_DESC": "如果您丢失或使用了您现有的代码,则生成新代码", - "REGENERATE": "重新生成备份代码", - "DISABLE_MFA": "禁用两步验证", - "DISABLE_MFA_DESC": "从您的帐户中删除两步验证", - "DISABLE_BUTTON": "禁用两步验证" - }, - "DISABLE": { - "TITLE": "禁用两步验证", - "DESCRIPTION": "您需要输入您的密码和验证码来禁用两步验证。", - "PASSWORD": "密码", - "OTP_CODE": "验证码", - "OTP_CODE_PLACEHOLDER": "000000", - "BACKUP_CODE": "备份代码", - "BACKUP_CODE_PLACEHOLDER": "输入您的备份代码", - "USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead", - "USE_OTP_CODE": "Use a verification code from your authenticator app", - "CONFIRM": "禁用两步验证", - "CANCEL": "取消", - "SUCCESS": "两步验证已禁用", - "ERROR": "禁用MFA失败。请检查您的凭据。" - }, - "REGENERATE": { - "TITLE": "重新生成备份代码", - "DESCRIPTION": "这将作废您现有的备份代码并生成新的替代。输入您的验证码以继续。", - "OTP_CODE": "验证码", - "OTP_CODE_PLACEHOLDER": "000000", - "CONFIRM": "生成新代码", - "CANCEL": "取消", - "NEW_CODES_TITLE": "新的备份码已生成", - "NEW_CODES_DESC": "您旧的备份代码已失效。将这些新代码保存到一个安全位置。", - "CODES_IMPORTANT": "重要:", - "CODES_IMPORTANT_NOTE": " 每个代码只能使用一次。在关闭此窗口前保存它们。", - "DOWNLOAD_CODES": "下载代码", - "COPY_ALL_CODES": "复制全部代码", - "CODES_SAVED": "我已保存我的代码", - "SUCCESS": "已生成新的备份代码", - "ERROR": "重新生成备份代码失败" - } - }, - "MFA_VERIFICATION": { - "TITLE": "两步验证", - "DESCRIPTION": "输入您的验证码以继续", - "AUTHENTICATOR_APP": "身份验证器应用", - "BACKUP_CODE": "备份代码", - "ENTER_OTP_CODE": "从您的身份验证程序中输入6位数字代码", - "ENTER_BACKUP_CODE": "输入您的备份代码", - "BACKUP_CODE_PLACEHOLDER": "000000", - "VERIFY_BUTTON": "验证", - "TRY_ANOTHER_METHOD": "尝试另一种验证方法", - "CANCEL_LOGIN": "取消并返回登录", - "HELP_TEXT": "登录遇到困难吗?", - "LEARN_MORE": "了解更多关于两步验证的信息", - "HELP_MODAL": { - "TITLE": "两步验证帮助", - "AUTHENTICATOR_TITLE": "使用身份验证器应用程序", - "AUTHENTICATOR_DESC": "打开你的身份验证器应用(Google Autenticator,Authy等),然后输入应用显示的6位数字", - "BACKUP_TITLE": "使用备份代码", - "BACKUP_DESC": "如果您无法访问身份验证器应用程序,你可以使用此前保存的备份代码替代,每个代码只能使用一次。", - "CONTACT_TITLE": "需要更多帮助吗?", - "CONTACT_DESC_CLOUD": "如果您无法访问身份验证器应用程序和备份代码,请联系Chatwoot 支持寻求帮助。", - "CONTACT_DESC_SELF_HOSTED": "如果您无法访问身份验证器应用程序和备份代码,请联系您的管理员寻求帮助。" - }, - "VERIFICATION_FAILED": "验证失败。请重试。" - } -} diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index 75eb8a2f..ab9f4d50 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -7,7 +7,6 @@ import { useBranding } from 'shared/composables/useBranding'; import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js'; import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; -import { parseBoolean } from '@chatwoot/utils'; import UserProfilePicture from './UserProfilePicture.vue'; import UserBasicDetails from './UserBasicDetails.vue'; import MessageSignature from './MessageSignature.vue'; @@ -19,7 +18,6 @@ import AudioNotifications from './AudioNotifications.vue'; import SectionLayout from '../account/components/SectionLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import AccessToken from './AccessToken.vue'; -import MfaSettingsCard from './MfaSettingsCard.vue'; import Policy from 'dashboard/components/policy.vue'; import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import { @@ -41,7 +39,6 @@ export default { NotificationPreferences, AudioNotifications, AccessToken, - MfaSettingsCard, BaseSettingsHeader, }, setup() { @@ -100,9 +97,6 @@ export default { currentUserId: 'getCurrentUserID', globalConfig: 'globalConfig/get', }), - isMfaEnabled() { - return parseBoolean(window.chatwootConfig?.isMfaEnabled); - }, }, mounted() { if (this.currentUserId) { @@ -299,14 +293,6 @@ export default { > - - - -import { ref } from 'vue'; -import { useI18n } from 'vue-i18n'; -import { copyTextToClipboard } from 'shared/helpers/clipboard'; -import { useAlert } from 'dashboard/composables'; -import Button from 'dashboard/components-next/button/Button.vue'; -import Input from 'dashboard/components-next/input/Input.vue'; -import Icon from 'dashboard/components-next/icon/Icon.vue'; -import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; - -const props = defineProps({ - mfaEnabled: { - type: Boolean, - required: true, - }, - backupCodes: { - type: Array, - default: () => [], - }, -}); - -const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']); - -const { t } = useI18n(); - -// Dialog refs -const disableDialogRef = ref(null); -const regenerateDialogRef = ref(null); -const backupCodesDialogRef = ref(null); - -// Form values -const disablePassword = ref(''); -const disableOtpCode = ref(''); -const disableBackupCode = ref(''); -const useBackupCodeToDisable = ref(false); -const regenerateOtpCode = ref(''); - -// Utility functions -const copyBackupCodes = async () => { - const codesText = props.backupCodes.join('\n'); - await copyTextToClipboard(codesText); - useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED')); -}; - -const downloadBackupCodes = () => { - const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`; - const blob = new Blob([codesText], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'chatwoot-backup-codes.txt'; - a.click(); - URL.revokeObjectURL(url); -}; - -const handleDisableMfa = async () => { - emit('disableMfa', { - password: disablePassword.value, - otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value, - backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '', - }); -}; - -const toggleDisableMethod = () => { - useBackupCodeToDisable.value = !useBackupCodeToDisable.value; - disableOtpCode.value = ''; - disableBackupCode.value = ''; -}; - -const handleRegenerateBackupCodes = async () => { - emit('regenerateBackupCodes', { - otpCode: regenerateOtpCode.value, - }); -}; - -// Methods exposed for parent component -const resetDisableForm = () => { - disablePassword.value = ''; - disableOtpCode.value = ''; - disableBackupCode.value = ''; - useBackupCodeToDisable.value = false; - disableDialogRef.value?.close(); -}; - -const resetRegenerateForm = () => { - regenerateOtpCode.value = ''; - regenerateDialogRef.value?.close(); -}; - -const showBackupCodesDialog = () => { - backupCodesDialogRef.value?.open(); -}; - -defineExpose({ - resetDisableForm, - resetRegenerateForm, - showBackupCodesDialog, -}); - - -