Changeset 0.28.4 (#190)

This commit is contained in:
2026-03-14 19:36:33 +00:00
parent fa6b04434a
commit 85d5e3cc13
54 changed files with 7355 additions and 102 deletions

View File

@@ -1,14 +1,19 @@
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.
@@ -19,10 +24,127 @@ type Claims struct {
jwt.RegisteredClaims
}
// Auth returns a Gin middleware that validates JWT bearer tokens.
// When no database is connected (unmanaged mode), all requests
// are allowed through without authentication.
func Auth(cfg *config.Config) gin.HandlerFunc {
// ─── 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() {
@@ -53,36 +175,50 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
return
}
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(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
// Store claims in context for downstream handlers
// 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", claims.Role)
c.Set("role", role) // from DB, not JWT claims
c.Next()
}
}
// ─── CORS middleware ─────────────────────────────────────────
// CORS returns a middleware that sets cross-origin headers.
// NOTE: If you have a separate cors.go, delete it — CORS lives here only.
func CORS() gin.HandlerFunc {
// 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) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
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
@@ -90,3 +226,29 @@ func CORS() gin.HandlerFunc {
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
}

View File

@@ -6,10 +6,10 @@ import (
"strings"
"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"
)
// AuthOrRedirect validates JWT tokens for page routes.
@@ -18,7 +18,7 @@ import (
//
// Token is read from the "sb_token" cookie (set by the login page JS)
// since page requests don't have Authorization headers.
func AuthOrRedirect(cfg *config.Config) gin.HandlerFunc {
func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc {
loginPath := cfg.BasePath + "/login"
return func(c *gin.Context) {
@@ -49,23 +49,23 @@ func AuthOrRedirect(cfg *config.Config) gin.HandlerFunc {
return
}
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(cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
redirectToLogin(c, loginPath)
return
}
// Store claims in context (same as Auth middleware)
// Verify user is active and resolve current role from DB
role, valid := verifyUser(c, claims, users, cache)
if !valid {
// verifyUser sent a JSON error, but for page routes we want a redirect.
// The abort already happened, so just return.
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("role", role)
c.Next()
}
}

View File

@@ -5,7 +5,6 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config"
@@ -19,7 +18,7 @@ import (
// session_id, channel_id, and auth_type="session".
//
// Session auth is only valid for workflow channels with allow_anonymous=true.
func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc {
func AuthOrSession(cfg *config.Config, stores store.Stores, cache *UserStatusCache) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth when running without a database
if !database.IsConnected() {
@@ -30,17 +29,14 @@ func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc {
// ── Try normal JWT auth first ──
tokenString := extractBearerToken(c)
if tokenString != "" {
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
if claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret); ok {
role, valid := verifyUser(c, claims, stores.Users, cache)
if !valid {
return
}
return []byte(cfg.JWTSecret), nil
})
if err == nil && token.Valid {
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("role", role)
c.Set("auth_type", "user")
c.Next()
return
@@ -49,17 +45,14 @@ func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc {
// ── Try sb_token cookie (page auth pattern) ──
if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" {
claims := &Claims{}
token, err := jwt.ParseWithClaims(cookie, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
if claims, ok := parseAndValidateJWT(cookie, cfg.JWTSecret); ok {
role, valid := verifyUser(c, claims, stores.Users, cache)
if !valid {
return
}
return []byte(cfg.JWTSecret), nil
})
if err == nil && token.Valid {
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("role", role)
c.Set("auth_type", "user")
c.Next()
return

View File

@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const maxParamLen = 255
// ValidatePathParams rejects requests with null bytes or excessively long
// path parameters. This prevents 500s from invalid UUIDs reaching the
// database layer.
func ValidatePathParams() gin.HandlerFunc {
return func(c *gin.Context) {
for _, p := range c.Params {
if len(p.Value) > maxParamLen {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "path parameter too long",
})
return
}
if strings.ContainsRune(p.Value, 0) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "invalid character in path parameter",
})
return
}
}
c.Next()
}
}