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
This commit is contained in:
Scott Anderson
2025-07-09 03:57:08 -04:00
committed by GitHub
parent 78deecdb6c
commit d8d5220948
8 changed files with 181 additions and 41 deletions
+113 -29
View File
@@ -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 = `
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: sans-serif;
background-size: cover;
background-position: center;
">
<div style="
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2em;
margin: 5em;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
">
<p style="margin-bottom: 1em;">Login is required to access this website.</p>
<p style="margin-bottom: 1.5em;">You are being redirected...</p>
<div style="width: 100%; height: 8px; background-color: #444; border-radius: 4px; overflow: hidden;">
<div style="
height: 100%;
width: 0%;
background-color: #4caf50;
animation: fillBar 5s linear forwards;
"></div>
</div>
</div>
</div>
<div class="bg-image"></div>
<style>
@keyframes fillBar {
from { width: 0%; }
to { width: 100%; }
}
</style>
`;
setTimeout(discordLogin, 5000);
} else {
// Unauthorized
document.body.innerHTML = `
<div style="
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: sans-serif;
background-size: cover;
background-position: center;
">
<div style="
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2em;
margin: 5em;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
">
<p style="margin-bottom: 1em;">You are not authorized to access this website.</p>
<p>If you believe you are seeing this message in error, please contact the website administrator.</p>
</div>
</div>
<div class="bg-image"></div>
`;
}
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));
}
+28 -7
View File
@@ -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<boolean> {
},
});
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<UserMeResponse | false> {
},
});
if (response.status === 401) {
localStorage.removeItem("token");
__isLoggedIn = false;
clearToken();
return false;
}
if (response.status !== 200) return false;
+1
View File
@@ -61,6 +61,7 @@ export interface ServerConfig {
cloudflareConfigPath(): string;
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
}
export interface NukeMagnitude {
+3
View File
@@ -70,6 +70,9 @@ const numPlayersConfig = {
} as const satisfies Record<GameMapType, [number, number, number]>;
export abstract class DefaultServerConfig implements ServerConfig {
allowedFlares(): string[] | undefined {
return;
}
stripePublishableKey(): string {
return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
}
+5
View File
@@ -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 {
+5
View File
@@ -11,4 +11,9 @@ export const preprodConfig = new (class extends DefaultServerConfig {
jwtAudience(): string {
return "openfront.dev";
}
allowedFlares(): string[] | undefined {
return [
// "access:openfront.dev"
];
}
})();
+23 -5
View File
@@ -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
+3
View File
@@ -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.");
}