mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 03:52:14 +00:00
feat: add ticket and session policy
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SteamTicket struct {
|
||||
TicketID string
|
||||
SteamID string
|
||||
AppID uint64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type VerifiedIdentity struct {
|
||||
PlayerID string
|
||||
SteamID string
|
||||
}
|
||||
|
||||
type TicketVerifier struct {
|
||||
mu sync.Mutex
|
||||
expectedApp uint64
|
||||
consumed map[string]time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTicketRejected = fmt.Errorf("steam ticket rejected")
|
||||
ErrSessionRejected = fmt.Errorf("session rejected")
|
||||
)
|
||||
|
||||
func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) {
|
||||
if expectedApp == 0 {
|
||||
return nil, ErrTicketRejected
|
||||
}
|
||||
return &TicketVerifier{expectedApp: expectedApp, consumed: make(map[string]time.Time)}, nil
|
||||
}
|
||||
|
||||
// Verify consumes a backend-validated ticket exactly once. In production the
|
||||
// adapter must obtain the Steam Web API response before calling this policy;
|
||||
// callers never get to choose the verified SteamID independently.
|
||||
func (v *TicketVerifier) Verify(ticket SteamTicket, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if ticket.TicketID == "" || ticket.SteamID == "" || resolve == nil || ticket.AppID != v.expectedApp || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) {
|
||||
return VerifiedIdentity{}, ErrTicketRejected
|
||||
}
|
||||
if _, used := v.consumed[ticket.TicketID]; used {
|
||||
return VerifiedIdentity{}, ErrTicketRejected
|
||||
}
|
||||
playerID, ok := resolve(ticket.SteamID)
|
||||
if !ok || playerID == "" {
|
||||
return VerifiedIdentity{}, ErrTicketRejected
|
||||
}
|
||||
v.consumed[ticket.TicketID] = now
|
||||
return VerifiedIdentity{PlayerID: playerID, SteamID: ticket.SteamID}, nil
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
SessionID string
|
||||
PlayerID string
|
||||
ExpiresAt time.Time
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]Session
|
||||
digests map[string]string
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{sessions: make(map[string]Session), digests: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Issue(playerID string, lifetime time.Duration, now time.Time) (Session, string, error) {
|
||||
if playerID == "" || lifetime <= 0 {
|
||||
return Session{}, "", ErrSessionRejected
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return Session{}, "", err
|
||||
}
|
||||
sessionID, err := randomToken()
|
||||
if err != nil {
|
||||
return Session{}, "", err
|
||||
}
|
||||
session := Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)}
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.digests[sessionID] = digestToken(token)
|
||||
s.mu.Unlock()
|
||||
return session, token, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Authenticate(sessionID, token string, now time.Time) (Session, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok || session.RevokedAt != (time.Time{}) || !now.Before(session.ExpiresAt) || !constantTimeEqual(s.digests[sessionID], digestToken(token)) {
|
||||
return Session{}, ErrSessionRejected
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Revoke(sessionID string, now time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return ErrSessionRejected
|
||||
}
|
||||
if session.RevokedAt.IsZero() {
|
||||
session.RevokedAt = now
|
||||
s.sessions[sessionID] = session
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func digestToken(token string) string {
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTicketVerifierBindsAppIdentityExpiryAndSingleUse(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
verifier, _ := NewTicketVerifier(480)
|
||||
ticket := SteamTicket{TicketID: "ticket-1", SteamID: "steam-1", AppID: 480, ExpiresAt: now.Add(time.Minute)}
|
||||
resolve := func(steamID string) (string, bool) { return "player-1", steamID == "steam-1" }
|
||||
identity, err := verifier.Verify(ticket, resolve, now)
|
||||
if err != nil || identity.PlayerID != "player-1" || identity.SteamID != "steam-1" {
|
||||
t.Fatalf("identity = %+v err=%v", identity, err)
|
||||
}
|
||||
if _, err := verifier.Verify(ticket, resolve, now); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("ticket replay accepted: %v", err)
|
||||
}
|
||||
wrong := ticket
|
||||
wrong.TicketID = "ticket-2"
|
||||
wrong.AppID = 481
|
||||
if _, err := verifier.Verify(wrong, resolve, now); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("wrong app accepted: %v", err)
|
||||
}
|
||||
expired := ticket
|
||||
expired.TicketID = "ticket-3"
|
||||
expired.ExpiresAt = now
|
||||
if _, err := verifier.Verify(expired, resolve, now); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("expired ticket accepted: %v", err)
|
||||
}
|
||||
unknown := ticket
|
||||
unknown.TicketID = "ticket-4"
|
||||
unknown.SteamID = "steam-unknown"
|
||||
if _, err := verifier.Verify(unknown, resolve, now); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("unresolved SteamID accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOpaqueShortLivedAndRevocable(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
store := NewSessionStore()
|
||||
session, token, err := store.Issue("player-1", time.Minute, now)
|
||||
if err != nil || token == "" || session.PlayerID != "player-1" {
|
||||
t.Fatalf("issue = %+v token=%q err=%v", session, token, err)
|
||||
}
|
||||
if _, err := store.Authenticate(session.SessionID, "wrong", now); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("wrong token accepted: %v", err)
|
||||
}
|
||||
if got, err := store.Authenticate(session.SessionID, token, now.Add(59*time.Second)); err != nil || got.SessionID != session.SessionID {
|
||||
t.Fatalf("valid auth = %+v err=%v", got, err)
|
||||
}
|
||||
if err := store.Revoke(session.SessionID, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Authenticate(session.SessionID, token, now); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("revoked session accepted: %v", err)
|
||||
}
|
||||
if _, err := store.Authenticate(session.SessionID, token, now.Add(time.Minute)); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("expired session accepted: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user