Changeset 0.28.4 (#190)
This commit is contained in:
@@ -22,16 +22,23 @@ import (
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
uekCache *crypto.UEKCache
|
||||
objStore storage.ObjectStore
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
uekCache *crypto.UEKCache
|
||||
objStore storage.ObjectStore
|
||||
onUserChanged func(userID string) // auth cache eviction callback
|
||||
}
|
||||
|
||||
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache, objStore storage.ObjectStore) *AdminHandler {
|
||||
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache, objStore: objStore}
|
||||
}
|
||||
|
||||
// OnUserChanged registers a callback invoked when a user's role or
|
||||
// active status changes. Used to evict the auth middleware cache.
|
||||
func (h *AdminHandler) OnUserChanged(fn func(userID string)) {
|
||||
h.onUserChanged = fn
|
||||
}
|
||||
|
||||
// ── User Management ─────────────────────────
|
||||
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
@@ -135,6 +142,9 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
||||
}
|
||||
|
||||
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
|
||||
if h.onUserChanged != nil {
|
||||
h.onUserChanged(id)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
|
||||
}
|
||||
|
||||
@@ -154,6 +164,9 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
|
||||
}
|
||||
|
||||
h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
|
||||
if h.onUserChanged != nil {
|
||||
h.onUserChanged(id)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -62,7 +63,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
|
||||
|
||||
// Protected routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// User extension endpoints
|
||||
protected.GET("/extensions", extH.ListUserExtensions)
|
||||
@@ -72,7 +73,7 @@ func setupExtensionHarness(t *testing.T) *extensionHarness {
|
||||
|
||||
// Admin extension endpoints
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
|
||||
admin.GET("/extensions", extH.AdminListExtensions)
|
||||
admin.POST("/extensions", extH.AdminInstallExtension)
|
||||
admin.PUT("/extensions/:id", extH.AdminUpdateExtension)
|
||||
|
||||
@@ -128,6 +128,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
|
||||
roleResolver := roles.NewResolver(stores, nil)
|
||||
@@ -147,7 +148,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
|
||||
// Protected routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// Models
|
||||
models := NewModelHandler(stores)
|
||||
@@ -358,7 +359,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
|
||||
admin.GET("/users", adm.ListUsers)
|
||||
admin.POST("/users", adm.CreateUser)
|
||||
admin.PUT("/users/:id/active", adm.ToggleUserActive)
|
||||
|
||||
@@ -44,11 +44,12 @@ func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
notifH := NewNotificationHandler(stores, nil) // no hub in tests
|
||||
protected.GET("/notifications", notifH.List)
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Profile Test Harness ──────────────────
|
||||
@@ -31,10 +34,18 @@ func setupProfileHarness(t *testing.T) *profileHarness {
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
|
||||
@@ -44,11 +44,12 @@ func setupProjectHarness(t *testing.T) *projectHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
projH := NewProjectHandler(stores)
|
||||
protected.GET("/projects", projH.List)
|
||||
@@ -69,7 +70,7 @@ func setupProjectHarness(t *testing.T) *projectHarness {
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg))
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.GET("/projects", projH.AdminList)
|
||||
admin.DELETE("/projects/:id", projH.Delete)
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestRouteRegistration(t *testing.T) {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
// This must not panic. If it does, there's a wildcard conflict.
|
||||
r := gin.New()
|
||||
@@ -55,7 +56,7 @@ func TestRouteRegistration(t *testing.T) {
|
||||
|
||||
// Protected API routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// Channels (the route group that conflicts with workflow)
|
||||
channels := NewChannelHandler()
|
||||
@@ -99,7 +100,7 @@ func TestRouteRegistration(t *testing.T) {
|
||||
|
||||
// Session API (the /w/:id group)
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores))
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
|
||||
wfAPI.POST("/:id/messages", msgs.CreateMessage)
|
||||
wfAPI.GET("/:id/messages", msgs.ListMessages)
|
||||
|
||||
@@ -111,9 +112,9 @@ func TestRouteRegistration(t *testing.T) {
|
||||
|
||||
pageEngine := pages.New(cfg, stores)
|
||||
pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
|
||||
Authenticated: middleware.AuthOrRedirect(cfg),
|
||||
Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg), middleware.RequireAdminPage()},
|
||||
Session: middleware.AuthOrSession(cfg, stores),
|
||||
Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
|
||||
Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage()},
|
||||
Session: middleware.AuthOrSession(cfg, stores, userCache),
|
||||
})
|
||||
|
||||
// ── Verify routes actually resolve ────
|
||||
|
||||
@@ -50,6 +50,7 @@ func setupSurfaceHarness(t *testing.T) *surfaceHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "surface-test-*")
|
||||
if err != nil {
|
||||
@@ -63,13 +64,13 @@ func setupSurfaceHarness(t *testing.T) *surfaceHarness {
|
||||
// User endpoint (authenticated)
|
||||
surfaceH := NewSurfaceHandler(stores)
|
||||
userGroup := api.Group("")
|
||||
userGroup.Use(middleware.Auth(cfg))
|
||||
userGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces)
|
||||
|
||||
// Admin endpoints
|
||||
surfaceAdm := NewSurfaceHandler(stores, tmpDir)
|
||||
adminGroup := api.Group("/admin")
|
||||
adminGroup.Use(middleware.Auth(cfg))
|
||||
adminGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
adminGroup.Use(middleware.RequireAdmin())
|
||||
adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces)
|
||||
adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface)
|
||||
|
||||
@@ -44,6 +44,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -53,7 +54,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
api.POST("/auth/register", auth.Register)
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// Workflow CRUD
|
||||
wfH := NewWorkflowHandler(stores)
|
||||
@@ -86,7 +87,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
// Teams (admin routes for creating teams + adding members)
|
||||
teamH := NewTeamHandler(stores, nil)
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
|
||||
admin.POST("/teams", teamH.CreateTeam)
|
||||
admin.POST("/teams/:id/members", teamH.AddMember)
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -293,7 +294,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
|
||||
api.POST("/auth/register", auth.Register)
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// Workflow CRUD
|
||||
wfH := NewWorkflowHandler(stores)
|
||||
|
||||
@@ -42,11 +42,12 @@ func setupWorkspaceHarness(t *testing.T) *workspaceHarness {
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
// Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem
|
||||
wsH := NewWorkspaceHandler(stores, nil)
|
||||
|
||||
@@ -368,7 +368,8 @@ func main() {
|
||||
defer memScanner.Stop()
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(middleware.CORS())
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
r.Use(middleware.CORS(cfg))
|
||||
|
||||
// ── Base path group ──────────────────────
|
||||
base := r.Group(cfg.BasePath)
|
||||
@@ -425,7 +426,7 @@ func main() {
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
|
||||
base.GET("/ws", middleware.Auth(cfg, stores.Users, userCache), hub.HandleWebSocket)
|
||||
|
||||
// ── Auth routes (rate limited) ──────────────
|
||||
authMode, err := auth.ParseMode(cfg.AuthMode)
|
||||
@@ -508,7 +509,8 @@ func main() {
|
||||
|
||||
// ── Protected routes ────────────────────
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
protected.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
// Channels
|
||||
channels := handlers.NewChannelHandler()
|
||||
@@ -948,10 +950,12 @@ func main() {
|
||||
|
||||
// ── Admin routes ────────────────────────
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg))
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
admin.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
|
||||
adm.OnUserChanged(userCache.Evict)
|
||||
|
||||
// User management
|
||||
admin.GET("/users", adm.ListUsers)
|
||||
@@ -1165,17 +1169,17 @@ func main() {
|
||||
// v0.25.0: Surface routes generated from manifest registry.
|
||||
// Replaces manual per-surface route blocks.
|
||||
pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
|
||||
Authenticated: middleware.AuthOrRedirect(cfg),
|
||||
Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
|
||||
Admin: []gin.HandlerFunc{
|
||||
middleware.AuthOrRedirect(cfg),
|
||||
middleware.AuthOrRedirect(cfg, stores.Users, userCache),
|
||||
middleware.RequireAdminPage(),
|
||||
},
|
||||
Session: middleware.AuthOrSession(cfg, stores),
|
||||
Session: middleware.AuthOrSession(cfg, stores, userCache),
|
||||
})
|
||||
|
||||
// Workflow API — session participants can send messages + trigger completions
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores))
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
|
||||
{
|
||||
wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
33
server/middleware/validate.go
Normal file
33
server/middleware/validate.go
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user