mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
f628ccfd35
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.
186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
// 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
|
|
}
|