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": ""}. // 3. Client connects WebSocket with ?ticket=. // 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 } } }