mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
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:
+113
-29
@@ -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
@@ -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;
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface ServerConfig {
|
||||
cloudflareConfigPath(): string;
|
||||
cloudflareCredsPath(): string;
|
||||
stripePublishableKey(): string;
|
||||
allowedFlares(): string[] | undefined;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user