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.
This commit is contained in:
Josh Creek
2026-09-05 10:26:25 +01:00
parent 320ec46ba2
commit 4248e51c60
4 changed files with 187 additions and 10 deletions
+90
View File
@@ -1916,3 +1916,93 @@ func TestPostgreSQLQueuedCandidatesCarryAuthoritativeRatings(t *testing.T) {
t.Fatalf("rating spread collapsed: %v", got)
}
}
// banned_until and ban_reason existed in the schema from day one but no
// production query ever read them: the only ban check was an in-memory map on
// domain.TicketVerifier used by domain tests. A banned identity therefore kept
// working through every already-issued session until expiry, and could obtain
// new ones, defeating the server-authoritative anti-abuse boundary.
func TestPostgreSQLIdentityBanIsEnforcedOnIssuanceAndAuthentication(t *testing.T) {
db := openIntegrationPostgres(t)
applyIntegrationMigrations(t, db)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('ban-player', 'ban-steam')`); err != nil {
t.Fatal(err)
}
// Two independently constructed stores stand in for two control-plane
// replicas: the ban must hold on the replica that did not apply it.
replicaA := PostgresSessions{DB: db}
replicaB := PostgresSessions{DB: db}
session, token, err := replicaA.Issue(ctx, "ban-player", time.Hour, now)
if err != nil {
t.Fatalf("issue before ban: %v", err)
}
if _, err := replicaB.Authenticate(ctx, session.SessionID, token, now); err != nil {
t.Fatalf("authenticate before ban: %v", err)
}
if err := ApplyIdentityBan(ctx, db, "ban-player", "cheating", now.Add(24*time.Hour), now); err != nil {
t.Fatalf("apply ban: %v", err)
}
// The pre-existing session must stop working immediately, on a replica
// that never saw the ban being applied -- not at session expiry.
if _, err := replicaB.Authenticate(ctx, session.SessionID, token, now.Add(time.Minute)); err == nil {
t.Fatal("banned identity still authenticated with its existing session")
}
// ... and no new session may be minted.
if _, _, err := replicaB.Issue(ctx, "ban-player", time.Hour, now.Add(time.Minute)); err == nil {
t.Fatal("banned identity was issued a new session")
}
// The ban is time-bounded: once it lapses, sign-in works again.
afterBan := now.Add(25 * time.Hour)
revived, revivedToken, err := replicaA.Issue(ctx, "ban-player", time.Hour, afterBan)
if err != nil {
t.Fatalf("issue after ban expiry: %v", err)
}
if _, err := replicaB.Authenticate(ctx, revived.SessionID, revivedToken, afterBan); err != nil {
t.Fatalf("authenticate after ban expiry: %v", err)
}
// An explicit unban clears the state without resurrecting revoked sessions.
if err := ApplyIdentityBan(ctx, db, "ban-player", "", time.Time{}, now); err != nil {
t.Fatalf("clear ban: %v", err)
}
if _, err := replicaB.Authenticate(ctx, session.SessionID, token, now.Add(time.Minute)); err == nil {
t.Fatal("unban resurrected a session revoked by the ban")
}
}
// ApplyIdentityBan revokes sessions, so revocation alone would mask a missing
// ban check in Authenticate. A ban applied by any other path -- an admin tool,
// a future adapter, a direct operational UPDATE -- does not revoke anything,
// and must still take effect on the very next authenticated request.
func TestPostgreSQLBanWithoutRevocationStillBlocksExistingSessions(t *testing.T) {
db := openIntegrationPostgres(t)
applyIntegrationMigrations(t, db)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('raw-ban-player', 'raw-ban-steam')`); err != nil {
t.Fatal(err)
}
sessions := PostgresSessions{DB: db}
session, token, err := sessions.Issue(ctx, "raw-ban-player", time.Hour, now)
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := sessions.Authenticate(ctx, session.SessionID, token, now); err != nil {
t.Fatalf("authenticate before ban: %v", err)
}
// Set the ban only; deliberately leave every session unrevoked.
if _, err := db.ExecContext(ctx, `UPDATE identities SET banned_until = $2, ban_reason = 'manual' WHERE player_id = $1`, "raw-ban-player", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := sessions.Authenticate(ctx, session.SessionID, token, now.Add(time.Minute)); err == nil {
t.Fatal("a ban applied without revocation left the existing session usable")
}
}