first commit
This commit is contained in:
102
transports/bifrost-http/handlers/ws_ticket.go
Normal file
102
transports/bifrost-http/handlers/ws_ticket.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
wsTicketTTL = 30 * time.Second
|
||||
wsTicketCleanupHz = 60 * time.Second
|
||||
)
|
||||
|
||||
type wsTicketEntry struct {
|
||||
sessionToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// WSTicketStore provides short-lived, single-use tickets for WebSocket authentication.
|
||||
// Instead of putting the long-lived session token in the WS URL (visible in logs/history),
|
||||
// clients exchange their session for a 30-second one-time ticket via an authenticated endpoint.
|
||||
type WSTicketStore struct {
|
||||
mu sync.Mutex
|
||||
tickets map[string]wsTicketEntry
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewWSTicketStore creates a new ticket store and starts a background goroutine
|
||||
// that periodically purges expired tickets.
|
||||
func NewWSTicketStore() *WSTicketStore {
|
||||
s := &WSTicketStore{
|
||||
tickets: make(map[string]wsTicketEntry),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// Issue generates a cryptographically random ticket bound to the given session token.
|
||||
// The ticket expires after wsTicketTTL (30 seconds).
|
||||
func (s *WSTicketStore) Issue(sessionToken string) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(b)
|
||||
|
||||
s.mu.Lock()
|
||||
s.tickets[ticket] = wsTicketEntry{
|
||||
sessionToken: sessionToken,
|
||||
expiresAt: time.Now().Add(wsTicketTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// Consume validates and deletes a ticket, returning the underlying session token.
|
||||
// Returns empty string if the ticket doesn't exist or has expired (single-use).
|
||||
func (s *WSTicketStore) Consume(ticket string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.tickets[ticket]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
delete(s.tickets, ticket)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
return entry.sessionToken
|
||||
}
|
||||
|
||||
// Stop terminates the background cleanup goroutine.
|
||||
func (s *WSTicketStore) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.done)
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup periodically removes expired tickets to prevent unbounded memory growth.
|
||||
func (s *WSTicketStore) cleanup() {
|
||||
ticker := time.NewTicker(wsTicketCleanupHz)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for k, v := range s.tickets {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(s.tickets, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user