mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 11:22: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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user