Files
CosmicClash/server/domain/auth.go
T
Josh Creek 4248e51c60 fix(server): enforce durable identity bans on session issuance and auth
banned_until and ban_reason have been in the schema since 0001, but no
production query ever read them -- grepping the tree found no reference
outside the migration itself. The only ban check was an in-memory map on
domain.TicketVerifier used by domain tests. Once real Steam login is
wired, a banned identity would keep full access through every existing
session until expiry and could obtain new ones.

Make the ban part of the durable authentication transaction rather than
a policy each login adapter must remember to re-implement:

- Session issuance inserts only when the identity exists and has no
  active ban, so a banned player cannot mint a session.
- Authentication joins the identity and rejects an active ban on every
  request, so a ban takes effect immediately on every replica rather
  than at session expiry.
- ApplyIdentityBan sets the ban and revokes that identity's sessions in
  one serializable transaction, closing the window where the ban is
  durable but another replica still accepts an issued session.

Bans are time-bounded and clearing one does not resurrect sessions the
ban revoked.

Tests cover enforcement across two independently constructed stores
standing in for two replicas, expiry/unban semantics, and -- separately,
because revocation would otherwise mask it -- that a ban applied without
revoking anything still blocks the next request.
2026-09-05 10:26:25 +01:00

285 lines
7.8 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
banned map[string]bool
}
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 is deliberately opaque to the client: it must not
// distinguish "no such session" from "wrong token".
ErrSessionRejected = fmt.Errorf("session rejected")
// ErrIdentityBanned is separate so the server can log and act on a ban
// distinctly, even though the client sees the same rejection.
ErrIdentityBanned = fmt.Errorf("identity is banned")
)
func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) {
if expectedApp == 0 {
return nil, ErrTicketRejected
}
return &TicketVerifier{expectedApp: expectedApp, consumed: make(map[string]time.Time), banned: make(map[string]bool)}, nil
}
func (v *TicketVerifier) SetBanned(playerID string, banned bool) error {
if v == nil || playerID == "" {
return ErrTicketRejected
}
v.mu.Lock()
defer v.mu.Unlock()
if banned {
v.banned[playerID] = true
} else {
delete(v.banned, playerID)
}
return 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 == "" || v.banned[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
}