Changeset 0.28.8 (#194)
This commit is contained in:
@@ -99,7 +99,12 @@ func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *Us
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
|
||||
return "", false
|
||||
}
|
||||
return verifyUserByID(c, userID, users, cache)
|
||||
}
|
||||
|
||||
// verifyUserByID checks is_active and resolves the current DB role 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) (string, bool) {
|
||||
// Cache hit
|
||||
if entry, ok := cache.get(userID); ok {
|
||||
if !entry.isActive {
|
||||
@@ -227,9 +232,20 @@ func CORS(cfg *config.Config) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -252,3 +268,114 @@ func originAllowed(origin, allowed string) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Ticket is valid — resolve role from DB (same as JWT path)
|
||||
role, ok := verifyUserByID(c, userID, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", userID)
|
||||
c.Set("role", role)
|
||||
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
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
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
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "bearer")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user