diff --git a/src/client/Main.ts b/src/client/Main.ts index 684881255..467ca08f8 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1,7 +1,9 @@ import page from "page"; import favicon from "../../resources/images/Favicon.svg"; +import { UserMeResponse } from "../core/ApiSchemas"; import { consolex } from "../core/Consolex"; import { GameRecord, GameStartInfo } 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"; @@ -173,12 +175,103 @@ class Client { hlpModal.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; + } 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; + } + }; + if (isLoggedIn() === false) { // Not logged in - loginDiscordButton.disable = false; - loginDiscordButton.translationKey = "main.login_discord"; - loginDiscordButton.addEventListener("click", discordLogin); - logoutDiscordButton.hidden = true; + onUserMe(false); } else { // JWT appears to be valid loginDiscordButton.disable = true; @@ -187,30 +280,11 @@ class Client { logoutDiscordButton.addEventListener("click", () => { // Log out logOut(); - loginDiscordButton.disable = false; - loginDiscordButton.translationKey = "main.login_discord"; - 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; - 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; - const { user, player } = userMeResponse; - }); + getUserMe().then(onUserMe); } const settingsModal = document.querySelector( @@ -427,3 +501,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 4fdf8ae15..7a8d94330 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 @@ function getApiBase() { } function getToken(): string | null { + // Check window hash const { hash } = window.location; if (hash.startsWith("#")) { const params = new URLSearchParams(hash.slice(1)); @@ -33,21 +35,44 @@ function getToken(): string | null { history.replaceState( null, "", - window.location.pathname + window.location.search, + window.location.pathname + + window.location.search + + (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}`; } 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"), @@ -165,6 +190,10 @@ export async function postRefresh(): Promise { authorization: `Bearer ${token}`, }, }); + if (response.status === 401) { + clearToken(); + return false; + } if (response.status !== 200) return false; const body = await response.json(); const result = RefreshResponseSchema.safeParse(body); @@ -192,6 +221,10 @@ export async function getUserMe(): Promise { authorization: `Bearer ${token}`, }, }); + if (response.status === 401) { + clearToken(); + return false; + } if (response.status !== 200) return false; const body = await response.json(); const result = UserMeResponseSchema.safeParse(body); diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index aec1ae506..1f5bcd2bd 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { base64urlToUuid } from "./Base64"; export const RefreshResponseSchema = z.object({ @@ -43,6 +43,7 @@ export const UserMeResponseSchema = z.object({ player: z.object({ publicId: z.string(), roles: z.string().array().optional(), + flares: z.string().array().optional(), }), }); export type UserMeResponse = z.infer; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e6f4af76f..27f5e5e6b 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -56,6 +56,14 @@ export interface ServerConfig { jwtAudience(): string; jwtIssuer(): string; jwkPublicKey(): Promise; + domain(): string; + subdomain(): string; + cloudflareAccountId(): string; + cloudflareApiToken(): string; + 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 894c1d3c3..5410b95bf 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -68,6 +68,31 @@ const numPlayersConfig = { } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { + allowedFlares(): string[] | undefined { + return; + } + stripePublishableKey(): string { + return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + } + domain(): string { + return process.env.DOMAIN ?? ""; + } + subdomain(): string { + return process.env.SUBDOMAIN ?? ""; + } + cloudflareAccountId(): string { + return process.env.CF_ACCOUNT_ID ?? ""; + } + cloudflareApiToken(): string { + return process.env.CF_API_TOKEN ?? ""; + } + cloudflareConfigPath(): string { + return process.env.CF_CONFIG_PATH ?? ""; + } + cloudflareCredsPath(): string { + return process.env.CF_CREDS_PATH ?? ""; + } + private publicKey: JWK; abstract jwtAudience(): string; jwtIssuer(): string { diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 9da20511a..95b3c2412 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -38,6 +38,19 @@ export class DevServerConfig extends DefaultServerConfig { gitCommit(): string { return "DEV"; } + + domain(): string { + return "localhost"; + } + + 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 068799fed..69471d44c 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -309,51 +309,80 @@ export function startWorker() { return; } - const { persistentId, claims } = await verifyClientToken( - clientMsg.token, - config, - ); + // Verify token signature + const result = await verifyClientToken(clientMsg.token, config); + if (result.claims === null) { + log.warn("Unauthorized: Invalid token"); + ws.close(1002, "Unauthorized"); + return; + } + const { persistentId, claims } = result; let roles: string[] | undefined; + let flares: string[] | undefined; - // Check user roles - if (claims !== null) { + const allowedFlares = config.allowedFlares(); + if (claims === null) { + 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("Token is not valid", claims); + 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; + } + } } - // TODO: Validate client settings based on roles + // Check if the flag is allowed + if (clientMsg.flag !== undefined) { + // TODO: Validate client settings based on roles - // Create client and add to game - const client = new Client( - clientMsg.clientID, - persistentId, - claims, - roles, - ip, - clientMsg.username, - ws, - clientMsg.flag, - ); - - const wasFound = gm.addClient( - client, - clientMsg.gameID, - clientMsg.lastTurn, - ); - - if (!wasFound) { - log.info( - `game ${clientMsg.gameID} not found on worker ${workerId}`, + // Create client and add to game + const client = new Client( + clientMsg.clientID, + persistentId, + claims, + roles, + ip, + clientMsg.username, + ws, + clientMsg.flag, ); - // Handle game not found case + + const wasFound = gm.addClient( + client, + clientMsg.gameID, + clientMsg.lastTurn, + ); + + if (!wasFound) { + log.info( + `game ${clientMsg.gameID} not found on worker ${workerId}`, + ); + // Handle game not found case + } } } - // Handle other message types } catch (error) { log.warn( diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 4c1f76eb4..c5e34ab30 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,30 @@ 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."); + } + cloudflareConfigPath(): string { + throw new Error("Method not implemented."); + } + cloudflareCredsPath(): string { + throw new Error("Method not implemented."); + } + domain(): string { + throw new Error("Method not implemented."); + } + subdomain(): string { + throw new Error("Method not implemented."); + } + cloudflareAccountId(): string { + throw new Error("Method not implemented."); + } + cloudflareApiToken(): string { + throw new Error("Method not implemented."); + } jwtAudience(): string { throw new Error("Method not implemented."); }