Require login to connect to staging (#1360)

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

- [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 evanpelle
parent 433964461a
commit 76fb54a81e
9 changed files with 285 additions and 61 deletions
+111 -25
View File
@@ -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 = `
<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;
} 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));
}
+37 -4
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 @@ 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<boolean> {
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<UserMeResponse | false> {
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);
+2 -1
View File
@@ -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<typeof UserMeResponseSchema>;
+8
View File
@@ -56,6 +56,14 @@ export interface ServerConfig {
jwtAudience(): string;
jwtIssuer(): string;
jwkPublicKey(): Promise<JWK>;
domain(): string;
subdomain(): string;
cloudflareAccountId(): string;
cloudflareApiToken(): string;
cloudflareConfigPath(): string;
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
}
export interface NukeMagnitude {
+25
View File
@@ -68,6 +68,31 @@ 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 ?? "";
}
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 {
+13
View File
@@ -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 {
+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"
];
}
})();
+60 -31
View File
@@ -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(
+24
View File
@@ -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.");
}