Changeset 0.28.8 (#194)
This commit is contained in:
118
server/events/tickets.go
Normal file
118
server/events/tickets.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user