Files
Josh Creek 4248e51c60 fix(server): enforce durable identity bans on session issuance and auth
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.
2026-09-05 10:26:25 +01:00

38 lines
1.4 KiB
Go

package store
import (
"testing"
"time"
)
func TestSessionSQLStoresDigestAndEnforcesRevocationBoundary(t *testing.T) {
for query, fragments := range map[string][]string{
// Issuance and authentication must both consult the identity's ban
// state; these fragments are the durable enforcement points.
SessionInsertSQL: {"token_digest", "expires_at", "created_at", "banned_until", "FROM identities"},
SessionSelectSQL: {"token_digest", "revoked_at", "banned_until", "JOIN identities", "WHERE s.session_id = $1"},
SessionRevokeSQL: {"COALESCE(revoked_at", "WHERE session_id = $1"},
SessionRevokeAllForPlayerSQL: {"COALESCE(revoked_at", "WHERE player_id = $1"},
IdentityBanSQL: {"banned_until", "ban_reason", "WHERE player_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")
}
}