mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4248e51c60
banned_until and ban_reason have been in the schema since 0001, but no production query ever read them -- grepping the tree found no reference outside the migration itself. The only ban check was an in-memory map on domain.TicketVerifier used by domain tests. Once real Steam login is wired, a banned identity would keep full access through every existing session until expiry and could obtain new ones. Make the ban part of the durable authentication transaction rather than a policy each login adapter must remember to re-implement: - Session issuance inserts only when the identity exists and has no active ban, so a banned player cannot mint a session. - Authentication joins the identity and rejects an active ban on every request, so a ban takes effect immediately on every replica rather than at session expiry. - ApplyIdentityBan sets the ban and revokes that identity's sessions in one serializable transaction, closing the window where the ban is durable but another replica still accepts an issued session. Bans are time-bounded and clearing one does not resurrect sessions the ban revoked. Tests cover enforcement across two independently constructed stores standing in for two replicas, expiry/unban semantics, and -- separately, because revocation would otherwise mask it -- that a ban applied without revoking anything still blocks the next request.
166 lines
6.1 KiB
Go
166 lines
6.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
|
|
})
|
|
}
|