Changeset 0.28.8 (#194)

This commit is contained in:
2026-03-15 23:43:36 +00:00
parent 3237d55e0c
commit 128cbb8174
25 changed files with 1157 additions and 863 deletions

118
server/events/tickets.go Normal file
View File

@@ -0,0 +1,118 @@
package events
import (
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
)
const (
// ticketTTL is how long a ticket remains valid after issuance.
ticketTTL = 30 * time.Second
// ticketReapInterval is how often the background reaper runs.
ticketReapInterval = 15 * time.Second
)
// ticket is a single-use opaque token that maps to a user ID.
type ticket struct {
userID string
expiresAt time.Time
}
// TicketStore manages short-lived, single-use WebSocket authentication tickets.
//
// Flow:
// 1. Client POSTs to /api/v1/ws/ticket (authenticated via JWT).
// 2. Server returns {"ticket": "<opaque>"}.
// 3. Client connects WebSocket with ?ticket=<opaque>.
// 4. Server validates and deletes the ticket atomically (single-use).
//
// This replaces passing the JWT as ?token= in the WebSocket URL, which
// exposed the token in server logs, proxy logs, and browser history.
type TicketStore struct {
mu sync.Mutex
tickets map[string]ticket
stopCh chan struct{}
}
// NewTicketStore creates a store and starts the background reaper.
func NewTicketStore() *TicketStore {
ts := &TicketStore{
tickets: make(map[string]ticket),
stopCh: make(chan struct{}),
}
go ts.reaper()
return ts
}
// Issue creates a new single-use ticket for the given user.
// Returns the opaque ticket string.
func (ts *TicketStore) Issue(userID string) (string, error) {
b := make([]byte, 16) // 128-bit random
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
ts.mu.Lock()
ts.tickets[id] = ticket{
userID: userID,
expiresAt: time.Now().Add(ticketTTL),
}
ts.mu.Unlock()
return id, nil
}
// Validate checks a ticket, deletes it (single-use), and returns the user ID.
// Returns ("", false) if the ticket is invalid, expired, or already consumed.
func (ts *TicketStore) Validate(ticketID string) (string, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
t, ok := ts.tickets[ticketID]
if !ok {
return "", false
}
delete(ts.tickets, ticketID) // single-use: delete immediately
if time.Now().After(t.expiresAt) {
return "", false
}
return t.userID, true
}
// Stop shuts down the background reaper.
func (ts *TicketStore) Stop() {
close(ts.stopCh)
}
// reaper periodically removes expired tickets that were never validated.
func (ts *TicketStore) reaper() {
ticker := time.NewTicker(ticketReapInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ts.mu.Lock()
now := time.Now()
reaped := 0
for id, t := range ts.tickets {
if now.After(t.expiresAt) {
delete(ts.tickets, id)
reaped++
}
}
ts.mu.Unlock()
if reaped > 0 {
log.Printf("[ws-tickets] reaped %d expired tickets", reaped)
}
case <-ts.stopCh:
return
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
@@ -11,12 +12,6 @@ import (
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
@@ -26,9 +21,10 @@ const (
// Hub manages WebSocket connections and bridges them to the Bus.
type Hub struct {
bus *Bus
mu sync.RWMutex
conns map[string]*Conn // connID → Conn
bus *Bus
mu sync.RWMutex
conns map[string]*Conn // connID → Conn
upgrader websocket.Upgrader
}
// Conn represents a single WebSocket connection.
@@ -43,11 +39,55 @@ type Conn struct {
}
// NewHub creates a Hub bound to an event bus.
func NewHub(bus *Bus) *Hub {
return &Hub{
// allowedOrigins controls the WebSocket upgrader's CheckOrigin behavior.
// Empty or "*" allows all origins (dev/test). Comma-separated list
// restricts to those origins (production).
func NewHub(bus *Bus, allowedOrigins string) *Hub {
h := &Hub{
bus: bus,
conns: make(map[string]*Conn),
}
h.upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: h.makeCheckOrigin(allowedOrigins),
}
return h
}
// makeCheckOrigin returns a CheckOrigin function that validates the
// request Origin header against the configured allowed origins.
func (h *Hub) makeCheckOrigin(allowedOrigins string) func(r *http.Request) bool {
origins := strings.TrimSpace(allowedOrigins)
// No restriction: dev/test or explicit "*"
if origins == "" || origins == "*" {
return func(r *http.Request) bool { return true }
}
// Parse comma-separated origin list
var allowed []string
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed = append(allowed, o)
}
}
return func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// No Origin header — same-origin request (non-browser client).
return true
}
for _, a := range allowed {
if a == origin {
return true
}
}
log.Printf("[ws] rejected WebSocket from origin %q (allowed: %s)", origin, origins)
return false
}
}
// HandleWebSocket is a Gin handler for WebSocket upgrade.
@@ -60,7 +100,7 @@ func (h *Hub) HandleWebSocket(c *gin.Context) {
return
}
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
ws, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[ws] upgrade failed: %v", err)
return