Basic live service:

- Matchmaking
- Instance
- Presence (albeit empty atm)

Authentication fixes; differentiate between user and profile
Default auth timeout is now 3 hours
Add "operators" database key ("all users with operator permissions", or "developer" role set in token), add check in `Profile`
Fix default profile image filename reference when not set
account/me
Log hile reporting, do stuff with the report later ("Server" user for commands, operators can check reports)
Refresh login done by client automatically when token expires, requires extra work
This commit is contained in:
2025-03-24 21:50:22 -04:00
parent 31f9cfdc1a
commit 616f5dd619
12 changed files with 298 additions and 42 deletions

21
res/script.js Normal file
View File

@@ -0,0 +1,21 @@
import * as fs from "node:fs";
const rooms = JSON.parse(fs.readFileSync('./rooms.json'));
const lines = [
"enum IntegratedRoomScene {"
];
for (const room of rooms) {
if (room.Scenes[0].Name == "Home") {
lines.push(` ${room.Name} = "${room.Scenes[0].RoomSceneLocationId}",`);
} else {
for (const scene of room.Scenes) {
lines.push(` ${room.Name}_${scene.Name} = "${scene.RoomSceneLocationId}",`);
}
}
}
lines.push("}");
fs.writeFileSync('./enum.ts', lines.join('\n'));

View File

