From d8d522094803a3e48c2c9eff38f347bfa65ff141 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Wed, 9 Jul 2025 03:57:08 -0400 Subject: [PATCH] Require login to connect to staging (#1360) ## Description: Complete: - Add support for cookie-based auth (ref https://github.com/openfrontio/infra/pull/83) - Restrict game server API access to users with a specific flare - Restrict join game to users with a valid token and an allowed flare - Unauthorized landing page - Token cache - Destroy token cookie on logout ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- src/client/Main.ts | 142 +++++++++++++++++++----- src/client/jwt.ts | 35 ++++-- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 3 + src/core/configuration/DevConfig.ts | 5 + src/core/configuration/PreprodConfig.ts | 5 + src/server/Worker.ts | 28 ++++- tests/util/TestServerConfig.ts | 3 + 8 files changed, 181 insertions(+), 41 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index e1a72f887..3f6b7ccd4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,6 +1,8 @@ import favicon from "../../resources/images/Favicon.svg"; import version from "../../resources/version.txt"; +import { UserMeResponse } from "../core/ApiSchemas"; import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; +import { ServerConfig } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; @@ -201,13 +203,105 @@ class Client { territoryModal.open(); }); + loginDiscordButton.addEventListener("click", discordLogin); + const onUserMe = async (userMeResponse: UserMeResponse | false) => { + const config = await getServerConfigFromClient(); + if (!hasAllowedFlare(userMeResponse, config)) { + if (userMeResponse === false) { + // Login is required + document.body.innerHTML = ` +
+
+

Login is required to access this website.

+

You are being redirected...

+
+
+
+
+
+
+ + `; + setTimeout(discordLogin, 5000); + } else { + // Unauthorized + document.body.innerHTML = ` +
+
+

You are not authorized to access this website.

+

If you believe you are seeing this message in error, please contact the website administrator.

+
+
+
+ `; + } + return; + } else if (userMeResponse === false) { + // Not logged in + loginDiscordButton.disable = false; + loginDiscordButton.hidden = false; + loginDiscordButton.translationKey = "main.login_discord"; + logoutDiscordButton.hidden = true; + territoryModal.onUserMe(null); + } else { + // Authorized + console.log( + `Your player ID is ${userMeResponse.player.publicId}\n` + + "Sharing this ID will allow others to view your game history and stats.", + ); + loginDiscordButton.translationKey = "main.logged_in"; + loginDiscordButton.hidden = true; + territoryModal.onUserMe(userMeResponse); + } + }; + if (isLoggedIn() === false) { // Not logged in - loginDiscordButton.disable = false; - loginDiscordButton.translationKey = "main.login_discord"; - loginDiscordButton.addEventListener("click", discordLogin); - logoutDiscordButton.hidden = true; - territoryModal.onUserMe(null); + onUserMe(false); } else { // JWT appears to be valid loginDiscordButton.disable = true; @@ -216,33 +310,11 @@ class Client { logoutDiscordButton.addEventListener("click", () => { // Log out logOut(); - territoryModal.onUserMe(null); - loginDiscordButton.disable = false; - loginDiscordButton.translationKey = "main.login_discord"; - loginDiscordButton.hidden = false; - loginDiscordButton.addEventListener("click", discordLogin); - logoutDiscordButton.hidden = true; + onUserMe(false); }); // Look up the discord user object. // TODO: Add caching - getUserMe().then((userMeResponse) => { - if (userMeResponse === false) { - // Not logged in - loginDiscordButton.disable = false; - loginDiscordButton.translationKey = "main.login_discord"; - loginDiscordButton.addEventListener("click", discordLogin); - logoutDiscordButton.hidden = true; - territoryModal.onUserMe(null); - return; - } - console.log( - `Your player ID is ${userMeResponse.player.publicId}\n` + - "Sharing this ID will allow others to view your game history and stats.", - ); - loginDiscordButton.translationKey = "main.logged_in"; - loginDiscordButton.hidden = true; - territoryModal.onUserMe(userMeResponse); - }); + getUserMe().then(onUserMe); } const settingsModal = document.querySelector( @@ -479,3 +551,15 @@ function getPersistentIDFromCookie(): string { return newID; } + +function hasAllowedFlare( + userMeResponse: UserMeResponse | false, + config: ServerConfig, +) { + const allowed = config.allowedFlares(); + if (allowed === undefined) return true; + if (userMeResponse === false) return false; + const flares = userMeResponse.player.flares; + if (flares === undefined) return false; + return allowed.length === 0 || allowed.some((f) => flares.includes(f)); +} diff --git a/src/client/jwt.ts b/src/client/jwt.ts index da5af1818..93221592d 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -7,6 +7,7 @@ import { UserMeResponse, UserMeResponseSchema, } from "../core/ApiSchemas"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; function getAudience() { const { hostname } = new URL(window.location.href); @@ -22,6 +23,7 @@ export function getApiBase() { } function getToken(): string | null { + // Check window hash const { hash } = window.location; if (hash.startsWith("#")) { const params = new URLSearchParams(hash.slice(1)); @@ -40,9 +42,31 @@ function getToken(): string | null { (params.size > 0 ? "#" + params.toString() : ""), ); } + + // Check cookie + const cookie = document.cookie + .split(";") + .find((c) => c.trim().startsWith("token=")) + ?.trim() + .substring(6); + if (cookie !== undefined) { + return cookie; + } + + // Check local storage return localStorage.getItem("token"); } +async function clearToken() { + localStorage.removeItem("token"); + __isLoggedIn = false; + const config = await getServerConfigFromClient(); + const audience = config.jwtAudience(); + const isSecure = window.location.protocol === "https:"; + const secure = isSecure ? "; Secure" : ""; + document.cookie = `token=logged_out; Path=/; Max-Age=0; Domain=${audience}${secure}`; +} + export function discordLogin() { window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; } @@ -54,10 +78,9 @@ export function getAuthHeader(): string { } export async function logOut(allSessions: boolean = false) { - const token = localStorage.getItem("token"); + const token = getToken(); if (token === null) return; - localStorage.removeItem("token"); - __isLoggedIn = false; + clearToken(); const response = await fetch( getApiBase() + (allSessions ? "/revoke" : "/logout"), @@ -177,8 +200,7 @@ export async function postRefresh(): Promise { }, }); if (response.status === 401) { - localStorage.removeItem("token"); - __isLoggedIn = false; + clearToken(); return false; } if (response.status !== 200) return false; @@ -209,8 +231,7 @@ export async function getUserMe(): Promise { }, }); if (response.status === 401) { - localStorage.removeItem("token"); - __isLoggedIn = false; + clearToken(); return false; } if (response.status !== 200) return false; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index f9b3d3bcb..cab53fc83 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -61,6 +61,7 @@ export interface ServerConfig { cloudflareConfigPath(): string; cloudflareCredsPath(): string; stripePublishableKey(): string; + allowedFlares(): string[] | undefined; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index a7fcf4a54..77087b6f2 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -70,6 +70,9 @@ const numPlayersConfig = { } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { + allowedFlares(): string[] | undefined { + return; + } stripePublishableKey(): string { return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 2b713d9cb..d14a29665 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -42,6 +42,11 @@ export class DevServerConfig extends DefaultServerConfig { subdomain(): string { return ""; } + allowedFlares(): string[] | undefined { + return [ + // Require login but do not rqeuire any flares + ]; + } } export class DevConfig extends DefaultConfig { diff --git a/src/core/configuration/PreprodConfig.ts b/src/core/configuration/PreprodConfig.ts index 78662578c..a0567aaa9 100644 --- a/src/core/configuration/PreprodConfig.ts +++ b/src/core/configuration/PreprodConfig.ts @@ -11,4 +11,9 @@ export const preprodConfig = new (class extends DefaultServerConfig { jwtAudience(): string { return "openfront.dev"; } + allowedFlares(): string[] | undefined { + return [ + // "access:openfront.dev" + ]; + } })(); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 5888209a7..489c0258f 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -345,8 +345,8 @@ export function startWorker() { // Verify token signature const result = await verifyClientToken(clientMsg.token, config); if (result === false) { - log.warn("Failed to verify token"); - ws.close(1002, "Failed to verify token"); + log.warn("Unauthorized: Invalid token"); + ws.close(1002, "Unauthorized"); return; } const { persistentId, claims } = result; @@ -354,18 +354,36 @@ export function startWorker() { let roles: string[] | undefined; let flares: string[] | undefined; + const allowedFlares = config.allowedFlares(); if (claims === null) { - // TODO: Verify that the persistendId is is not a registered player + if (allowedFlares !== undefined) { + log.warn("Unauthorized: Anonymous user attempted to join game"); + ws.close(1002, "Unauthorized"); + return; + } } else { // Verify token and get player permissions const result = await getUserMe(clientMsg.token, config); if (result === false) { - log.warn("Failed to verify token"); - ws.close(1002, "Failed to verify token"); + log.warn("Unauthorized: Invalid session"); + ws.close(1002, "Unauthorized"); return; } roles = result.player.roles; flares = result.player.flares; + + if (allowedFlares !== undefined) { + const allowed = + allowedFlares.length === 0 || + allowedFlares.some((f) => flares?.includes(f)); + if (!allowed) { + log.warn( + "Forbidden: player without an allowed flare attempted to join game", + ); + ws.close(1002, "Forbidden"); + return; + } + } } // Check if the flag is allowed diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 5db2c4ca4..5babd040f 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + allowedFlares(): string[] | undefined { + throw new Error("Method not implemented."); + } stripePublishableKey(): string { throw new Error("Method not implemented."); }