mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 05:52:04 +00:00
feat: persist authenticated sessions
This commit is contained in:
+1
-1
@@ -1135,7 +1135,7 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns
|
|||||||
| 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate |
|
| 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate |
|
||||||
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side |
|
| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side |
|
||||||
| 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional |
|
| 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional |
|
||||||
| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier, including maintenance expiry sweeping; separate Web API/game-server ticket lifecycles remain | `server/domain/auth.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, and release identity only after verifier success; real Steam BeginAuthSession/EndAuthSession adapter and persistent session integration remain |
|
| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier, including maintenance expiry sweeping; `store.PostgresSessions` persists only token digests and enforces durable expiry/revocation | `server/domain/auth.go`, `server/store/session_sql.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, expire abandoned attempts at the boundary, release identity only after verifier success, and reject invalid session inputs; real Steam BeginAuthSession/EndAuthSession adapter and live PostgreSQL/session integration remain |
|
||||||
| 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Production prerequisites and rotation owners are recorded; no Spacewar credential or development certificate can reach a release build |
|
| 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Production prerequisites and rotation owners are recorded; no Spacewar credential or development certificate can reach a release build |
|
||||||
| 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Two real accounts complete and reconnect to an assigned dedicated match through SDR; server/player IPs are not exposed; ENet gates remain green |
|
| 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Two real accounts complete and reconnect to an assigned dedicated match through SDR; server/player IPs are not exposed; ENet gates remain green |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 = `INSERT INTO sessions (session_id, player_id, token_digest, expires_at, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`
|
||||||
|
SessionSelectSQL = `SELECT session_id, player_id, token_digest, expires_at, revoked_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE session_id = $1`
|
||||||
|
SessionRevokeSQL = `UPDATE sessions SET revoked_at = COALESCE(revoked_at, $2)
|
||||||
|
WHERE session_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))
|
||||||
|
if _, err := s.DB.ExecContext(ctx, SessionInsertSQL, session.SessionID, session.PlayerID, digest[:], session.ExpiresAt, now); err != nil {
|
||||||
|
return domain.Session{}, "", err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if err := s.DB.QueryRowContext(ctx, SessionSelectSQL, sessionID).Scan(&session.SessionID, &session.PlayerID, &digestBytes, &session.ExpiresAt, &revokedAt); 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionSQLStoresDigestAndEnforcesRevocationBoundary(t *testing.T) {
|
||||||
|
for query, fragments := range map[string][]string{
|
||||||
|
SessionInsertSQL: {"token_digest", "expires_at", "created_at"},
|
||||||
|
SessionSelectSQL: {"token_digest", "revoked_at", "WHERE session_id = $1"},
|
||||||
|
SessionRevokeSQL: {"COALESCE(revoked_at", "WHERE session_id = $1"},
|
||||||
|
} {
|
||||||
|
for _, fragment := range fragments {
|
||||||
|
if !contains(query, fragment) {
|
||||||
|
t.Fatalf("query %q missing %q", query, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresSessionsRejectsInvalidArgumentsWithoutDatabase(t *testing.T) {
|
||||||
|
sessions := PostgresSessions{}
|
||||||
|
if _, _, err := sessions.Issue(nil, "player-1", time.Minute, time.Unix(1000, 0)); err == nil {
|
||||||
|
t.Fatal("invalid issue accepted")
|
||||||
|
}
|
||||||
|
if _, err := sessions.Authenticate(nil, "session-1", "token-1", time.Unix(1000, 0)); err == nil {
|
||||||
|
t.Fatal("invalid authentication accepted")
|
||||||
|
}
|
||||||
|
if err := sessions.Revoke(nil, "session-1", time.Unix(1000, 0)); err == nil {
|
||||||
|
t.Fatal("invalid revoke accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user