Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
464 lines
11 KiB
Go
464 lines
11 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// UserType distinguishes between internal employees and external customers
|
|
type UserType string
|
|
|
|
const (
|
|
UserTypeEmployee UserType = "employee"
|
|
UserTypeCustomer UserType = "customer"
|
|
)
|
|
|
|
// Session represents a user session with RBAC data
|
|
type Session struct {
|
|
SessionID string `json:"session_id"`
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
UserType UserType `json:"user_type"`
|
|
Roles []string `json:"roles"`
|
|
Permissions []string `json:"permissions"`
|
|
TenantID *string `json:"tenant_id,omitempty"`
|
|
IPAddress *string `json:"ip_address,omitempty"`
|
|
UserAgent *string `json:"user_agent,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastActivityAt time.Time `json:"last_activity_at"`
|
|
}
|
|
|
|
// HasPermission checks if session has a specific permission
|
|
func (s *Session) HasPermission(permission string) bool {
|
|
for _, p := range s.Permissions {
|
|
if p == permission {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasAnyPermission checks if session has any of the specified permissions
|
|
func (s *Session) HasAnyPermission(permissions []string) bool {
|
|
for _, needed := range permissions {
|
|
for _, has := range s.Permissions {
|
|
if needed == has {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasAllPermissions checks if session has all specified permissions
|
|
func (s *Session) HasAllPermissions(permissions []string) bool {
|
|
for _, needed := range permissions {
|
|
found := false
|
|
for _, has := range s.Permissions {
|
|
if needed == has {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// HasRole checks if session has a specific role
|
|
func (s *Session) HasRole(role string) bool {
|
|
for _, r := range s.Roles {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsEmployee checks if user is an employee (internal staff)
|
|
func (s *Session) IsEmployee() bool {
|
|
return s.UserType == UserTypeEmployee
|
|
}
|
|
|
|
// IsCustomer checks if user is a customer (external user)
|
|
func (s *Session) IsCustomer() bool {
|
|
return s.UserType == UserTypeCustomer
|
|
}
|
|
|
|
// SessionStore provides hybrid Valkey + PostgreSQL session storage
|
|
type SessionStore struct {
|
|
valkeyClient *redis.Client
|
|
pgPool *pgxpool.Pool
|
|
sessionTTL time.Duration
|
|
valkeyEnabled bool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewSessionStore creates a new session store
|
|
func NewSessionStore(pgPool *pgxpool.Pool) *SessionStore {
|
|
ttlHours := 24
|
|
if ttlStr := os.Getenv("SESSION_TTL_HOURS"); ttlStr != "" {
|
|
if val, err := strconv.Atoi(ttlStr); err == nil {
|
|
ttlHours = val
|
|
}
|
|
}
|
|
|
|
store := &SessionStore{
|
|
pgPool: pgPool,
|
|
sessionTTL: time.Duration(ttlHours) * time.Hour,
|
|
valkeyEnabled: false,
|
|
}
|
|
|
|
// Try to connect to Valkey
|
|
valkeyURL := os.Getenv("VALKEY_URL")
|
|
if valkeyURL == "" {
|
|
valkeyURL = "redis://localhost:6379"
|
|
}
|
|
|
|
opt, err := redis.ParseURL(valkeyURL)
|
|
if err == nil {
|
|
store.valkeyClient = redis.NewClient(opt)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := store.valkeyClient.Ping(ctx).Err(); err == nil {
|
|
store.valkeyEnabled = true
|
|
}
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
// Close closes all connections
|
|
func (s *SessionStore) Close() {
|
|
if s.valkeyClient != nil {
|
|
s.valkeyClient.Close()
|
|
}
|
|
}
|
|
|
|
// getValkeyKey returns the Valkey key for a session
|
|
func (s *SessionStore) getValkeyKey(sessionID string) string {
|
|
return fmt.Sprintf("session:%s", sessionID)
|
|
}
|
|
|
|
// CreateSession creates a new session
|
|
func (s *SessionStore) CreateSession(ctx context.Context, userID, email string, userType UserType, roles, permissions []string, tenantID, ipAddress, userAgent *string) (*Session, error) {
|
|
session := &Session{
|
|
SessionID: uuid.New().String(),
|
|
UserID: userID,
|
|
Email: email,
|
|
UserType: userType,
|
|
Roles: roles,
|
|
Permissions: permissions,
|
|
TenantID: tenantID,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
CreatedAt: time.Now().UTC(),
|
|
LastActivityAt: time.Now().UTC(),
|
|
}
|
|
|
|
// Store in Valkey (primary cache)
|
|
if s.valkeyEnabled {
|
|
data, err := json.Marshal(session)
|
|
if err == nil {
|
|
key := s.getValkeyKey(session.SessionID)
|
|
s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL)
|
|
}
|
|
}
|
|
|
|
// Store in PostgreSQL (persistent + audit)
|
|
if s.pgPool != nil {
|
|
rolesJSON, _ := json.Marshal(roles)
|
|
permsJSON, _ := json.Marshal(permissions)
|
|
|
|
expiresAt := time.Now().UTC().Add(s.sessionTTL)
|
|
|
|
_, err := s.pgPool.Exec(ctx, `
|
|
INSERT INTO user_sessions (
|
|
id, user_id, token_hash, email, user_type, roles,
|
|
permissions, tenant_id, ip_address, user_agent,
|
|
expires_at, created_at, last_activity_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
`,
|
|
session.SessionID,
|
|
session.UserID,
|
|
session.SessionID, // token_hash = session_id for session-based auth
|
|
session.Email,
|
|
string(session.UserType),
|
|
rolesJSON,
|
|
permsJSON,
|
|
tenantID,
|
|
ipAddress,
|
|
userAgent,
|
|
expiresAt,
|
|
session.CreatedAt,
|
|
session.LastActivityAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to store session in PostgreSQL: %w", err)
|
|
}
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// GetSession retrieves a session by ID
|
|
func (s *SessionStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
|
// Try Valkey first
|
|
if s.valkeyEnabled {
|
|
key := s.getValkeyKey(sessionID)
|
|
data, err := s.valkeyClient.Get(ctx, key).Bytes()
|
|
if err == nil {
|
|
var session Session
|
|
if err := json.Unmarshal(data, &session); err == nil {
|
|
// Update last activity
|
|
go s.updateLastActivity(sessionID)
|
|
return &session, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to PostgreSQL
|
|
if s.pgPool != nil {
|
|
var session Session
|
|
var rolesJSON, permsJSON []byte
|
|
var tenantID, ipAddress, userAgent *string
|
|
|
|
err := s.pgPool.QueryRow(ctx, `
|
|
SELECT id, user_id, email, user_type, roles, permissions,
|
|
tenant_id, ip_address, user_agent, created_at, last_activity_at
|
|
FROM user_sessions
|
|
WHERE id = $1
|
|
AND revoked_at IS NULL
|
|
AND expires_at > NOW()
|
|
`, sessionID).Scan(
|
|
&session.SessionID,
|
|
&session.UserID,
|
|
&session.Email,
|
|
&session.UserType,
|
|
&rolesJSON,
|
|
&permsJSON,
|
|
&tenantID,
|
|
&ipAddress,
|
|
&userAgent,
|
|
&session.CreatedAt,
|
|
&session.LastActivityAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, errors.New("session not found or expired")
|
|
}
|
|
|
|
json.Unmarshal(rolesJSON, &session.Roles)
|
|
json.Unmarshal(permsJSON, &session.Permissions)
|
|
session.TenantID = tenantID
|
|
session.IPAddress = ipAddress
|
|
session.UserAgent = userAgent
|
|
|
|
// Re-cache in Valkey
|
|
if s.valkeyEnabled {
|
|
data, _ := json.Marshal(session)
|
|
key := s.getValkeyKey(sessionID)
|
|
s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
return nil, errors.New("session not found")
|
|
}
|
|
|
|
// updateLastActivity updates the last activity timestamp
|
|
func (s *SessionStore) updateLastActivity(sessionID string) {
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
|
|
// Update Valkey TTL
|
|
if s.valkeyEnabled {
|
|
key := s.getValkeyKey(sessionID)
|
|
s.valkeyClient.Expire(ctx, key, s.sessionTTL)
|
|
}
|
|
|
|
// Update PostgreSQL
|
|
if s.pgPool != nil {
|
|
s.pgPool.Exec(ctx, `
|
|
UPDATE user_sessions
|
|
SET last_activity_at = $1, expires_at = $2
|
|
WHERE id = $3
|
|
`, now, now.Add(s.sessionTTL), sessionID)
|
|
}
|
|
}
|
|
|
|
// RevokeSession revokes a session (logout)
|
|
func (s *SessionStore) RevokeSession(ctx context.Context, sessionID string) error {
|
|
// Remove from Valkey
|
|
if s.valkeyEnabled {
|
|
key := s.getValkeyKey(sessionID)
|
|
s.valkeyClient.Del(ctx, key)
|
|
}
|
|
|
|
// Mark as revoked in PostgreSQL
|
|
if s.pgPool != nil {
|
|
_, err := s.pgPool.Exec(ctx, `
|
|
UPDATE user_sessions
|
|
SET revoked_at = NOW()
|
|
WHERE id = $1
|
|
`, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to revoke session: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RevokeAllUserSessions revokes all sessions for a user
|
|
func (s *SessionStore) RevokeAllUserSessions(ctx context.Context, userID string) (int, error) {
|
|
if s.pgPool == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
// Get all session IDs
|
|
rows, err := s.pgPool.Query(ctx, `
|
|
SELECT id FROM user_sessions
|
|
WHERE user_id = $1
|
|
AND revoked_at IS NULL
|
|
AND expires_at > NOW()
|
|
`, userID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sessionIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil {
|
|
sessionIDs = append(sessionIDs, id)
|
|
}
|
|
}
|
|
|
|
// Revoke in PostgreSQL
|
|
result, err := s.pgPool.Exec(ctx, `
|
|
UPDATE user_sessions
|
|
SET revoked_at = NOW()
|
|
WHERE user_id = $1 AND revoked_at IS NULL
|
|
`, userID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Remove from Valkey
|
|
if s.valkeyEnabled {
|
|
for _, sessionID := range sessionIDs {
|
|
key := s.getValkeyKey(sessionID)
|
|
s.valkeyClient.Del(ctx, key)
|
|
}
|
|
}
|
|
|
|
return int(result.RowsAffected()), nil
|
|
}
|
|
|
|
// GetActiveSessions returns all active sessions for a user
|
|
func (s *SessionStore) GetActiveSessions(ctx context.Context, userID string) ([]*Session, error) {
|
|
if s.pgPool == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
rows, err := s.pgPool.Query(ctx, `
|
|
SELECT id, user_id, email, user_type, roles, permissions,
|
|
tenant_id, ip_address, user_agent, created_at, last_activity_at
|
|
FROM user_sessions
|
|
WHERE user_id = $1
|
|
AND revoked_at IS NULL
|
|
AND expires_at > NOW()
|
|
ORDER BY last_activity_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sessions []*Session
|
|
for rows.Next() {
|
|
var session Session
|
|
var rolesJSON, permsJSON []byte
|
|
var tenantID, ipAddress, userAgent *string
|
|
|
|
err := rows.Scan(
|
|
&session.SessionID,
|
|
&session.UserID,
|
|
&session.Email,
|
|
&session.UserType,
|
|
&rolesJSON,
|
|
&permsJSON,
|
|
&tenantID,
|
|
&ipAddress,
|
|
&userAgent,
|
|
&session.CreatedAt,
|
|
&session.LastActivityAt,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
json.Unmarshal(rolesJSON, &session.Roles)
|
|
json.Unmarshal(permsJSON, &session.Permissions)
|
|
session.TenantID = tenantID
|
|
session.IPAddress = ipAddress
|
|
session.UserAgent = userAgent
|
|
|
|
sessions = append(sessions, &session)
|
|
}
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// CleanupExpiredSessions removes old expired sessions from PostgreSQL
|
|
func (s *SessionStore) CleanupExpiredSessions(ctx context.Context) (int, error) {
|
|
if s.pgPool == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
result, err := s.pgPool.Exec(ctx, `
|
|
DELETE FROM user_sessions
|
|
WHERE expires_at < NOW() - INTERVAL '7 days'
|
|
`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return int(result.RowsAffected()), nil
|
|
}
|
|
|
|
// Global session store instance
|
|
var (
|
|
globalStore *SessionStore
|
|
globalStoreMu sync.Mutex
|
|
globalStoreOnce sync.Once
|
|
)
|
|
|
|
// GetSessionStore returns the global session store instance
|
|
func GetSessionStore(pgPool *pgxpool.Pool) *SessionStore {
|
|
globalStoreMu.Lock()
|
|
defer globalStoreMu.Unlock()
|
|
|
|
if globalStore == nil {
|
|
globalStore = NewSessionStore(pgPool)
|
|
}
|
|
|
|
return globalStore
|
|
}
|