mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:40:46 +00:00
enforce clantag
This commit is contained in:
@@ -661,7 +661,8 @@
|
||||
"tag": "TAG",
|
||||
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
|
||||
"tag_too_long": "Clan tag cannot exceed 5 characters.",
|
||||
"tag_invalid_chars": "Clan tag can only contain letters and numbers."
|
||||
"tag_invalid_chars": "Clan tag can only contain letters and numbers.",
|
||||
"tag_not_member": "Join the {tag} clan before using its tag."
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Create Private Lobby",
|
||||
|
||||
@@ -125,6 +125,23 @@ export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight existence probe. Public endpoint, no auth required — used to
|
||||
// detect clan-tag ownership conflicts when a user types a tag into the input.
|
||||
// Returns null on unexpected statuses so callers can fail open.
|
||||
export async function fetchClanExists(tag: string): Promise<boolean | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getApiBase()}/public/clan/${encodeURIComponent(tag)}/exists`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (res.status === 200) return true;
|
||||
if (res.status === 404) return false;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ClanMemberSort =
|
||||
| "default"
|
||||
| "winsTotal"
|
||||
|
||||
+106
-11
@@ -10,8 +10,12 @@ import {
|
||||
validateClanTag,
|
||||
validateUsername,
|
||||
} from "../core/validations/username";
|
||||
import { getUserMe } from "./Api";
|
||||
import { fetchClanExists } from "./ClanApi";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
|
||||
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
|
||||
|
||||
interface LangSelectorLike {
|
||||
currentLang?: string;
|
||||
translations?: Record<string, string>;
|
||||
@@ -29,6 +33,11 @@ export class UsernameInput extends LitElement {
|
||||
@property({ type: String }) validationError: string = "";
|
||||
private _isValid: boolean = true;
|
||||
private _lastValidatedLang: string | null = null;
|
||||
private syncValidationError: string = "";
|
||||
private syncIsValid: boolean = true;
|
||||
private clanTagAsyncError: string = "";
|
||||
private clanTagCheckCounter: number = 0;
|
||||
private clanTagCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Remove static styles since we're using Tailwind
|
||||
|
||||
@@ -179,22 +188,108 @@ export class UsernameInput extends LitElement {
|
||||
|
||||
const clanTagResult = validateClanTag(this.clanTag);
|
||||
if (!clanTagResult.isValid) {
|
||||
this._isValid = false;
|
||||
this.validationError = clanTagResult.error ?? "";
|
||||
return;
|
||||
this.syncIsValid = false;
|
||||
this.syncValidationError = clanTagResult.error ?? "";
|
||||
} else {
|
||||
const result = validateUsername(trimmedBase);
|
||||
this.syncIsValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(usernameKey, trimmedBase);
|
||||
// clanTag is persisted by scheduleClanTagOwnershipCheck (or its async
|
||||
// continuation) so we never store a tag the server would reject.
|
||||
this.syncValidationError = "";
|
||||
} else {
|
||||
this.syncValidationError = result.error ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const result = validateUsername(trimmedBase);
|
||||
this._isValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(usernameKey, trimmedBase);
|
||||
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
|
||||
this.validationError = "";
|
||||
} else {
|
||||
this.validationError = result.error ?? "";
|
||||
this.scheduleClanTagOwnershipCheck();
|
||||
this.updateValidationState();
|
||||
}
|
||||
|
||||
private persistClanTag(tag: string) {
|
||||
if (this.syncIsValid) {
|
||||
localStorage.setItem(clanTagKey, tag);
|
||||
}
|
||||
}
|
||||
|
||||
private updateValidationState() {
|
||||
if (!this.syncIsValid) {
|
||||
this._isValid = false;
|
||||
this.validationError = this.syncValidationError;
|
||||
return;
|
||||
}
|
||||
if (this.clanTagAsyncError) {
|
||||
this._isValid = false;
|
||||
this.validationError = this.clanTagAsyncError;
|
||||
return;
|
||||
}
|
||||
this._isValid = true;
|
||||
this.validationError = "";
|
||||
}
|
||||
|
||||
private scheduleClanTagOwnershipCheck() {
|
||||
if (this.clanTagCheckTimer !== null) {
|
||||
clearTimeout(this.clanTagCheckTimer);
|
||||
this.clanTagCheckTimer = null;
|
||||
}
|
||||
const tag = this.clanTag;
|
||||
if (
|
||||
tag.length < MIN_CLAN_TAG_LENGTH ||
|
||||
tag.length > MAX_CLAN_TAG_LENGTH ||
|
||||
!validateClanTag(tag).isValid
|
||||
) {
|
||||
// Bump the counter so any in-flight check is discarded.
|
||||
this.clanTagCheckCounter++;
|
||||
if (this.clanTagAsyncError) {
|
||||
this.clanTagAsyncError = "";
|
||||
}
|
||||
// No async check needed — persist the (empty/short) value so clearing
|
||||
// the tag is remembered across reloads.
|
||||
this.persistClanTag(this.getClanTag() ?? "");
|
||||
return;
|
||||
}
|
||||
this.clanTagCheckTimer = setTimeout(() => {
|
||||
this.clanTagCheckTimer = null;
|
||||
void this.runClanTagOwnershipCheck(tag);
|
||||
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async runClanTagOwnershipCheck(expectedTag: string) {
|
||||
const checkId = ++this.clanTagCheckCounter;
|
||||
const stillCurrent = () =>
|
||||
checkId === this.clanTagCheckCounter && this.clanTag === expectedTag;
|
||||
|
||||
const me = await getUserMe();
|
||||
if (!stillCurrent()) return;
|
||||
if (me) {
|
||||
const myTags = (me.player.clans ?? []).map((c) => c.tag.toUpperCase());
|
||||
if (myTags.includes(expectedTag.toUpperCase())) {
|
||||
this.setClanTagAsyncError("");
|
||||
this.persistClanTag(expectedTag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const exists = await fetchClanExists(expectedTag);
|
||||
if (!stillCurrent()) return;
|
||||
if (exists === true) {
|
||||
this.setClanTagAsyncError(
|
||||
translateText("username.tag_not_member", { tag: expectedTag }),
|
||||
);
|
||||
} else {
|
||||
this.setClanTagAsyncError("");
|
||||
this.persistClanTag(expectedTag);
|
||||
}
|
||||
}
|
||||
|
||||
private setClanTagAsyncError(error: string) {
|
||||
if (this.clanTagAsyncError === error) return;
|
||||
this.clanTagAsyncError = error;
|
||||
this.updateValidationState();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return this._isValid;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class GameManager {
|
||||
persistentID: string,
|
||||
gameID: GameID,
|
||||
lastTurn: number = 0,
|
||||
identityUpdate?: { username: string; clanTag: string | null },
|
||||
identityUpdate?: { username: string; clanTag?: string | null },
|
||||
): boolean {
|
||||
const game = this.games.get(gameID);
|
||||
if (!game) return false;
|
||||
|
||||
@@ -286,7 +286,7 @@ export class GameServer {
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
lastTurn: number = 0,
|
||||
identityUpdate?: { username: string; clanTag: string | null },
|
||||
identityUpdate?: { username: string; clanTag?: string | null },
|
||||
): boolean {
|
||||
const clientID = this.getClientIdForPersistentId(persistentID);
|
||||
if (!clientID) return false;
|
||||
@@ -308,7 +308,12 @@ export class GameServer {
|
||||
this.activeClients.push(client);
|
||||
if (identityUpdate && !this.hasStarted()) {
|
||||
client.username = identityUpdate.username;
|
||||
client.clanTag = identityUpdate.clanTag;
|
||||
// clanTag is only updated when explicitly provided. The reconnect
|
||||
// fast-path omits it so a refreshed client can't swap to a tag the
|
||||
// initial join didn't validate.
|
||||
if (identityUpdate.clanTag !== undefined) {
|
||||
client.clanTag = identityUpdate.clanTag;
|
||||
}
|
||||
}
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
|
||||
+32
-7
@@ -21,7 +21,7 @@ import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { registerGamePreviewRoute } from "./GamePreviewRoute";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { clanExistsByTag, getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
@@ -357,19 +357,18 @@ export async function startWorker() {
|
||||
}
|
||||
|
||||
// Normalize username and clan tag before any rejoin/join handling.
|
||||
// If this connection maps to an existing lobby client, we still want
|
||||
// the latest pre-join identity to be reflected.
|
||||
const { clanTag: censoredClanTag, username: censoredUsername } =
|
||||
privilegeRefresher
|
||||
.get()
|
||||
.censor(clientMsg.username, clientMsg.clanTag ?? null);
|
||||
|
||||
// Try to reconnect an existing client (e.g., page refresh)
|
||||
// If successful, skip all authorization
|
||||
// Try to reconnect an existing client (e.g., page refresh).
|
||||
// Username may have changed since initial join; clanTag is intentionally
|
||||
// omitted so the reconnect can't swap to a tag that wasn't validated on
|
||||
// the original join. To change clan tag, the player must fully rejoin.
|
||||
if (
|
||||
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
|
||||
username: censoredUsername,
|
||||
clanTag: censoredClanTag,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -378,6 +377,7 @@ export async function startWorker() {
|
||||
let flares: string[] | undefined;
|
||||
let publicId: string | undefined;
|
||||
let friends: string[] = [];
|
||||
let userClanTags: Set<string> = new Set();
|
||||
|
||||
const allowedFlares = ServerEnv.allowedFlares();
|
||||
if (claims === null) {
|
||||
@@ -400,6 +400,11 @@ export async function startWorker() {
|
||||
flares = result.response.player.flares;
|
||||
publicId = result.response.player.publicId;
|
||||
friends = result.response.player.friends;
|
||||
userClanTags = new Set(
|
||||
(result.response.player.clans ?? []).map((c) =>
|
||||
c.tag.toUpperCase(),
|
||||
),
|
||||
);
|
||||
|
||||
if (allowedFlares !== undefined) {
|
||||
const allowed =
|
||||
@@ -454,6 +459,26 @@ export async function startWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce clan tag ownership. A player can wear a tag only if they're
|
||||
// a member; if they're not and the tag belongs to a real clan, drop it
|
||||
// to prevent impersonation. Fictional tags pass through. Runs after
|
||||
// turnstile so we don't burn an API call on rejected bot joins.
|
||||
let resolvedClanTag = censoredClanTag;
|
||||
if (
|
||||
resolvedClanTag !== null &&
|
||||
!userClanTags.has(resolvedClanTag.toUpperCase())
|
||||
) {
|
||||
const exists = await clanExistsByTag(resolvedClanTag);
|
||||
if (exists === true) {
|
||||
log.warn("Dropped clan tag: player is not a member", {
|
||||
persistentID: persistentId,
|
||||
gameID: clientMsg.gameID,
|
||||
clanTag: resolvedClanTag,
|
||||
});
|
||||
resolvedClanTag = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create client and add to game
|
||||
const client = new Client(
|
||||
generateID(),
|
||||
@@ -463,7 +488,7 @@ export async function startWorker() {
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
censoredClanTag,
|
||||
resolvedClanTag,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
publicId,
|
||||
|
||||
@@ -98,3 +98,18 @@ export async function getUserMe(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort check: does a clan with this tag exist?
|
||||
// Returns null on transport errors or unexpected statuses so callers can
|
||||
// fail open — the goal is impersonation prevention, not availability blocker.
|
||||
export async function clanExistsByTag(tag: string): Promise<boolean | null> {
|
||||
try {
|
||||
const url = `${ServerEnv.jwtIssuer()}/public/clan/${encodeURIComponent(tag)}/exists`;
|
||||
const response = await fetch(url);
|
||||
if (response.status === 200) return true;
|
||||
if (response.status === 404) return false;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user