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.");
}