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.
189 lines
7.1 KiB
Go
189 lines
7.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
)
|
|
|
|
const (
|
|
// SessionInsertSQL refuses to mint a session for an actively banned
|
|
// identity. Enforcing it here rather than in the login adapter makes the
|
|
// ban part of the durable authentication transaction, so it holds for any
|
|
// present or future adapter rather than depending on each one to
|
|
// re-implement the policy.
|
|
SessionInsertSQL = `INSERT INTO sessions (session_id, player_id, token_digest, expires_at, created_at)
|
|
SELECT $1, $2, $3, $4, $5
|
|
FROM identities i
|
|
WHERE i.player_id = $2
|
|
AND (i.banned_until IS NULL OR i.banned_until <= $5)`
|
|
// SessionSelectSQL joins the identity so an existing session stops working
|
|
// the moment a ban lands. Without this a banned player kept full access
|
|
// through every already-issued session until it expired.
|
|
SessionSelectSQL = `SELECT s.session_id, s.player_id, s.token_digest, s.expires_at, s.revoked_at,
|
|
i.banned_until
|
|
FROM sessions s
|
|
JOIN identities i ON i.player_id = s.player_id
|
|
WHERE s.session_id = $1`
|
|
SessionRevokeSQL = `UPDATE sessions SET revoked_at = COALESCE(revoked_at, $2)
|
|
WHERE session_id = $1`
|
|
// SessionRevokeAllForPlayerSQL is applied in the same transaction as a ban
|
|
// so there is no window in which the ban is durable but the player's
|
|
// existing sessions still authenticate on another replica.
|
|
SessionRevokeAllForPlayerSQL = `UPDATE sessions SET revoked_at = COALESCE(revoked_at, $2)
|
|
WHERE player_id = $1 AND revoked_at IS NULL`
|
|
IdentityBanSQL = `UPDATE identities SET banned_until = $2, ban_reason = $3
|
|
WHERE player_id = $1`
|
|
IdentityBanClearSQL = `UPDATE identities SET banned_until = NULL, ban_reason = NULL
|
|
WHERE player_id = $1`
|
|
)
|
|
|
|
// PostgresSessions persists only a SHA-256 token digest. The plaintext token
|
|
// is returned once by Issue and is never sent to SQL or logged by this layer.
|
|
type PostgresSessions struct{ DB *sql.DB }
|
|
|
|
func (s PostgresSessions) Issue(ctx context.Context, playerID string, lifetime time.Duration, now time.Time) (domain.Session, string, error) {
|
|
if s.DB == nil || playerID == "" || lifetime <= 0 || now.IsZero() {
|
|
return domain.Session{}, "", domain.ErrSessionRejected
|
|
}
|
|
sessionID, err := opaqueSessionValue()
|
|
if err != nil {
|
|
return domain.Session{}, "", err
|
|
}
|
|
token, err := opaqueSessionValue()
|
|
if err != nil {
|
|
return domain.Session{}, "", err
|
|
}
|
|
session := domain.Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)}
|
|
digest := sha256.Sum256([]byte(token))
|
|
result, err := s.DB.ExecContext(ctx, SessionInsertSQL, session.SessionID, session.PlayerID, digest[:], session.ExpiresAt, now)
|
|
if err != nil {
|
|
return domain.Session{}, "", err
|
|
}
|
|
inserted, err := result.RowsAffected()
|
|
if err != nil {
|
|
return domain.Session{}, "", err
|
|
}
|
|
if inserted != 1 {
|
|
// Either no such identity or an active ban; both refuse issuance.
|
|
return domain.Session{}, "", domain.ErrSessionRejected
|
|
}
|
|
return session, token, nil
|
|
}
|
|
|
|
func (s PostgresSessions) Authenticate(ctx context.Context, sessionID, token string, now time.Time) (domain.Session, error) {
|
|
if s.DB == nil || sessionID == "" || token == "" || now.IsZero() {
|
|
return domain.Session{}, domain.ErrSessionRejected
|
|
}
|
|
var session domain.Session
|
|
var digestBytes []byte
|
|
var revokedAt sql.NullTime
|
|
var bannedUntil sql.NullTime
|
|
if err := s.DB.QueryRowContext(ctx, SessionSelectSQL, sessionID).Scan(&session.SessionID, &session.PlayerID, &digestBytes, &session.ExpiresAt, &revokedAt, &bannedUntil); err != nil {
|
|
return domain.Session{}, domain.ErrSessionRejected
|
|
}
|
|
provided := sha256.Sum256([]byte(token))
|
|
if len(digestBytes) != sha256.Size || subtle.ConstantTimeCompare(digestBytes, provided[:]) != 1 || (revokedAt.Valid && !revokedAt.Time.IsZero()) || !now.Before(session.ExpiresAt) {
|
|
return domain.Session{}, domain.ErrSessionRejected
|
|
}
|
|
// Checked on every authenticated request, not only at issuance, so a ban
|
|
// takes effect immediately across every replica rather than at expiry.
|
|
if bannedUntil.Valid && now.Before(bannedUntil.Time) {
|
|
return domain.Session{}, domain.ErrIdentityBanned
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (s PostgresSessions) Revoke(ctx context.Context, sessionID string, now time.Time) error {
|
|
if s.DB == nil || sessionID == "" || now.IsZero() {
|
|
return domain.ErrSessionRejected
|
|
}
|
|
result, err := s.DB.ExecContext(ctx, SessionRevokeSQL, sessionID, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := result.RowsAffected()
|
|
if err != nil || changed != 1 {
|
|
return domain.ErrSessionRejected
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func opaqueSessionValue() (string, error) {
|
|
value := make([]byte, 32)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(value), nil
|
|
}
|
|
|
|
// ApplyIdentityBan makes a ban and the revocation of that identity's existing
|
|
// sessions one atomic change. Applying the ban alone would leave a window in
|
|
// which another control-plane replica still authenticates an already-issued
|
|
// session, which is exactly the gap that made banned_until dead schema.
|
|
//
|
|
// bannedUntil is the instant the ban lifts; a zero value clears the ban.
|
|
func ApplyIdentityBan(ctx context.Context, db *sql.DB, playerID, reason string, bannedUntil, now time.Time) error {
|
|
if db == nil || playerID == "" || now.IsZero() {
|
|
return domain.ErrSessionRejected
|
|
}
|
|
if !bannedUntil.IsZero() && !bannedUntil.After(now) {
|
|
return domain.ErrSessionRejected
|
|
}
|
|
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
|
var result sql.Result
|
|
var err error
|
|
if bannedUntil.IsZero() {
|
|
result, err = tx.ExecContext(ctx, IdentityBanClearSQL, playerID)
|
|
} else {
|
|
result, err = tx.ExecContext(ctx, IdentityBanSQL, playerID, bannedUntil, reason)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if changed != 1 {
|
|
return domain.ErrSessionRejected
|
|
}
|
|
if bannedUntil.IsZero() {
|
|
// Unbanning does not resurrect revoked sessions; the player signs
|
|
// in again and receives a fresh one.
|
|
return nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, SessionRevokeAllForPlayerSQL, playerID, now)
|
|
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
|
|
}
|