Files
CosmicClash/server/domain/auth.go
T
2026-08-31 20:49:45 +01:00

143 lines
3.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
}
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
}