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

319 lines
11 KiB
Plaintext

package auth
// Reference: M13 §4 — SSO Session Store (Redis-backed)
// Tracks active SSO sessions across the system for single sign-on and single logout.
// An SSO session is created when a user authenticates via SAML/OAuth and persists
// until the user explicitly logs out or the session expires.
// This enables: (1) SSO — one login grants access to all accounts for that user,
// (2) SLO — logout terminates all active sessions across all accounts,
// (3) audit — track when/where a user authenticated and from which IdP.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
applogger "github.com/gochat/gochat/pkg/logger"
)
// SSOSessionData holds the data stored in Redis for an active SSO session.
type SSOSessionData struct {
SessionID string `json:"session_id"` // unique SSO session identifier
UserID uint `json:"user_id"` // user who owns this session
Provider string `json:"provider"` // saml, google, github
IdPEntityID string `json:"idp_entity_id"` // SAML IdP entity ID (for SAML sessions)
NameID string `json:"name_id"` // SAML NameID from assertion
AccountID uint `json:"account_id"` // account context for this session
Role string `json:"role"` // role in the account
CreatedAt int64 `json:"created_at"` // Unix timestamp of creation
ExpiresAt int64 `json:"expires_at"` // Unix timestamp of expiry
}
// SSOSessionStore manages SSO sessions in Redis for SSO/SLO support.
// Key patterns:
// sso:session:{session_id} → SSOSessionData (single session)
// sso:user_sessions:{user_id} → SET of session_ids (all sessions for a user)
// sso:idp_sessions:{idp_entity_id} → SET of session_ids (all sessions for an IdP, for SLO)
type SSOSessionStore struct {
rdb redis.Cmdable
ttl time.Duration // default session TTL
}
// NewSSOSessionStore creates a Redis-backed SSO session store.
func NewSSOSessionStore(rdb redis.Cmdable, ttl time.Duration) *SSOSessionStore {
if ttl == 0 {
ttl = 24 * time.Hour // default: 24 hours
}
return &SSOSessionStore{rdb: rdb, ttl: ttl}
}
// SessionTTL returns the configured session TTL duration.
// Used by handlers to calculate SSO session expiry timestamps.
func (s *SSOSessionStore) SessionTTL() time.Duration {
return s.ttl
}
// generateSessionID creates a random, unique SSO session identifier.
func generateSSOSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate session ID: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Create creates a new SSO session in Redis.
// Stores the session data, adds session ID to user and IdP index sets.
// Returns the session ID for reference.
func (s *SSOSessionStore) Create(ctx context.Context, data *SSOSessionData) (string, error) {
if data.SessionID == "" {
sessionID, err := generateSSOSessionID()
if err != nil {
return "", err
}
data.SessionID = sessionID
}
// Set creation and expiry timestamps
now := time.Now()
if data.CreatedAt == 0 {
data.CreatedAt = now.Unix()
}
if data.ExpiresAt == 0 {
data.ExpiresAt = now.Add(s.ttl).Unix()
}
// Serialize session data
dataBytes, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("failed to marshal SSO session data: %w", err)
}
// Calculate TTL based on expires_at
expiresAt := time.Unix(data.ExpiresAt, 0)
ttl := time.Until(expiresAt)
if ttl <= 0 {
return "", fmt.Errorf("SSO session expiry is in the past")
}
sessionKey := fmt.Sprintf("sso:session:%s", data.SessionID)
userKey := fmt.Sprintf("sso:user_sessions:%d", data.UserID)
idpKey := fmt.Sprintf("sso:idp_sessions:%s", data.IdPEntityID)
// Use pipeline for atomic multi-key operations
pipe := s.rdb.Pipeline()
pipe.Set(ctx, sessionKey, dataBytes, ttl)
pipe.SAdd(ctx, userKey, data.SessionID)
pipe.SAdd(ctx, idpKey, data.SessionID)
// Set TTL on index sets (longer than session TTL to allow cleanup)
pipe.Expire(ctx, userKey, ttl+time.Hour)
pipe.Expire(ctx, idpKey, ttl+time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return "", fmt.Errorf("failed to store SSO session in Redis: %w", err)
}
applogger.L().Infof("SSO session created (id=%s, user=%d, provider=%s, idp=%s)", data.SessionID, data.UserID, data.Provider, data.IdPEntityID)
return data.SessionID, nil
}
// Get retrieves an SSO session by session ID.
func (s *SSOSessionStore) Get(ctx context.Context, sessionID string) (*SSOSessionData, error) {
key := fmt.Sprintf("sso:session:%s", sessionID)
dataBytes, err := s.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil // session not found or expired
}
if err != nil {
return nil, fmt.Errorf("failed to get SSO session: %w", err)
}
var data SSOSessionData
if err := json.Unmarshal(dataBytes, &data); err != nil {
return nil, fmt.Errorf("failed to unmarshal SSO session data: %w", err)
}
return &data, nil
}
// GetByUser retrieves all active SSO sessions for a user.
// Returns a list of SSOSessionData for all sessions indexed under the user.
func (s *SSOSessionStore) GetByUser(ctx context.Context, userID uint) ([]*SSOSessionData, error) {
userKey := fmt.Sprintf("sso:user_sessions:%d", userID)
sessionIDs, err := s.rdb.SMembers(ctx, userKey).Result()
if err != nil {
return nil, fmt.Errorf("failed to get user SSO sessions: %w", err)
}
var sessions []*SSOSessionData
for _, sid := range sessionIDs {
data, err := s.Get(ctx, sid)
if err != nil {
applogger.L().Warnf("Failed to get SSO session %s for user %d: %v", sid, userID, err)
continue
}
if data != nil {
sessions = append(sessions, data)
} else {
// Session expired but still in index set — clean up
s.rdb.SRem(ctx, userKey, sid)
}
}
return sessions, nil
}
// GetByIdP retrieves all active SSO sessions for an IdP entity ID.
// Used for IdP-initiated SLO: terminate all sessions associated with an IdP.
func (s *SSOSessionStore) GetByIdP(ctx context.Context, idpEntityID string) ([]*SSOSessionData, error) {
idpKey := fmt.Sprintf("sso:idp_sessions:%s", idpEntityID)
sessionIDs, err := s.rdb.SMembers(ctx, idpKey).Result()
if err != nil {
return nil, fmt.Errorf("failed to get IdP SSO sessions: %w", err)
}
var sessions []*SSOSessionData
for _, sid := range sessionIDs {
data, err := s.Get(ctx, sid)
if err != nil {
applogger.L().Warnf("Failed to get SSO session %s for IdP %s: %v", sid, idpEntityID, err)
continue
}
if data != nil {
sessions = append(sessions, data)
} else {
// Session expired but still in index set — clean up
s.rdb.SRem(ctx, idpKey, sid)
}
}
return sessions, nil
}
// Terminate removes an SSO session and cleans up index entries.
// Returns true if the session was found and terminated, false if not found.
func (s *SSOSessionStore) Terminate(ctx context.Context, sessionID string) (bool, error) {
// Get session data first to clean up index sets
data, err := s.Get(ctx, sessionID)
if err != nil {
return false, fmt.Errorf("failed to get SSO session for termination: %w", err)
}
if data == nil {
return false, nil // session not found
}
sessionKey := fmt.Sprintf("sso:session:%s", sessionID)
userKey := fmt.Sprintf("sso:user_sessions:%d", data.UserID)
idpKey := fmt.Sprintf("sso:idp_sessions:%s", data.IdPEntityID)
pipe := s.rdb.Pipeline()
pipe.Del(ctx, sessionKey)
pipe.SRem(ctx, userKey, sessionID)
pipe.SRem(ctx, idpKey, sessionID)
if _, err := pipe.Exec(ctx); err != nil {
return false, fmt.Errorf("failed to terminate SSO session in Redis: %w", err)
}
applogger.L().Infof("SSO session terminated (id=%s, user=%d, provider=%s)", sessionID, data.UserID, data.Provider)
return true, nil
}
// TerminateUserSessions terminates all SSO sessions for a user.
// Returns the number of sessions terminated.
// Used for SP-initiated SLO or explicit user logout.
func (s *SSOSessionStore) TerminateUserSessions(ctx context.Context, userID uint) (int, error) {
sessions, err := s.GetByUser(ctx, userID)
if err != nil {
return 0, fmt.Errorf("failed to get user sessions for termination: %w", err)
}
count := 0
for _, data := range sessions {
terminated, err := s.Terminate(ctx, data.SessionID)
if err != nil {
applogger.L().Warnf("Failed to terminate SSO session %s: %v", data.SessionID, err)
continue
}
if terminated {
count++
}
}
return count, nil
}
// TerminateIdPSessions terminates all SSO sessions for an IdP.
// Returns the number of sessions terminated.
// Used for IdP-initiated SLO: when the IdP sends a LogoutRequest,
// we terminate all sessions for that IdP.
func (s *SSOSessionStore) TerminateIdPSessions(ctx context.Context, idpEntityID string) (int, error) {
sessions, err := s.GetByIdP(ctx, idpEntityID)
if err != nil {
return 0, fmt.Errorf("failed to get IdP sessions for termination: %w", err)
}
count := 0
for _, data := range sessions {
terminated, err := s.Terminate(ctx, data.SessionID)
if err != nil {
applogger.L().Warnf("Failed to terminate SSO session %s: %v", data.SessionID, err)
continue
}
if terminated {
count++
}
}
return count, nil
}
// Refresh extends the TTL of an active SSO session.
// Updates the expires_at timestamp and Redis TTL.
func (s *SSOSessionStore) Refresh(ctx context.Context, sessionID string, newTTL time.Duration) error {
if newTTL == 0 {
newTTL = s.ttl
}
data, err := s.Get(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get SSO session for refresh: %w", err)
}
if data == nil {
return fmt.Errorf("SSO session not found")
}
// Update expires_at
data.ExpiresAt = time.Now().Add(newTTL).Unix()
dataBytes, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal refreshed SSO session data: %w", err)
}
sessionKey := fmt.Sprintf("sso:session:%s", sessionID)
if err := s.rdb.Set(ctx, sessionKey, dataBytes, newTTL).Err(); err != nil {
return fmt.Errorf("failed to refresh SSO session in Redis: %w", err)
}
return nil
}
// CountByUser returns the number of active SSO sessions for a user.
func (s *SSOSessionStore) CountByUser(ctx context.Context, userID uint) (int64, error) {
userKey := fmt.Sprintf("sso:user_sessions:%d", userID)
// Use SCARD for efficient count without fetching all members
count, err := s.rdb.SCard(ctx, userKey).Result()
if err != nil {
return 0, fmt.Errorf("failed to count user SSO sessions: %w", err)
}
return count, nil
}
// Exists checks whether an SSO session exists and is not expired.
func (s *SSOSessionStore) Exists(ctx context.Context, sessionID string) (bool, error) {
key := fmt.Sprintf("sso:session:%s", sessionID)
exists, err := s.rdb.Exists(ctx, key).Result()
if err != nil {
return false, fmt.Errorf("failed to check SSO session existence: %w", err)
}
return exists > 0, nil
}