119 lines
2.7 KiB
Go
119 lines
2.7 KiB
Go
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
|
|
}
|
|
}
|
|
}
|