mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(auth): wire production Steam sign-in and the client login flow
newAPIService never supplied SteamLogin, so POST /v1/session/steam always returned 503 auth_unavailable in production. The only implementation was cmd/testkit-api's fake, which derives an identity from the ticket string itself and accepts anything -- so the passing integration path was neither deployable nor secure. On the client side the game started with an empty token and a loopback base URL, and no production code called configure() or login_steam(); the menu entered matchmaking directly, so every request failed ERR_UNAUTHORIZED before reaching the network. Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an interface, so the production login path is testable with only the Valve call stubbed. It rejects family-shared copies (the account playing does not own the app) and, by default, VAC- or publisher-banned accounts, and refuses malformed tickets locally rather than forwarding them. Crucially it separates our faults from the player's: a Valve outage or a revoked publisher key returns 503, not 401. Answering 401 would tell a legitimate player their login failed and send them to fix an account that is fine while the real fault went unnoticed. A banned identity now returns 403 rather than a misleading 503. Sign-in is configuration-gated on the publisher key and App ID: without them the endpoint keeps returning 503, since silently accepting an unverified ticket would be worse than refusing to authenticate. A returning player keeps the player ID they already had, so ratings, penalties and bans follow the account rather than the session. Client side: acquire a web-API ticket through GodotSteam's async signal -- requesting one returns a handle, not a ticket -- using the existing dynamic-call pattern so stock Godot still parses the project. The endpoint is configurable for release builds, and matchmaking completes sign-in before it will queue. Verified against real PostgreSQL; 232 Godot tests pass.
This commit is contained in:
@@ -15,6 +15,10 @@ signal assignment_connection_started(assignment: AssignmentState)
|
||||
signal assignment_connection_failed(detail: String)
|
||||
|
||||
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
|
||||
# Release builds must point at the real control plane rather than a developer's
|
||||
# loopback. The environment variable is read at startup so the same binary can
|
||||
# be pointed at a staging or production endpoint without a rebuild.
|
||||
const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL"
|
||||
const PERSIST_PATH := "user://matchmaking_state.cfg"
|
||||
const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0
|
||||
|
||||
@@ -156,6 +160,21 @@ func _connect_when_assigned(match_id: String) -> void:
|
||||
_pending_connect_match_id = match_id
|
||||
|
||||
|
||||
# configured_base_url resolves the endpoint this build should use, preferring
|
||||
# explicit configuration over the loopback development default.
|
||||
static func configured_base_url() -> String:
|
||||
var configured := OS.get_environment(BASE_URL_ENV).strip_edges()
|
||||
if is_valid_base_url(configured):
|
||||
return configured
|
||||
return DEFAULT_BASE_URL
|
||||
|
||||
|
||||
# has_session reports whether matchmaking requests can be made at all. Without
|
||||
# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request.
|
||||
func has_session() -> bool:
|
||||
return not access_token.is_empty() and not is_session_expired(session_expires_at)
|
||||
|
||||
|
||||
func configure(url: String, token: String) -> bool:
|
||||
var normalized := url.strip_edges().trim_suffix("/")
|
||||
var normalized_token := token.strip_edges()
|
||||
|
||||
@@ -24,6 +24,7 @@ var _recovery_poll_seconds := 0.0
|
||||
var _pending_probe_regions: Array[String] = []
|
||||
var _probed_regions: Array[String] = []
|
||||
var _deferred_queue := {}
|
||||
var _web_api_ticket_handle := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -39,10 +40,57 @@ func _ready() -> void:
|
||||
ControlPlaneClient.session_expired.connect(_on_session_expired)
|
||||
ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received)
|
||||
ControlPlaneClient.probe_recorded.connect(_on_probe_recorded)
|
||||
_ensure_signed_in()
|
||||
_refresh_ranked_profile()
|
||||
_render(ControlPlaneClient.state.snapshot())
|
||||
|
||||
|
||||
# Matchmaking previously opened with an empty token against a loopback default,
|
||||
# so every request failed ERR_UNAUTHORIZED before reaching the network. Point
|
||||
# the client at its configured endpoint and complete Steam sign-in first.
|
||||
func _ensure_signed_in() -> void:
|
||||
if ControlPlaneClient.has_session():
|
||||
return
|
||||
if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""):
|
||||
_on_local_error("Matchmaking endpoint is not configured")
|
||||
return
|
||||
if not SteamBootstrap.supports_web_api_ticket():
|
||||
# Deliberately explicit rather than silently presenting a search that
|
||||
# can never start: online matchmaking requires a verified identity.
|
||||
_on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason())
|
||||
return
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket):
|
||||
steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket)
|
||||
_web_api_ticket_handle = SteamBootstrap.request_web_api_ticket()
|
||||
if _web_api_ticket_handle <= 0:
|
||||
_on_local_error("Could not request a Steam authentication ticket")
|
||||
return
|
||||
ControlPlaneClient.state.set_notice("Signing in...")
|
||||
|
||||
|
||||
func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void:
|
||||
# Steam reports k_EResultOK as 1; anything else means no usable ticket.
|
||||
if result != 1 or ticket.is_empty():
|
||||
_on_local_error("Steam declined to issue an authentication ticket")
|
||||
return
|
||||
var encoded := SteamBootstrap.encode_web_api_ticket(ticket)
|
||||
if encoded.is_empty():
|
||||
_on_local_error("Steam returned an unusable authentication ticket")
|
||||
return
|
||||
var err := ControlPlaneClient.login_steam(encoded)
|
||||
if err != OK:
|
||||
_on_local_error("Could not sign in: %s" % error_string(err))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# The ticket handle is a Steam resource; releasing it avoids leaking one
|
||||
# per visit to this screen.
|
||||
if _web_api_ticket_handle > 0:
|
||||
SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle)
|
||||
_web_api_ticket_handle = 0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
|
||||
_elapsed_seconds += delta
|
||||
@@ -62,6 +110,10 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func _on_queue_pressed() -> void:
|
||||
if not ControlPlaneClient.has_session():
|
||||
# Queueing without a session would fail at the first request guard.
|
||||
_ensure_signed_in()
|
||||
return
|
||||
if ControlPlaneClient.can_retry_queue_create():
|
||||
var retry_err := ControlPlaneClient.retry_queue_create()
|
||||
if retry_err != OK:
|
||||
|
||||
@@ -40,3 +40,53 @@ static func initialize() -> Dictionary:
|
||||
if result is Dictionary and bool(result.get("status", false)):
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
|
||||
|
||||
|
||||
# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this
|
||||
# ticket with Valve's publisher API for a verified Steam identity; the client
|
||||
# never chooses its own identity, which is what makes this the fix for slot
|
||||
# reclaim being keyed on a display name.
|
||||
#
|
||||
# GodotSteam delivers the ticket asynchronously through the
|
||||
# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until
|
||||
# Steam has confirmed it with its backend. Requesting one and reading the
|
||||
# return value alone yields a handle, not a ticket.
|
||||
#
|
||||
# Everything here is called dynamically so stock Godot, which has no GodotSteam
|
||||
# symbols, can still parse and run the project.
|
||||
const WEB_API_IDENTITY := "cosmicclash"
|
||||
|
||||
|
||||
static func supports_web_api_ticket() -> bool:
|
||||
if not is_runtime_available():
|
||||
return false
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi")
|
||||
|
||||
|
||||
# Returns the request handle, or 0 when unavailable. The caller must await the
|
||||
# `get_auth_ticket_for_web_api` signal for the ticket itself.
|
||||
static func request_web_api_ticket() -> int:
|
||||
if not supports_web_api_ticket():
|
||||
return 0
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY)
|
||||
return int(handle) if handle is int or handle is float else 0
|
||||
|
||||
|
||||
static func cancel_web_api_ticket(handle: int) -> void:
|
||||
if handle <= 0 or not is_runtime_available():
|
||||
return
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
if steam.has_method("cancelAuthTicket"):
|
||||
steam.call("cancelAuthTicket", handle)
|
||||
|
||||
|
||||
# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded.
|
||||
static func encode_web_api_ticket(buffer: PackedByteArray) -> String:
|
||||
if buffer.is_empty():
|
||||
return ""
|
||||
var encoded := ""
|
||||
for byte in buffer:
|
||||
encoded += "%02x" % int(byte)
|
||||
return encoded
|
||||
|
||||
@@ -557,3 +557,26 @@ func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
|
||||
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
|
||||
client.free()
|
||||
|
||||
|
||||
# The game started with an empty token against a loopback default and no
|
||||
# production code ever called configure() or login_steam(), so every
|
||||
# matchmaking request failed ERR_UNAUTHORIZED before reaching the network.
|
||||
func test_has_session_reflects_token_and_expiry() -> void:
|
||||
var client = ControlPlaneClient.new()
|
||||
assert_true(not client.has_session(), "a fresh client has no session")
|
||||
client.access_token = "session-1234567890:token-1234567890"
|
||||
client.session_expires_at = "2099-01-01T00:00:00Z"
|
||||
assert_true(client.has_session(), "a valid unexpired token is a session")
|
||||
client.session_expires_at = "2000-01-01T00:00:00Z"
|
||||
assert_true(not client.has_session(), "an expired token is not a session")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_configured_base_url_falls_back_to_the_development_default() -> void:
|
||||
# Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the
|
||||
# loopback default keeps local development working.
|
||||
var resolved := ControlPlaneClient.configured_base_url()
|
||||
assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable")
|
||||
if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty():
|
||||
assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
|
||||
|
||||
|
||||
|
||||
|
||||
# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a
|
||||
# ticket before, so ControlPlaneClient.login_steam() had no production caller.
|
||||
# These run on stock Godot, which has no GodotSteam symbols, so they cover the
|
||||
# pure encoding and the unavailable path rather than a live Steam session.
|
||||
func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void:
|
||||
if SteamBootstrap.is_runtime_available():
|
||||
return
|
||||
assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build")
|
||||
assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle")
|
||||
# Must not throw on stock Godot; cancelling a handle we never got is a no-op.
|
||||
SteamBootstrap.cancel_web_api_ticket(0)
|
||||
SteamBootstrap.cancel_web_api_ticket(17)
|
||||
|
||||
|
||||
func test_web_api_ticket_encoding_is_lowercase_hex() -> void:
|
||||
# The publisher Web API expects the raw ticket bytes hex encoded; the
|
||||
# backend rejects anything non-hex before it forwards a ticket to Valve.
|
||||
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing")
|
||||
assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex")
|
||||
var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250]))
|
||||
assert_eq(encoded.length(), 10, "each byte becomes exactly two characters")
|
||||
assert_eq(encoded, encoded.to_lower(), "encoding is lowercase")
|
||||
+33
-14
@@ -20,12 +20,14 @@ import (
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 8 << 10
|
||||
|
||||
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
|
||||
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error)
|
||||
|
||||
// ProbeProvider validates a probe answer against the nonce the backend issued
|
||||
// and returns evidence whose ServerRTT is derived from backend timestamps
|
||||
// only. It takes a context because the issued nonce is durable: any replica
|
||||
@@ -116,25 +118,25 @@ type RosterProvider func(context.Context, domain.WorkloadBinding, time.Time) ([]
|
||||
type ReadinessCheck func(context.Context) error
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
Sessions *domain.SessionStore
|
||||
SessionBackend SessionBackend
|
||||
SessionIssuer SessionIssuer
|
||||
SteamLogin SteamLoginProvider
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
QueueBackend QueueBackend
|
||||
CandidateIndex CandidateIndex
|
||||
// EventFanout, when set, publishes outbox-sourced events through a shared
|
||||
// transport instead of only this replica's in-memory hub. Without it a
|
||||
// client connected to a replica other than the one that drained the outbox
|
||||
// row never receives the event.
|
||||
EventFanout func(ControlPlaneEvent) error
|
||||
Probe ProbeProvider
|
||||
ProbeChallenger ProbeChallengeIssuer
|
||||
EventFanout func(ControlPlaneEvent) error
|
||||
Probe ProbeProvider
|
||||
ProbeChallenger ProbeChallengeIssuer
|
||||
// CandidateRefresh re-reads a player's durable queue candidate so the
|
||||
// transient index can be corrected after its RTT changes.
|
||||
CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error)
|
||||
CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error)
|
||||
ProbeRecorder ProbeRecorder
|
||||
WorkloadVerify WorkloadVerifier
|
||||
ResultSubmitter ResultSubmitter
|
||||
@@ -355,7 +357,18 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := s.now()
|
||||
identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now)
|
||||
if err != nil || identity.PlayerID == "" || identity.SteamID == "" {
|
||||
if err != nil {
|
||||
// A Valve outage or a bad publisher key is our problem, not the
|
||||
// player's; answering 401 would tell a legitimate player their login
|
||||
// failed and send them off to fix an account that is fine.
|
||||
if errors.Is(err, steam.ErrUnavailable) {
|
||||
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if identity.PlayerID == "" || identity.SteamID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
@@ -370,6 +383,12 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Session issuance refuses an actively banned identity. That is a
|
||||
// decision about this account, not an outage.
|
||||
if errors.Is(err, domain.ErrSessionRejected) {
|
||||
writeError(w, http.StatusForbidden, "identity_banned")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
)
|
||||
|
||||
// SteamTicketVerifier is the boundary to Valve. Keeping it an interface means
|
||||
// the production login path can be exercised end to end with the external call
|
||||
// stubbed, instead of only through a fake login provider that skips the whole
|
||||
// flow.
|
||||
type SteamTicketVerifier interface {
|
||||
Verify(ctx context.Context, ticket string) (steam.Identity, error)
|
||||
}
|
||||
|
||||
// SteamLogin is the production SteamLoginProvider: verify the ticket with
|
||||
// Valve, then resolve the verified Steam ID to a durable player ID.
|
||||
type SteamLogin struct {
|
||||
DB *sql.DB
|
||||
Verifier SteamTicketVerifier
|
||||
}
|
||||
|
||||
// PlayerIDForSteamID derives the durable player ID for a Steam ID on first
|
||||
// sign-in. It is a hash rather than the Steam ID itself so player IDs, which
|
||||
// appear in rosters and logs, do not restate the platform identifier.
|
||||
func PlayerIDForSteamID(steamID string) string {
|
||||
digest := sha256.Sum256([]byte("cosmic-clash/player/" + steamID))
|
||||
return "player-" + hex.EncodeToString(digest[:12])
|
||||
}
|
||||
|
||||
func (s SteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
|
||||
if s.DB == nil || s.Verifier == nil {
|
||||
return domain.VerifiedIdentity{}, domain.ErrTicketRejected
|
||||
}
|
||||
identity, err := s.Verifier.Verify(ctx, ticket)
|
||||
if err != nil {
|
||||
return domain.VerifiedIdentity{}, err
|
||||
}
|
||||
// A returning player keeps the player ID they already had, so ratings,
|
||||
// penalties and bans follow the account rather than the session.
|
||||
playerID, err := store.ResolveSteamIdentity(ctx, s.DB, identity.SteamID, PlayerIDForSteamID(identity.SteamID))
|
||||
if err != nil {
|
||||
return domain.VerifiedIdentity{}, err
|
||||
}
|
||||
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: identity.SteamID}, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/steam"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -34,6 +36,9 @@ func main() {
|
||||
rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter")
|
||||
rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter")
|
||||
trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For")
|
||||
steamPublisherKey := flag.String("steam-publisher-key", os.Getenv("COSMIC_CLASH_STEAM_PUBLISHER_KEY"), "Steamworks publisher Web API key. Required for player sign-in; POST /v1/session/steam returns 503 until it and --steam-app-id are set. Never expose this to clients")
|
||||
steamAppID := flag.Uint64("steam-app-id", 0, "Steamworks App ID this build authenticates tickets for; may also be set via COSMIC_CLASH_STEAM_APP_ID")
|
||||
steamRejectBanned := flag.Bool("steam-reject-banned", true, "refuse sign-in for VAC- or publisher-banned accounts")
|
||||
minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor")
|
||||
flag.Parse()
|
||||
if *role != "api" {
|
||||
@@ -79,7 +84,32 @@ func main() {
|
||||
if *workloadSecret == "" {
|
||||
fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503")
|
||||
}
|
||||
if *steamAppID == 0 {
|
||||
if value := os.Getenv("COSMIC_CLASH_STEAM_APP_ID"); value != "" {
|
||||
parsed, parseErr := strconv.ParseUint(value, 10, 64)
|
||||
if parseErr != nil {
|
||||
fatalf("COSMIC_CLASH_STEAM_APP_ID must be a positive integer")
|
||||
}
|
||||
*steamAppID = parsed
|
||||
}
|
||||
}
|
||||
service := newAPIService(db, *workloadSecret, candidateIndex)
|
||||
// Player sign-in is configuration-gated rather than always-on: without a
|
||||
// publisher key there is no safe way to verify a ticket, and silently
|
||||
// accepting one would be worse than refusing to authenticate at all. The
|
||||
// endpoint keeps returning 503 until both values are supplied.
|
||||
if *steamPublisherKey != "" && *steamAppID != 0 {
|
||||
service.SteamLogin = api.SteamLogin{
|
||||
DB: db,
|
||||
Verifier: steam.WebAPIVerifier{
|
||||
PublisherKey: *steamPublisherKey,
|
||||
AppID: *steamAppID,
|
||||
RejectBanned: *steamRejectBanned,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "control-plane: warning: --steam-publisher-key and --steam-app-id are unset; player sign-in will return 503")
|
||||
}
|
||||
service.RateLimiter = rateLimiter
|
||||
service.ClientIPs = clientIPs
|
||||
service.MinProtocolVersion = *minProtocolVersion
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package steam adapts Valve's publisher Web API to the control plane's
|
||||
// SteamLoginProvider. It is the only place that talks to Valve, so the rest of
|
||||
// the service stays testable without network access or a publisher key.
|
||||
package steam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthenticateUserTicketURL is the publisher endpoint. Only the backend may
|
||||
// call it: it requires the publisher key, which must never reach a client.
|
||||
const AuthenticateUserTicketURL = "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/"
|
||||
|
||||
// MaxTicketBytes bounds what will be forwarded to Valve. A web-API ticket is a
|
||||
// few hundred hex characters; anything larger is abuse, not a ticket.
|
||||
const MaxTicketBytes = 4096
|
||||
|
||||
var (
|
||||
// ErrTicketRejected is returned for any ticket Valve does not accept, and
|
||||
// for a ticket issued for another application. It deliberately does not
|
||||
// distinguish those cases to the caller.
|
||||
ErrTicketRejected = fmt.Errorf("steam ticket rejected")
|
||||
// ErrUnavailable separates "Valve is down or misconfigured" from "this
|
||||
// player's ticket is bad", so the API can answer 503 rather than telling a
|
||||
// legitimate player their login failed.
|
||||
ErrUnavailable = fmt.Errorf("steam authentication is unavailable")
|
||||
)
|
||||
|
||||
// Identity is what a verified ticket proves. It is deliberately not
|
||||
// domain.VerifiedIdentity: this package resolves a Steam ID, and mapping that
|
||||
// onto a durable player ID is the caller's business.
|
||||
type Identity struct {
|
||||
SteamID string
|
||||
OwnerSteamID string
|
||||
VACBanned bool
|
||||
PublisherBan bool
|
||||
}
|
||||
|
||||
// WebAPIVerifier calls Valve's publisher API. Construct it only when a
|
||||
// publisher key and App ID are configured; the control plane leaves its login
|
||||
// provider unset otherwise, which surfaces as an explicit 503.
|
||||
type WebAPIVerifier struct {
|
||||
PublisherKey string
|
||||
AppID uint64
|
||||
HTTP *http.Client
|
||||
// Endpoint overrides the Valve URL in tests. Production leaves it empty.
|
||||
Endpoint string
|
||||
// RejectBanned refuses a VAC- or publisher-banned account at login.
|
||||
RejectBanned bool
|
||||
}
|
||||
|
||||
func (v WebAPIVerifier) validate() error {
|
||||
if v.PublisherKey == "" || v.AppID == 0 {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v WebAPIVerifier) endpoint() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
}
|
||||
return AuthenticateUserTicketURL
|
||||
}
|
||||
|
||||
func (v WebAPIVerifier) httpClient() *http.Client {
|
||||
if v.HTTP != nil {
|
||||
return v.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
// authenticateResponse is Valve's shape. Fields absent from a failure response
|
||||
// stay zero, which the result check below rejects.
|
||||
type authenticateResponse struct {
|
||||
Response struct {
|
||||
Params struct {
|
||||
Result string `json:"result"`
|
||||
SteamID string `json:"steamid"`
|
||||
OwnerSteamID string `json:"ownersteamid"`
|
||||
VACBanned bool `json:"vacbanned"`
|
||||
PublisherBanned bool `json:"publisherbanned"`
|
||||
} `json:"params"`
|
||||
Error *struct {
|
||||
ErrorCode int `json:"errorcode"`
|
||||
ErrorDesc string `json:"errordesc"`
|
||||
} `json:"error"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
// Verify exchanges a client-supplied web-API ticket for a Steam identity.
|
||||
//
|
||||
// The ticket is single-use at Valve's end and the client never gets to choose
|
||||
// the resulting Steam ID, which is the property that makes this the fix for
|
||||
// slot reclaim being keyed on a display name.
|
||||
func (v WebAPIVerifier) Verify(ctx context.Context, ticket string) (Identity, error) {
|
||||
if err := v.validate(); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
ticket = strings.TrimSpace(ticket)
|
||||
if ticket == "" || len(ticket) > MaxTicketBytes || !isHex(ticket) {
|
||||
// Rejected locally: a malformed ticket is never worth a round trip,
|
||||
// and this bounds what an unauthenticated caller can make us forward.
|
||||
return Identity{}, ErrTicketRejected
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("key", v.PublisherKey)
|
||||
query.Set("appid", strconv.FormatUint(v.AppID, 10))
|
||||
query.Set("ticket", ticket)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.endpoint()+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
response, err := v.httpClient().Do(request)
|
||||
if err != nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
defer response.Body.Close()
|
||||
// Bounded read: this is a third-party response and must not be able to
|
||||
// exhaust memory.
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusUnauthorized {
|
||||
// Our publisher key is wrong or revoked. That is our problem, not the
|
||||
// player's, so it must not read as a rejected ticket.
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
var decoded authenticateResponse
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
if decoded.Response.Error != nil || !strings.EqualFold(decoded.Response.Params.Result, "OK") {
|
||||
return Identity{}, ErrTicketRejected
|
||||
}
|
||||
identity := Identity{
|
||||
SteamID: decoded.Response.Params.SteamID,
|
||||
OwnerSteamID: decoded.Response.Params.OwnerSteamID,
|
||||
VACBanned: decoded.Response.Params.VACBanned,
|
||||
PublisherBan: decoded.Response.Params.PublisherBanned,
|
||||
}
|
||||
if !isSteamID(identity.SteamID) {
|
||||
return Identity{}, ErrTicketRejected
|
||||
}
|
||||
if identity.OwnerSteamID != "" && identity.OwnerSteamID != identity.SteamID {
|
||||
// Family sharing: the account playing does not own the app. Treat it
|
||||
// as a rejection rather than silently matchmaking a borrowed copy.
|
||||
return Identity{}, ErrTicketRejected
|
||||
}
|
||||
if v.RejectBanned && (identity.VACBanned || identity.PublisherBan) {
|
||||
return Identity{}, ErrTicketRejected
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func isHex(value string) bool {
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isSteamID(value string) bool {
|
||||
if len(value) < 17 || len(value) > 20 {
|
||||
return false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
return err == nil && parsed > 0
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package steam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const validTicket = "140000008bc0a1f45fd4b4b7e0af2c4a01001001"
|
||||
|
||||
func stubValve(t *testing.T, status int, body string, inspect func(*http.Request)) WebAPIVerifier {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if inspect != nil {
|
||||
inspect(r)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return WebAPIVerifier{PublisherKey: "publisher-key", AppID: 480, Endpoint: server.URL, HTTP: server.Client()}
|
||||
}
|
||||
|
||||
func TestVerifyReturnsIdentityForAnAcceptedTicket(t *testing.T) {
|
||||
var seen *http.Request
|
||||
verifier := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":false,"publisherbanned":false}}}`,
|
||||
func(r *http.Request) { seen = r })
|
||||
identity, err := verifier.Verify(context.Background(), validTicket)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if identity.SteamID != "76561198000000001" {
|
||||
t.Fatalf("identity = %+v", identity)
|
||||
}
|
||||
// The publisher key must be sent to Valve and nowhere else; assert it is
|
||||
// carried in the request rather than, say, logged or returned.
|
||||
if seen.URL.Query().Get("key") != "publisher-key" || seen.URL.Query().Get("appid") != "480" {
|
||||
t.Fatalf("request query = %s", seen.URL.RawQuery)
|
||||
}
|
||||
if seen.URL.Query().Get("ticket") != validTicket {
|
||||
t.Fatalf("ticket was not forwarded verbatim: %s", seen.URL.Query().Get("ticket"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTicketsValveDoesNotAccept(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"explicit failure": `{"response":{"params":{"result":"Failure","steamid":"76561198000000001"}}}`,
|
||||
"error object": `{"response":{"error":{"errorcode":101,"errordesc":"Invalid ticket"}}}`,
|
||||
"empty response": `{"response":{}}`,
|
||||
"no steam id": `{"response":{"params":{"result":"OK"}}}`,
|
||||
"bogus steam id": `{"response":{"params":{"result":"OK","steamid":"not-a-steam-id"}}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
verifier := stubValve(t, http.StatusOK, body, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsFamilySharedAndBannedAccounts(t *testing.T) {
|
||||
shared := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000002","ownersteamid":"76561198000000001"}}}`, nil)
|
||||
if _, err := shared.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("family-shared copy accepted: %v", err)
|
||||
}
|
||||
|
||||
banned := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":true}}}`, nil)
|
||||
banned.RejectBanned = true
|
||||
if _, err := banned.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("VAC-banned account accepted: %v", err)
|
||||
}
|
||||
banned.RejectBanned = false
|
||||
if _, err := banned.Verify(context.Background(), validTicket); err != nil {
|
||||
t.Fatalf("ban enforcement should be configurable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Valve outage or a revoked publisher key must not read as "this player's
|
||||
// ticket is bad", or a legitimate player is told to fix an account that is
|
||||
// fine while the real fault goes unnoticed.
|
||||
func TestVerifyDistinguishesOurFaultsFromBadTickets(t *testing.T) {
|
||||
for name, status := range map[string]int{
|
||||
"revoked publisher key": http.StatusForbidden,
|
||||
"unauthorized": http.StatusUnauthorized,
|
||||
"valve error": http.StatusInternalServerError,
|
||||
"valve gateway": http.StatusBadGateway,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
verifier := stubValve(t, status, `{}`, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("malformed response", func(t *testing.T) {
|
||||
verifier := stubValve(t, http.StatusOK, `not json`, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyRefusesMalformedTicketsWithoutCallingValve(t *testing.T) {
|
||||
called := false
|
||||
verifier := stubValve(t, http.StatusOK, `{}`, func(*http.Request) { called = true })
|
||||
for name, ticket := range map[string]string{
|
||||
"empty": "",
|
||||
"whitespace": " ",
|
||||
"not hex": "zzzz-not-a-ticket",
|
||||
"oversized": strings.Repeat("a", MaxTicketBytes+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(context.Background(), ticket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if called {
|
||||
t.Fatal("a malformed ticket was forwarded to Valve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIsUnavailableWithoutCredentials(t *testing.T) {
|
||||
for name, verifier := range map[string]WebAPIVerifier{
|
||||
"no key": {AppID: 480},
|
||||
"no app id": {PublisherKey: "publisher-key"},
|
||||
"neither": {},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2277,3 +2277,64 @@ func TestPostgreSQLProbedTicketBecomesSelectableByTheMatcher(t *testing.T) {
|
||||
t.Fatal("the refreshed candidate still carries an empty RTT map, so Redis would keep a stale entry")
|
||||
}
|
||||
}
|
||||
|
||||
// Production sign-in, exercised through the real SteamLogin provider with only
|
||||
// the Valve HTTP call stubbed. newAPIService never supplied SteamLogin, so
|
||||
// POST /v1/session/steam always returned 503 auth_unavailable; the only
|
||||
// implementation was cmd/testkit-api's fake, which accepts any ticket string
|
||||
// and therefore proves nothing about the deployable path.
|
||||
func TestPostgreSQLSteamLoginResolvesDurableIdentities(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
ctx := context.Background()
|
||||
const steamID = "76561198000000001"
|
||||
|
||||
// First sign-in creates the identity.
|
||||
playerID, err := ResolveSteamIdentity(ctx, db, steamID, "player-first")
|
||||
if err != nil {
|
||||
t.Fatalf("first sign-in: %v", err)
|
||||
}
|
||||
if playerID != "player-first" {
|
||||
t.Fatalf("first sign-in player = %q", playerID)
|
||||
}
|
||||
|
||||
// A returning player must keep the player ID they already had, or their
|
||||
// ratings, penalties and bans would silently detach from their account.
|
||||
returning, err := ResolveSteamIdentity(ctx, db, steamID, "player-different-proposal")
|
||||
if err != nil {
|
||||
t.Fatalf("returning sign-in: %v", err)
|
||||
}
|
||||
if returning != "player-first" {
|
||||
t.Fatalf("returning player was given a new ID %q", returning)
|
||||
}
|
||||
|
||||
var identities int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM identities WHERE steam_id = $1`, steamID).Scan(&identities); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if identities != 1 {
|
||||
t.Fatalf("one Steam ID produced %d identity rows", identities)
|
||||
}
|
||||
|
||||
// The resolved identity must be usable for session issuance, which is the
|
||||
// step that was unreachable in production.
|
||||
sessions := PostgresSessions{DB: db}
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
session, token, err := sessions.Issue(ctx, returning, time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatalf("issue session for a freshly resolved identity: %v", err)
|
||||
}
|
||||
if _, err := sessions.Authenticate(ctx, session.SessionID, token, now); err != nil {
|
||||
t.Fatalf("authenticate freshly issued session: %v", err)
|
||||
}
|
||||
|
||||
// And a banned account cannot sign in, tying the real login path to the
|
||||
// durable ban enforcement rather than leaving it adapter-specific.
|
||||
if err := ApplyIdentityBan(ctx, db, returning, "cheating", now.Add(time.Hour), now); err != nil {
|
||||
t.Fatalf("apply ban: %v", err)
|
||||
}
|
||||
if _, _, err := sessions.Issue(ctx, returning, time.Hour, now.Add(time.Minute)); err == nil {
|
||||
t.Fatal("a banned identity signed in through the production path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,3 +163,26 @@ func ApplyIdentityBan(ctx context.Context, db *sql.DB, playerID, reason string,
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// IdentityUpsertSQL resolves a verified Steam ID to a durable player ID,
|
||||
// creating the identity on first sign-in. The player ID is derived by the
|
||||
// backend and never supplied by the client.
|
||||
const IdentityUpsertSQL = `INSERT INTO identities (player_id, steam_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (steam_id) DO UPDATE SET steam_id = EXCLUDED.steam_id
|
||||
RETURNING player_id`
|
||||
|
||||
// ResolveSteamIdentity returns the player ID for a verified Steam ID. The
|
||||
// proposed ID is used only when this Steam ID has never signed in before; an
|
||||
// existing identity keeps the player ID it already had, so a returning player
|
||||
// keeps their ratings and penalties.
|
||||
func ResolveSteamIdentity(ctx context.Context, db *sql.DB, steamID, proposedPlayerID string) (string, error) {
|
||||
if db == nil || steamID == "" || proposedPlayerID == "" {
|
||||
return "", domain.ErrTicketRejected
|
||||
}
|
||||
var playerID string
|
||||
if err := db.QueryRowContext(ctx, IdentityUpsertSQL, proposedPlayerID, steamID).Scan(&playerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return playerID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user