255 lines
7.2 KiB
Go
255 lines
7.2 KiB
Go
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
|
|
}
|