This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/middleware/auth.go
Jeffrey Smith b10f5bee05
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m22s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 1m18s
drop users.role column: full RBAC through group membership
The role column was a pre-RBAC artifact. All authorization now flows
through explicit group membership and permission grants:

- Everyone group: all users added on creation (no implicit membership)
- Admins group: grants surface.admin.access + all platform permissions
- JWT claims, login response, profile: role field removed
- OIDC: isIdPAdmin() maps IdP claims → Admins group (no role writes)
- Admin UI: role dropdown removed, admin managed through groups
- Middleware cache simplified to isActive only

28 files changed, -79 lines net. Zero magic roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:23:12 +00:00

370 lines
10 KiB
Go

package middleware
import (
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/store"
)
// Claims represents the JWT payload. Must match handlers.Claims.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
// ─── User Status Cache ──────────────────────────────────────
// Short-TTL cache so deactivation takes effect within seconds,
// without a DB query on every single request.
const userCacheTTL = 30 * time.Second
type userCacheEntry struct {
isActive bool
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) {
c.mu.Lock()
c.entries[userID] = userCacheEntry{isActive: active, 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 that the user exists and is active.
// Returns true on success or false (with error already sent) on failure.
func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *UserStatusCache) bool {
userID := claims.UserID
if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
return false
}
return verifyUserByID(c, userID, users, cache)
}
// verifyUserByID checks is_active for a user ID.
// Used by both JWT auth (via verifyUser) and ticket auth.
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) bool {
// Cache hit
if entry, ok := cache.get(userID); ok {
if !entry.isActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return false
}
return 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)
if !user.IsActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "account deactivated"})
return false
}
return 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
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
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()
}
}
// GetAllowedOrigins returns the resolved CORS origin string for use by
// other subsystems (e.g. WebSocket upgrader CheckOrigin). Call after CORS
// middleware is initialized.
func GetAllowedOrigins(cfg *config.Config) string {
return getAllowedOrigin(cfg)
}
func getAllowedOrigin(cfg *config.Config) string {
// Explicit env var overrides everything
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
if v == "*" {
log.Println("[CORS] WARNING: CORS_ALLOWED_ORIGINS is explicitly set to '*' — all origins allowed. " +
"Set to a comma-separated list of allowed origins for production deployments.")
}
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
}
// ── WebSocket Auth (ticket exchange) ─────────────────────────
// TicketValidator validates a single-use WebSocket ticket.
// Implemented by events.TicketStore. Interface avoids circular import.
type TicketValidator interface {
Validate(ticketID string) (userID string, ok bool)
}
// WsAuth returns a Gin middleware for the WebSocket endpoint.
// It authenticates via three methods in priority order:
//
// 1. ?ticket=<opaque> — single-use ticket (preferred, v0.28.8+)
// 2. ?token=<jwt> — legacy JWT in query param (deprecated)
// 3. Authorization header — standard Bearer JWT
//
// When ?token= is used, a deprecation notice is logged. The ticket
// path avoids exposing the JWT in server logs, proxy logs, and
// browser history.
func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator) gin.HandlerFunc {
return func(c *gin.Context) {
if !database.IsConnected() {
c.Next()
return
}
// Path 1: Ticket exchange (preferred)
if ticketID := c.Query("ticket"); ticketID != "" {
userID, ok := tickets.Validate(ticketID)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired ticket",
})
return
}
if !verifyUserByID(c, userID, users, cache) {
return
}
c.Set("user_id", userID)
c.Set("ws_auth", "ticket")
c.Next()
return
}
// Path 2: Legacy ?token= (deprecated — log warning)
if qToken := c.Query("token"); qToken != "" {
log.Printf("[ws] DEPRECATED: client using ?token= query param for WebSocket auth — migrate to ticket exchange")
claims, ok := parseAndValidateJWT(qToken, cfg.JWTSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("ws_auth", "token_legacy")
c.Next()
return
}
// Path 3: Authorization header (non-browser clients)
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authentication — use ticket exchange or 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
}
if !verifyUser(c, claims, users, cache) {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("ws_auth", "bearer")
c.Next()
}
}