mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 21:43:44 +00:00
265 lines
7.2 KiB
Go
265 lines
7.2 KiB
Go
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
|
|
}
|
|
|
|
type AuthAttemptState string
|
|
|
|
const (
|
|
AuthPending AuthAttemptState = "PENDING"
|
|
AuthAccepted AuthAttemptState = "ACCEPTED"
|
|
AuthRejected AuthAttemptState = "REJECTED"
|
|
AuthCancelled AuthAttemptState = "CANCELLED"
|
|
)
|
|
|
|
type AuthAttempt struct {
|
|
AttemptID string
|
|
TicketID string
|
|
State AuthAttemptState
|
|
Identity VerifiedIdentity
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// AuthCoordinator models the asynchronous BeginAuthSession lifecycle. The
|
|
// external Steam adapter calls Complete only after Steam confirms the ticket;
|
|
// clients never supply the verified identity or transition state themselves.
|
|
type AuthCoordinator struct {
|
|
mu sync.Mutex
|
|
attempts map[string]AuthAttempt
|
|
}
|
|
|
|
var (
|
|
ErrAuthAttemptRejected = fmt.Errorf("auth attempt rejected")
|
|
ErrAuthAttemptPending = fmt.Errorf("auth attempt pending")
|
|
)
|
|
|
|
func NewAuthCoordinator() *AuthCoordinator {
|
|
return &AuthCoordinator{attempts: make(map[string]AuthAttempt)}
|
|
}
|
|
|
|
func (c *AuthCoordinator) Begin(attemptID string, ticket SteamTicket, now time.Time) error {
|
|
if c == nil || attemptID == "" || ticket.TicketID == "" || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) {
|
|
return ErrAuthAttemptRejected
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if _, exists := c.attempts[attemptID]; exists {
|
|
return ErrAuthAttemptRejected
|
|
}
|
|
c.attempts[attemptID] = AuthAttempt{AttemptID: attemptID, TicketID: ticket.TicketID, State: AuthPending, ExpiresAt: ticket.ExpiresAt}
|
|
return nil
|
|
}
|
|
|
|
func (c *AuthCoordinator) Complete(attemptID string, ticket SteamTicket, verifier *TicketVerifier, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) {
|
|
if c == nil || verifier == nil {
|
|
return VerifiedIdentity{}, ErrAuthAttemptRejected
|
|
}
|
|
c.mu.Lock()
|
|
attempt, ok := c.attempts[attemptID]
|
|
if !ok || attempt.State != AuthPending || attempt.TicketID != ticket.TicketID || !now.Before(attempt.ExpiresAt) {
|
|
c.mu.Unlock()
|
|
return VerifiedIdentity{}, ErrAuthAttemptRejected
|
|
}
|
|
identity, err := verifier.Verify(ticket, resolve, now)
|
|
if err != nil {
|
|
attempt.State = AuthRejected
|
|
c.attempts[attemptID] = attempt
|
|
c.mu.Unlock()
|
|
return VerifiedIdentity{}, ErrAuthAttemptRejected
|
|
}
|
|
attempt.State = AuthAccepted
|
|
attempt.Identity = identity
|
|
c.attempts[attemptID] = attempt
|
|
c.mu.Unlock()
|
|
return identity, nil
|
|
}
|
|
|
|
func (c *AuthCoordinator) Cancel(attemptID string) error {
|
|
if c == nil || attemptID == "" {
|
|
return ErrAuthAttemptRejected
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
attempt, ok := c.attempts[attemptID]
|
|
if !ok || attempt.State != AuthPending {
|
|
return ErrAuthAttemptRejected
|
|
}
|
|
attempt.State = AuthCancelled
|
|
c.attempts[attemptID] = attempt
|
|
return nil
|
|
}
|
|
|
|
func (c *AuthCoordinator) Get(attemptID string) (AuthAttempt, error) {
|
|
if c == nil {
|
|
return AuthAttempt{}, ErrAuthAttemptRejected
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
attempt, ok := c.attempts[attemptID]
|
|
if !ok {
|
|
return AuthAttempt{}, ErrAuthAttemptRejected
|
|
}
|
|
if attempt.State != AuthAccepted {
|
|
return attempt, ErrAuthAttemptPending
|
|
}
|
|
return attempt, nil
|
|
}
|
|
|
|
// Expire closes abandoned pending attempts. The caller should run this from
|
|
// the auth maintenance loop; accepted and already terminal attempts are left
|
|
// unchanged so audit/reconciliation can still inspect their outcome.
|
|
func (c *AuthCoordinator) Expire(now time.Time) []AuthAttempt {
|
|
if c == nil || now.IsZero() {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
var expired []AuthAttempt
|
|
for id, attempt := range c.attempts {
|
|
if attempt.State == AuthPending && !now.Before(attempt.ExpiresAt) {
|
|
attempt.State = AuthRejected
|
|
c.attempts[id] = attempt
|
|
expired = append(expired, attempt)
|
|
}
|
|
}
|
|
return expired
|
|
}
|
|
|
|
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
|
|
}
|