@@ -206,9 +206,7 @@ export class RateLimiter {
export interface TokenBaseFormat {
typ: AuthType;
iss: string;
nbf: number;
exp: number;
iat: number;
}
export type TokenFormat = UserTokenFormat | ProfileTokenFormat;
@@ -222,8 +220,21 @@ export async function Authentication(
rs.json(genericResponseFormat(true, "Authorization required."));
}
const token: string | undefined = rq.header("GalvanicAuth");
if (typeof token == "undefined") {
const userToken: string | undefined = rq.header("GalvanicAuth");
const profileToken: string | undefined = rq.header("Authorization");
let token: string;
if (typeof userToken == "undefined" && typeof profileToken == "undefined") {
returnUnauthorized();
return;
} else if (typeof userToken == 'string') token = userToken;
else if (typeof profileToken == 'string') {
const splitToken = profileToken.split(' ');
if (splitToken.length >= 2) token = splitToken[1];
else {
returnUnauthorized();
return;
}
} else {
returnUnauthorized();
return;
}
@@ -239,15 +250,11 @@ export async function Authentication(
const valid = ![
decodedToken.iss == config.web.publichost,
decodedToken.nbf < Math.round(Date.now() / 1000),
decodedToken.exp > Math.round(Date.now() / 1000),
].includes(false);
if (valid) {
if (decodedToken.typ == AuthType.Web) {
rs.locals.user = new User(decodedToken.sub);
} else if (decodedToken.typ == AuthType.Game) {
rs.locals.profile = new Profile(decodedToken.sub);
}
if (decodedToken.typ == AuthType.Web) rs.locals.user = new User(decodedToken.sub);
else if (decodedToken.typ == AuthType.Game) rs.locals.profile = new Profile(decodedToken.sub);
nxt();
} else {
returnUnauthorized();

View File

@@ -88,7 +88,7 @@ export const defaultConfig: GalvanicConfiguration = {
discord: null,
auth: {
secret: "CHANGE-ME-PLEASE",
timeout: 48,
timeout: 3,
},
};

25
src/data/live/base.ts Normal file
View File

@@ -0,0 +1,25 @@
import Profile from "../profiles.ts";
const loginLocks: Map<number, string> = new Map();
class MatchmakingBase {
createLoginLock(prof: Profile, lock: string) {
if (loginLocks.has(prof.getId())) return;
else loginLocks.set(prof.getId(), lock);
}
lockMatches(prof: Profile, lock: string) {
const maybeLock = loginLocks.get(prof.getId());
if (maybeLock) return maybeLock == lock;
else return false;
}
deleteLoginLock(prof: Profile) {
loginLocks.delete(prof.getId());
}
}
const Matchmaking = new MatchmakingBase();
export default Matchmaking;

View File

@@ -0,0 +1,89 @@
import Profile from "../profiles.ts";
enum IntegratedRoomScene {
Calibration = "f5fbd9c9-e853-4036-9d48-5f68e861af04",
DormRoom = "76d98498-60a1-430c-ab76-b54a29b7a163",
RecCenter = "cbad71af-0831-44d8-b8ef-69edafa841f6",
ThreeDCharades = "4078dfed-24bb-4db7-863f-578ba48d726b",
DiscGolfLake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
DiscGolfPropulsion = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
Dodgeball = "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
Paddleball = "d89f74fa-d51e-477a-a425-025a891dd499",
Paintball_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
Paintball_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7",
Paintball_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827",
Paintball_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161",
Paintball_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709",
PaintballVR_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
PaintballVR_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7",
PaintballVR_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827",
PaintballVR_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161",
PaintballVR_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709",
GoldenTrophy = "91e16e35-f48f-4700-ab8a-a1b79e50e51b",
TheRiseofJumbotron = "acc06e66-c2d0-4361-b0cd-46246a4c455c",
CrimsonCauldron = "949fa41f-4347-45c0-b7ac-489129174045",
IsleOfLostSkulls = "7e01cfe0-820a-406f-b1b3-0a5bf575235c",
Soccer = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
LaserTagHangar = "239e676c-f12f-489f-bf3a-d4c383d692c3",
LaserTagCyberJunk = "9d6456ce-6264-48b4-808d-2d96b3d91038",
RecRoyaleSquads = "253fa009-6e65-4c90-91a1-7137a56a267f",
RecRoyaleVR = "253fa009-6e65-4c90-91a1-7137a56a267f",
RecRoyaleSolos = "b010171f-4875-4e89-baba-61e878cd41e1",
Lounge = "a067557f-ca32-43e6-b6e5-daaec60b4f5a",
PerformanceHall = "9932f88f-3929-43a0-a012-a40b5128e346",
MakerRoom = "a75f7547-79eb-47c6-8986-6767abcb4f92",
Park = "0a864c86-5a71-4e18-8041-8124e4dc9d98",
ArtTesting = "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79",
River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0",
Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7",
Quarry = "ff4c6427-7079-4f59-b22a-69b089420827",
Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161",
Spillway = "58763055-2dfb-4814-80b8-16fac5c85709",
Lake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0",
PropulsionTestRange = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55",
Gym = "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
Stadium = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03",
Hangar = "239e676c-f12f-489f-bf3a-d4c383d692c3",
CyberJunkCity = "9d6456ce-6264-48b4-808d-2d96b3d91038",
DodgeballVR = "3d474b26-26f7-45e9-9a36-9b02847d5e6f",
}
interface RoomInstance {
roomInstanceId: number,
roomId: number,
subRoomId: number,
location: IntegratedRoomScene,
dataBlob?: string,
eventId?: number,
photonRegionId: "us",
photonRoomId: string,
name?: string,
maxCapacity: number,
isFull: boolean,
isPrivate: boolean,
isInProgress: boolean
}
const instancePlayers: Map<RoomInstance, Set<Profile>> = new Map();
const instanceMap: Map<number, Set<RoomInstance>> = new Map();
class InstancesBase {
async #ensureSubSet(roomId: number) {
const subMap = instanceMap.get(roomId);
if (!subMap) instanceMap.set(roomId, new Set());
}
// get all instances
// get all instances for a room
// do not put instance categorization here (instance searching, or "matchmaking"); put that in MatchmakingBase
// add, remove, check for, get profile(s) in instances
// synchronize profile.#instance with the profile's current instance (profile.setInstance(RoomInstance)?)
}
const Instances = new InstancesBase();
export default Instances;

View File

@@ -0,0 +1,8 @@
class PresenceBase {
}
const Presence = new PresenceBase();
export default Presence;

View File

@@ -4,9 +4,12 @@ import { Config } from "../config.ts";
import { AuthType } from "./users.ts";
import * as JsonWebToken from "@gz/jwt";
import { TokenBaseFormat } from "../apiutils.ts";
import Logging from "@proxnet/undead-logging";
const config = Config.getConfig();
const log = new Logging("Profiles");
interface ProfileInitOptions {
username: string;
}
@@ -20,6 +23,7 @@ interface AccountExport {
}
export interface ProfileTokenFormat extends TokenBaseFormat {
sub: number;
role: "developer" | "user";
typ: AuthType.Game;
}
@@ -152,7 +156,7 @@ class Profile {
resolve({
accountId: id,
profileImage: values[0] == null
? "DefaultProfileImage"
? "DefaultProfileImage.png"
: values[0],
isJunior: values[1] == null
? false
@@ -192,6 +196,10 @@ class Profile {
return this.#id;
}
async getIsDev() {
return (await Redis.Database.sismember(Redis.buildKey(Redis.KeyGroups.Operators), this.#id.toString())) >= 1;
}
async export() {
return await Profile.getExportAccount(this.#id);
}
@@ -200,15 +208,11 @@ class Profile {
const payload: ProfileTokenFormat = {
iss: config.web.publichost,
sub: this.#id,
nbf: Math.round(Date.now() / 1000) - 200,
iat: Math.round(Date.now() / 1000),
exp: Math.round(Date.now() / 1000) +
(config.auth.timeout * 60 * 60),
typ: AuthType.Game,
role: (await this.getIsDev()) ? 'developer' : 'user',
exp: Math.round(Date.now() / 1000) + Math.round(config.auth.timeout * 60 * 60),
typ: AuthType.Game
};
return await JsonWebToken.encode(payload, config.auth.secret, {
algorithm: "HS512",
});
return await JsonWebToken.encode(payload, config.auth.secret, {algorithm: "HS512"});
}
}

View File

@@ -80,15 +80,10 @@ export class User {
const payload: UserTokenFormat = {
iss: config.web.publichost,
sub: this.#client_id,
nbf: Math.round(Date.now() / 1000) - 200,
iat: Math.round(Date.now() / 1000),
exp: Math.round(Date.now() / 1000) +
(config.auth.timeout * 60 * 60),
typ: AuthType.Web,
exp: Math.round(Date.now() / 1000) + (config.auth.timeout * 60 * 60),
typ: AuthType.Web
};
return await JsonWebToken.encode(payload, config.auth.secret, {
algorithm: "HS512",
});
return await JsonWebToken.encode(payload, config.auth.secret, {algorithm: "HS512"});
}
async exportAssociatedProfiles() {

View File

@@ -66,6 +66,7 @@ export const KeyGroups = {
Platforms: "platforms",
DisplayName: "displayname",
},
Operators: "operators",
Users: {
Root: "users",
Profiles: "profiles",

View File

@@ -70,3 +70,17 @@ route.router.get("/bulk",
},
);
route.router.get("/me",
APIUtils.Authentication,
async (_rq, rs) => {
const exportAccount = await rs.locals.profile.export();
if (exportAccount == null) rs.sendStatus(500);
else rs.json(exportAccount);
},
);

View File

@@ -0,0 +1,24 @@
import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import Logging from "@proxnet/undead-logging";
const log = new Logging("PlayerReportingRoute");
export const route = APIUtils.createRouter("/PlayerReporting");
interface HileMessage {
Message: string;
}
route.router.post('/v1/hile',
APIUtils.Authentication,
express.json(),
APIUtils.checkBodyTypes<HileMessage>({Message: ""}),
(rq: express.Request<NoBody, NoBody, HileMessage>, rs) => {
rs.sendStatus(204);
log.w(`Client sent hile report: '${rq.body.Message}'`);
},
);

View File

@@ -1,12 +1,18 @@
import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import Profile from "../../data/profiles.ts";
import { decode } from "@gz/jwt";
import { Config } from "../../config.ts";
import Logging from "@proxnet/undead-logging";
const config = Config.getConfig();
const log = new Logging("AuthConnectRoute");
export const route = APIUtils.createRouter("/connect");
interface TokenRequestBody {
interface AuthBodyBase {
grant_type: string;
account_id: string;
client_id: string;
client_secret: string;
platform: string;
@@ -18,6 +24,16 @@ interface TokenRequestBody {
asid: string;
platform_auth: string;
}
interface TokenRequest extends AuthBodyBase {
account_id: string;
grant_type: "cached_login"
}
interface RefreshRequest extends AuthBodyBase {
refresh_token: string,
grant_type: "refresh_token"
}
type TokenRequestBody = TokenRequest | RefreshRequest;
interface TokenResponseBody {
error?: string;
@@ -30,9 +46,9 @@ route.router.post("/token",
APIUtils.Authentication,
express.urlencoded({ extended: true }),
APIUtils.checkBodyTypes<TokenRequestBody>({
APIUtils.logBody,
APIUtils.checkBodyTypes<AuthBodyBase>({
grant_type: "",
account_id: "",
client_id: "",
client_secret: "",
platform: "",
@@ -42,7 +58,7 @@ route.router.post("/token",
time: "",
ver: "",
asid: "",
platform_auth: "",
platform_auth: ""
}),
async (
@@ -56,11 +72,9 @@ route.router.post("/token",
access_token: "",
refresh_token: "",
});
return;
}
const conditionsMet = ![
rq.body.grant_type == "cached_login",
rq.body.client_id == "recroom",
rq.body.platform == "0",
rq.body.ver == '20191120',
@@ -71,24 +85,78 @@ route.router.post("/token",
!(rq.body.asid.length > 32),
].includes(false);
if (conditionsMet) {
if (!conditionsMet) {
requestFailed();
return;
}
if (rq.body.grant_type == 'cached_login') {
const accounts = await rs.locals.user.getAssociatedProfiles();
const targetAccount = parseInt(rq.body.account_id);
if (isNaN(targetAccount)) requestFailed();
if (!accounts.has(targetAccount)) requestFailed("access_denied");
if (isNaN(targetAccount)) {
requestFailed();
return;
}
if (!accounts.has(targetAccount)) {
requestFailed("access_denied");
return;
}
rs.locals.user.addAssociatedDeviceId(rq.body.device_id);
rs.locals.user.addAssociatedPlatformId(rq.body.platform_id);
const profile = new Profile(targetAccount);
if (!(await Profile.exists(profile.getId()))) requestFailed();
if (!(await Profile.exists(profile.getId()))) {
requestFailed();
return;
}
const token = await profile.getToken();
rs.json({
access_token: token,
refresh_token: token,
});
} else requestFailed();
} else {
const refreshToken = rq.body.refresh_token;
if (typeof refreshToken == 'undefined') {
requestFailed();
return;
}
let decodedToken;
try {
decodedToken = await decode(rq.body.refresh_token, config.auth.secret, { algorithm: "HS512" });
} catch (err) {
log.w(`Refresh token decode failed: ${err}`);
requestFailed();
return;
}
const accounts = await rs.locals.user.getAssociatedProfiles();
const targetAccount = parseInt(decodedToken.sub ? decodedToken.sub : "NaN");
if (isNaN(targetAccount)) {
requestFailed();
return;
}
if (!accounts.has(targetAccount)) {
requestFailed("access_denied");
return;
}
rs.locals.user.addAssociatedDeviceId(rq.body.device_id);
rs.locals.user.addAssociatedPlatformId(rq.body.platform_id);
const profile = new Profile(targetAccount);
if (!(await Profile.exists(profile.getId()))) {
requestFailed();
return;
}
const token = await profile.getToken();
rs.json({
access_token: token,
refresh_token: token,
});
}
},
);