package middleware import ( "log" "net/http" "os" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" ) // Claims represents the JWT payload. Must match handlers.Claims. type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims } // ─── User Status Cache ────────────────────────────────────── // Short-TTL cache so deactivation and role changes take effect // within seconds, without a DB query on every single request. const userCacheTTL = 30 * time.Second type userCacheEntry struct { isActive bool role string fetchedAt time.Time } // UserStatusCache is a concurrency-safe TTL cache for user active/role state. type UserStatusCache struct { mu sync.RWMutex entries map[string]userCacheEntry } // NewUserStatusCache creates a cache and starts a background cleanup goroutine. func NewUserStatusCache() *UserStatusCache { c := &UserStatusCache{entries: make(map[string]userCacheEntry)} go func() { for { time.Sleep(2 * time.Minute) c.evictExpired() } }() return c } func (c *UserStatusCache) get(userID string) (userCacheEntry, bool) { c.mu.RLock() defer c.mu.RUnlock() e, ok := c.entries[userID] if !ok || time.Since(e.fetchedAt) > userCacheTTL { return userCacheEntry{}, false } return e, true } func (c *UserStatusCache) set(userID string, active bool, role string) { c.mu.Lock() c.entries[userID] = userCacheEntry{isActive: active, role: role, fetchedAt: time.Now()} c.mu.Unlock() } // Evict removes a specific user from the cache (call on role/active change). func (c *UserStatusCache) Evict(userID string) { c.mu.Lock() delete(c.entries, userID) c.mu.Unlock() } func (c *UserStatusCache) evictExpired() { c.mu.Lock() defer c.mu.Unlock() cutoff := time.Now().Add(-2 * userCacheTTL) for k, v := range c.entries { if v.fetchedAt.Before(cutoff) { delete(c.entries, k) } } } // ─── Shared user verification ─────────────────────────────── // verifyUser checks is_active and resolves the current DB role. // Returns (role, nil) on success or ("", error-already-sent) on failure. // Callers must abort the request on error. func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) (string, bool) { userID := claims.UserID if userID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"}) return "", false } // Cache hit if entry, ok := cache.get(userID); ok { if !entry.isActive { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) return "", false } return entry.role, true } // Cache miss → DB lookup user, err := users.GetByID(c.Request.Context(), userID) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) return "", false } cache.set(userID, user.IsActive, user.Role) if !user.IsActive { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"}) return "", false } return user.Role, true } // parseAndValidateJWT parses a raw JWT string and returns claims if valid. func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(jwtSecret), nil }) if err != nil || !token.Valid { return nil, false } return claims, true } // ─── Auth middleware ───────────────────────────────────────── // Auth returns a Gin middleware that validates JWT bearer tokens and // verifies the user is active with their current DB role. func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc { return func(c *gin.Context) { // Skip auth when running without a database (unmanaged mode) if !database.IsConnected() { c.Next() return } header := c.GetHeader("Authorization") if header == "" { // WebSocket connections can't set headers from browser. // Fall back to ?token= query parameter. if qToken := c.Query("token"); qToken != "" { header = "Bearer " + qToken } } if header == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "missing authorization header", }) return } tokenString := strings.TrimPrefix(header, "Bearer ") if tokenString == header { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "invalid authorization format", }) return } claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "invalid or expired token", }) return } // Verify user is active and resolve current role from DB role, ok := verifyUser(c, claims, users, cache) if !ok { return // verifyUser already sent the error response } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) c.Set("role", role) // from DB, not JWT claims c.Next() } } // ─── CORS middleware ───────────────────────────────────────── // CORS returns a middleware that sets cross-origin headers. // In production, restricts to the configured allowed origin(s). // In development/test, allows all origins. func CORS(cfg *config.Config) gin.HandlerFunc { allowedOrigin := getAllowedOrigin(cfg) return func(c *gin.Context) { origin := c.GetHeader("Origin") if allowedOrigin == "*" { c.Header("Access-Control-Allow-Origin", "*") } else if origin != "" && originAllowed(origin, allowedOrigin) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } func getAllowedOrigin(cfg *config.Config) string { // Explicit env var overrides everything if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" { return v } // Production: restrict. Dev/test: allow all. if cfg.Environment == "production" { log.Println("[CORS] production mode with no CORS_ALLOWED_ORIGINS — defaulting to same-origin only") return "" // empty = no Access-Control-Allow-Origin header → same-origin only } return "*" } func originAllowed(origin, allowed string) bool { if allowed == "*" { return true } // allowed can be comma-separated for _, a := range strings.Split(allowed, ",") { if strings.TrimSpace(a) == origin { return true } } return false }