mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 21:53:28 +00:00
fix: don't re-challenge Turnstile on lobby reconnect (#4420)
## Problem
A player who joins a **private** lobby and waits for the start timer can
get an alert — `connection refused: Unauthorized: Turnstile token
rejected` — the moment the game starts. Turnstile is only supposed to
gate the *first* join, so this looks wrong.
## Root cause
A websocket **reconnect during the lobby phase** re-sends the original
Turnstile token via `joinGame()` (`ClientGameRunner.ts` lobby
`onconnect` → `Transport.joinGame()`, line 417). Cloudflare Turnstile
tokens are **single-use** and `lobbyConfig.turnstileToken` is never
refreshed, so re-verifying the already-redeemed token returns `rejected`
→ `ws.close(1002, ...)` (`Worker.ts`).
Normally the server skips Turnstile for reconnects: a `join` first tries
`rejoinClient` and returns early if the player is a known member
(`Worker.ts:359-366`). But on a **lobby-phase disconnect**, the close
handler **deletes** the `persistentId → clientId` mapping to free the
slot (`GameServer.ts`, `if (!this._hasStarted) {
persistentIdToClientId.delete(...) }`). With the mapping gone,
`rejoinClient` fails and the reconnect falls through to a full join + a
doomed Turnstile re-check.
**Why at game start:** `GameManager.tick()` calls `prestart()`
immediately but schedules `start()` 2s later, so `_hasStarted` is still
`false` for ~2s — exactly while the client runs its heavy terrain-decode
+ WebGL init, which stalls the ping loop and makes a socket drop (`1006`
→ `reconnect()`) likely. A reconnect in that window re-sends the spent
token and gets rejected.
## Fix
Decouple **"was admitted"** from the slot-mapping:
- `GameServer` tracks `admittedPersistentIds` (populated on a successful
`joinClient`) that **survives** lobby-phase disconnects, plus a
`wasAdmitted()` accessor.
- `GameManager.wasAdmitted(gameID, persistentID)` exposes it.
- `Worker` skips the Turnstile check for an already-admitted player: `if
(env !== Dev && !gm.wasAdmitted(gameID, persistentId))`.
A reconnecting admitted player now proceeds through `joinClient`
normally instead of failing on the spent token.
### Safety
Only the Turnstile check is skipped. Every other gate still runs on
every join: token-signature, ban, flares, clan tag, cosmetics,
allowlist, maxPlayers, and **kick**. Genuine first joins are still
challenged (no admission record yet). The set is per-game and excludes
kicked players, and `persistentId` comes from the verified token so it
can't be spoofed.
## Testing
- New `tests/server/TurnstileReadmit.test.ts` (4 tests), incl. a
regression that fires the real `ws.on("close")` handler and asserts
`getClientIdForPersistentId` goes null **but `wasAdmitted` stays true**.
- Full server suite: 126/126 pass · `tsc --noEmit` clean · eslint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,10 @@ export class GameManager {
|
||||
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
|
||||
}
|
||||
|
||||
wasAdmitted(gameID: GameID, persistentID: string): boolean {
|
||||
return this.games.get(gameID)?.wasAdmitted(persistentID) ?? false;
|
||||
}
|
||||
|
||||
createGame(
|
||||
id: GameID,
|
||||
gameConfig: Partial<GameConfig> | undefined,
|
||||
|
||||
@@ -79,6 +79,11 @@ export class GameServer {
|
||||
private allClients: Map<ClientID, Client> = new Map();
|
||||
// Map persistentID to clientID for reconnection lookup
|
||||
private persistentIdToClientId: Map<string, ClientID> = new Map();
|
||||
// persistentIDs that have passed authorization (incl. Turnstile) for this
|
||||
// game at least once. Survives lobby-phase disconnects, unlike
|
||||
// persistentIdToClientId (which is cleared to free up player slots). Lets a
|
||||
// reconnecting player skip the single-use Turnstile re-check.
|
||||
private admittedPersistentIds: Set<string> = new Set();
|
||||
private clientsDisconnectedStatus: Map<ClientID, boolean> = new Map();
|
||||
private _hasStarted = false;
|
||||
private _startTime: number | null = null;
|
||||
@@ -406,6 +411,15 @@ export class GameServer {
|
||||
return clientID;
|
||||
}
|
||||
|
||||
// Whether this persistentID has already been admitted (passed Turnstile and
|
||||
// other join authorization) for this game. Used to skip the single-use
|
||||
// Turnstile re-check when an already-admitted player reconnects. Kicked
|
||||
// players are excluded so a kick still forces them back through the gate.
|
||||
public wasAdmitted(persistentID: string): boolean {
|
||||
if (this.kickedPersistentIds.has(persistentID)) return false;
|
||||
return this.admittedPersistentIds.has(persistentID);
|
||||
}
|
||||
|
||||
public joinClient(
|
||||
client: Client,
|
||||
): "joined" | "kicked" | "rejected" | "not_allowlisted" {
|
||||
@@ -488,6 +502,7 @@ export class GameServer {
|
||||
// Client connection accepted
|
||||
this.websockets.add(client.ws);
|
||||
this.persistentIdToClientId.set(client.persistentID, client.clientID);
|
||||
this.admittedPersistentIds.add(client.persistentID);
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
|
||||
@@ -435,7 +435,15 @@ export async function startWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ServerEnv.env() !== GameEnv.Dev) {
|
||||
// Turnstile gates the FIRST join only. An already-admitted player who
|
||||
// reconnects (e.g. a socket drop during the lobby->start transition,
|
||||
// after which the server has cleared their reconnection mapping) must
|
||||
// not be re-challenged: their original Turnstile token is single-use
|
||||
// and was already redeemed, so re-verifying it would always fail.
|
||||
if (
|
||||
ServerEnv.env() !== GameEnv.Dev &&
|
||||
!gm.wasAdmitted(clientMsg.gameID, persistentId)
|
||||
) {
|
||||
const turnstileResult = await verifyTurnstileToken(
|
||||
ip,
|
||||
clientMsg.turnstileToken,
|
||||
|
||||
Reference in New Issue
Block a user