Files
CosmicClash/server/domain/auth.go
T
2026-08-31 22:21:31 +01:00

245 lines
6.6 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
}
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
}