diff --git a/resources/lang/en.json b/resources/lang/en.json index b827a0027..16a3206a4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -882,6 +882,7 @@ "kick_reason": { "duplicate_session": "Kicked from game (you may have been playing on another tab)", "lobby_creator": "Kicked by lobby creator", + "admin": "Kicked by an admin", "host_left": "The host has left the lobby." }, "send_troops_modal": { diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index d2a5eaaba..aea335ffe 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -57,6 +57,7 @@ export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; playerName: string; playerClanTag: string | null; + playerRole: string | null; gameID: GameID; turnstileToken: string | null; // GameStartInfo only exists when playing a singleplayer game. @@ -259,7 +260,12 @@ async function createClientGame( const canvas = createCanvas(); const soundManager = new SoundManager(eventBus, userSettings); try { - const gameRenderer = createRenderer(canvas, gameView, eventBus); + const gameRenderer = createRenderer( + canvas, + gameView, + eventBus, + lobbyConfig.playerRole, + ); console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, diff --git a/src/client/Main.ts b/src/client/Main.ts index c2bf472c7..93dfc47df 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -756,6 +756,8 @@ class Client { if (lobby.source !== "public") { this.updateJoinUrlForShare(lobby.gameID, config); } + const auth = await userAuth(); + const playerRole = auth !== false ? (auth.claims.role ?? null) : null; const newLobbyHandle = joinLobby(this.eventBus, { gameID: lobby.gameID, serverConfig: config, @@ -763,6 +765,7 @@ class Client { turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), playerClanTag: this.usernameInput?.getClanTag() ?? null, + playerRole, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }); diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index fad782e2c..4df65facc 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -51,6 +51,7 @@ export function createRenderer( canvas: HTMLCanvasElement, game: GameView, eventBus: EventBus, + playerRole: string | null, ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); @@ -204,6 +205,8 @@ export function createRenderer( playerPanel.emojiTable = emojiTable; playerPanel.uiState = uiState; + playerPanel.setRole(playerRole); + const chatModal = document.querySelector("chat-modal") as ChatModal; if (!(chatModal instanceof ChatModal)) { console.error("chat modal not found"); diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts index 2bf570e31..dcf8b833a 100644 --- a/src/client/graphics/layers/PlayerModerationModal.ts +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -18,6 +18,7 @@ export class PlayerModerationModal extends LitElement { @property({ type: Boolean }) open: boolean = false; @property({ type: Boolean }) alreadyKicked: boolean = false; + @property({ type: Boolean }) isAdmin: boolean = false; createRenderRoot() { return this; @@ -44,7 +45,7 @@ export class PlayerModerationModal extends LitElement { private canKick(my: PlayerView, other: PlayerView): boolean { return ( - my.isLobbyCreator() && + (my.isLobbyCreator() || this.isAdmin) && other !== my && other.type() === PlayerType.Human && !!other.clientID() diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 17dc419bd..6a60675d2 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -72,6 +72,15 @@ export class PlayerPanel extends LitElement implements Layer { @state() private otherProfile: PlayerProfile | null = null; @state() private suppressNextHide: boolean = false; @state() private moderationTarget: PlayerView | null = null; + @state() private playerRole: string | null = null; + + setRole(role: string | null): void { + this.playerRole = role; + } + + private get isAdminRole(): boolean { + return this.playerRole === "admin" || this.playerRole === "root"; + } private ctModal: ChatModal; @@ -441,8 +450,12 @@ export class PlayerPanel extends LitElement implements Layer { `; } - private renderModeration(my: PlayerView, other: PlayerView) { - if (!my.isLobbyCreator()) return html``; + private renderModeration( + my: PlayerView, + other: PlayerView, + isAdmin: boolean, + ) { + if (!my.isLobbyCreator() && !isAdmin) return html``; const moderationTitle = translateText("player_panel.moderation"); return html` @@ -845,7 +858,7 @@ export class PlayerPanel extends LitElement implements Layer { })} ` : ""} - ${this.renderModeration(my, other)} + ${this.renderModeration(my, other, this.isAdminRole)} `; } @@ -963,6 +976,7 @@ export class PlayerPanel extends LitElement implements Layer { .myPlayer=${my} .target=${this.moderationTarget} .eventBus=${this.eventBus} + .isAdmin=${this.isAdminRole} .alreadyKicked=${this.kickedPlayerIDs.has( String(this.moderationTarget.id()), )} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index f0e2a02e0..25a4d8bc0 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -51,6 +51,11 @@ export const TokenPayloadSchema = z.object({ }); export type TokenPayload = z.infer; +export const ADMIN_ROLES = ["admin", "root"] as const; +export function isAdminRole(role: string | null | undefined): boolean { + return role === "admin" || role === "root"; +} + export const DiscordUserSchema = z.object({ id: z.string(), avatar: z.string().nullable(), @@ -72,7 +77,6 @@ export const UserMeResponseSchema = z.object({ }), player: z.object({ publicId: z.string(), - roles: z.string().array().optional(), flares: z.string().array().optional(), achievements: z.object({ singleplayerMap: z.array(SingleplayerMapAchievementSchema), diff --git a/src/server/Client.ts b/src/server/Client.ts index ca57acc3e..51a8e7a77 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -14,7 +14,7 @@ export class Client { public readonly clientID: ClientID, public readonly persistentID: string, public readonly claims: TokenPayload | null, - public readonly roles: string[] | undefined, + public readonly role: string | null, public readonly flares: string[] | undefined, public readonly ip: string, public username: string, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index cf8ede075..ab95da102 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -2,6 +2,7 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; import { z } from "zod"; +import { isAdminRole } from "../core/ApiSchemas"; import { GameEnv, ServerConfig } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { @@ -35,6 +36,7 @@ export enum GamePhase { const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; +const KICK_REASON_ADMIN = "kick_reason.admin"; const KICK_REASON_HOST_LEFT = "kick_reason.host_left"; const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data"; const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message"; @@ -394,18 +396,24 @@ export class GameServer { // Handle kick_player intent via WebSocket case "kick_player": { - // Check if the authenticated client is the lobby creator - if (client.clientID !== this.lobbyCreatorID) { - this.log.warn(`Only lobby creator can kick players`, { - clientID: client.clientID, - creatorID: this.lobbyCreatorID, - target: stampedIntent.target, - gameID: this.id, - }); + const isLobbyCreator = client.clientID === this.lobbyCreatorID; + const isAdmin = isAdminRole(client.role); + + // Check if the authenticated client is the lobby creator or admin + if (!isLobbyCreator && !isAdmin) { + this.log.warn( + `Only lobby creator or admin can kick players`, + { + clientID: client.clientID, + creatorID: this.lobbyCreatorID, + target: stampedIntent.target, + gameID: this.id, + }, + ); return; } - // Don't allow lobby creator to kick themselves + // Don't allow kicking yourself if (client.clientID === stampedIntent.target) { this.log.warn(`Cannot kick yourself`, { clientID: client.clientID, @@ -414,8 +422,9 @@ export class GameServer { } // Log and execute the kick - this.log.info(`Lobby creator initiated kick of player`, { - creatorID: client.clientID, + this.log.info(`Player initiated kick`, { + kickerID: client.clientID, + isAdmin, target: stampedIntent.target, gameID: this.id, kickMethod: "websocket", @@ -423,7 +432,9 @@ export class GameServer { this.kickClient( stampedIntent.target, - KICK_REASON_LOBBY_CREATOR, + isAdmin && !isLobbyCreator + ? KICK_REASON_ADMIN + : KICK_REASON_LOBBY_CREATOR, ); return; } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 3beacb6e8..750d2817c 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -401,7 +401,6 @@ export async function startWorker() { return; } - let roles: string[] | undefined; let flares: string[] | undefined; const allowedFlares = config.allowedFlares(); @@ -422,7 +421,6 @@ export async function startWorker() { ws.close(1002, "Unauthorized: user me fetch failed"); return; } - roles = result.response.player.roles; flares = result.response.player.flares; if (allowedFlares !== undefined) { @@ -484,7 +482,7 @@ export async function startWorker() { generateID(), persistentId, claims, - roles, + claims?.role ?? null, flares, ip, censoredUsername, diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts index e5a37bfef..ce9755b17 100644 --- a/tests/client/graphics/layers/PlayerPanelKick.test.ts +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -59,7 +59,7 @@ describe("PlayerPanel - kick player moderation", () => { } as unknown as PlayerView; (actionButton as unknown as ReturnType).mockClear(); - (panel as any).renderModeration(my, other); + (panel as any).renderModeration(my, other, false); expect(actionButton).toHaveBeenCalledTimes(1); expect( (actionButton as unknown as ReturnType).mock.calls[0][0], @@ -71,16 +71,31 @@ describe("PlayerPanel - kick player moderation", () => { (actionButton as unknown as ReturnType).mockClear(); (panel as any).kickedPlayerIDs.add("2"); - (panel as any).renderModeration(my, other); + (panel as any).renderModeration(my, other, false); expect(actionButton).toHaveBeenCalledTimes(1); const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView; (actionButton as unknown as ReturnType).mockClear(); (panel as any).kickedPlayerIDs.clear(); - (panel as any).renderModeration(notCreator, other); + (panel as any).renderModeration(notCreator, other, false); expect(actionButton).not.toHaveBeenCalled(); }); + test("renders moderation action when isAdmin=true even if not lobby creator", () => { + const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView; + const other = { + id: () => 2, + name: () => "Other", + displayName: () => "[TAG] Other", + type: () => PlayerType.Human, + clientID: () => "client-2", + } as unknown as PlayerView; + + (actionButton as unknown as ReturnType).mockClear(); + (panel as any).renderModeration(notCreator, other, true); + expect(actionButton).toHaveBeenCalledTimes(1); + }); + test("opens moderation modal and hides after a kick", () => { const other = { id: () => 2, @@ -171,4 +186,40 @@ describe("PlayerModerationModal - kick confirmation", () => { expect(eventBus.emit).not.toHaveBeenCalled(); expect(kickedListener).not.toHaveBeenCalled(); }); + + describe("canKick", () => { + function makeModal(isAdmin: boolean) { + const modal = new PlayerModerationModal(); + modal.isAdmin = isAdmin; + return modal; + } + + const nonCreator = { isLobbyCreator: () => false } as unknown as PlayerView; + const creator = { isLobbyCreator: () => true } as unknown as PlayerView; + const humanOther = { + type: () => PlayerType.Human, + clientID: () => "client-other", + } as unknown as PlayerView; + + test("admin non-creator can kick a valid other player", () => { + const modal = makeModal(true); + expect((modal as any).canKick(nonCreator, humanOther)).toBe(true); + }); + + test("non-admin non-creator cannot kick", () => { + const modal = makeModal(false); + expect((modal as any).canKick(nonCreator, humanOther)).toBe(false); + }); + + test("admin cannot kick themselves", () => { + const modal = makeModal(true); + // same object reference → other === my + expect((modal as any).canKick(nonCreator, nonCreator)).toBe(false); + }); + + test("lobby creator can kick a valid other player", () => { + const modal = makeModal(false); + expect((modal as any).canKick(creator, humanOther)).toBe(true); + }); + }); }); diff --git a/tests/server/KickPlayerAuthorization.test.ts b/tests/server/KickPlayerAuthorization.test.ts new file mode 100644 index 000000000..e5a1c698e --- /dev/null +++ b/tests/server/KickPlayerAuthorization.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/configuration/ConfigLoader", () => ({ + getServerConfigFromServer: () => ({ + otelEnabled: () => false, + otelAuthHeader: () => "", + otelEndpoint: () => "", + env: () => 0, // GameEnv.Dev + }), + getServerConfig: () => ({ + otelEnabled: () => false, + }), +})); + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameStartInfoSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ServerPrestartMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ClientMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + }; +}); + +import { GameType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; +import { GameServer } from "../../src/server/GameServer"; + +function makeMockWs() { + const handlers: Record any> = {}; + return { + on: (event: string, handler: (...args: any[]) => any) => { + handlers[event] = handler; + }, + removeAllListeners: (_event: string) => {}, + send: vi.fn(), + close: vi.fn(), + readyState: 1, + trigger: (event: string, ...args: any[]) => handlers[event]?.(...args), + }; +} + +function makeClient( + clientID: string, + persistentID: string, + role?: string, +): { client: Client; ws: ReturnType } { + const ws = makeMockWs(); + const client = new Client( + clientID, + persistentID, + null, + role ?? null, + undefined, + "127.0.0.1", + "TestUser", + null, + ws as any, + undefined, + ); + return { client, ws }; +} + +describe("GameServer - kick_player authorization", () => { + let mockLogger: any; + let mockConfig: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + mockConfig = { + turnIntervalMs: () => 100, + gameCreationRate: () => 1000, + env: () => 0, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + function makeGame(creatorPersistentID?: string) { + return new GameServer( + "test-game", + mockLogger, + Date.now(), + mockConfig, + { gameType: GameType.Private } as any, + creatorPersistentID, + ); + } + + async function sendKickMessage( + ws: ReturnType, + target: string, + ) { + await ws.trigger( + "message", + JSON.stringify({ + type: "intent", + intent: { type: "kick_player", target }, + }), + ); + } + + it("lobby creator can kick another player with lobby_creator reason", async () => { + const game = makeGame("creator-pid"); + const kickSpy = vi.spyOn(game, "kickClient"); + + const { client: creator, ws: creatorWs } = makeClient( + "creator-cid", + "creator-pid", + ); + const { client: target } = makeClient("target-cid", "target-pid"); + + game.joinClient(creator); + game.joinClient(target); + + await sendKickMessage(creatorWs, "target-cid"); + + expect(kickSpy).toHaveBeenCalledOnce(); + expect(kickSpy).toHaveBeenCalledWith( + "target-cid", + "kick_reason.lobby_creator", + ); + }); + + it("admin-flared player can kick another player with admin reason", async () => { + const game = makeGame(); + const kickSpy = vi.spyOn(game, "kickClient"); + + const { client: admin, ws: adminWs } = makeClient( + "admin-cid", + "admin-pid", + "admin", + ); + const { client: target } = makeClient("target-cid", "target-pid"); + + game.joinClient(admin); + game.joinClient(target); + + await sendKickMessage(adminWs, "target-cid"); + + expect(kickSpy).toHaveBeenCalledOnce(); + expect(kickSpy).toHaveBeenCalledWith("target-cid", "kick_reason.admin"); + }); + + it("non-creator non-admin cannot kick", async () => { + const game = makeGame("creator-pid"); + const kickSpy = vi.spyOn(game, "kickClient"); + + const { client: creator } = makeClient("creator-cid", "creator-pid"); + const { client: rando, ws: randoWs } = makeClient("rando-cid", "rando-pid"); + const { client: target } = makeClient("target-cid", "target-pid"); + + game.joinClient(creator); + game.joinClient(rando); + game.joinClient(target); + + await sendKickMessage(randoWs, "target-cid"); + + expect(kickSpy).not.toHaveBeenCalled(); + }); + + it("cannot kick yourself even as lobby creator", async () => { + const game = makeGame("creator-pid"); + const kickSpy = vi.spyOn(game, "kickClient"); + + const { client: creator, ws: creatorWs } = makeClient( + "creator-cid", + "creator-pid", + ); + game.joinClient(creator); + + await sendKickMessage(creatorWs, "creator-cid"); + + expect(kickSpy).not.toHaveBeenCalled(); + }); +});