From 616f5dd619b0395b96e1c85e57686a04a8cf1528 Mon Sep 17 00:00:00 2001 From: zombieb Date: Mon, 24 Mar 2025 21:50:22 -0400 Subject: [PATCH] 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 --- res/script.js | 21 +++++++ src/apiutils.ts | 29 ++++++---- src/config.ts | 2 +- src/data/live/base.ts | 25 ++++++++ src/data/live/instances.ts | 89 +++++++++++++++++++++++++++++ src/data/live/presence.ts | 8 +++ src/data/profiles.ts | 22 +++++--- src/data/users.ts | 11 +--- src/db.ts | 1 + src/routes/account/account.ts | 14 +++++ src/routes/api/PlayerReporting.ts | 24 ++++++++ src/routes/auth/connect.ts | 94 ++++++++++++++++++++++++++----- 12 files changed, 298 insertions(+), 42 deletions(-) create mode 100644 res/script.js create mode 100644 src/data/live/base.ts create mode 100644 src/data/live/instances.ts create mode 100644 src/data/live/presence.ts create mode 100644 src/routes/api/PlayerReporting.ts diff --git a/res/script.js b/res/script.js new file mode 100644 index 0000000..2f8fc1d --- /dev/null +++ b/res/script.js @@ -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')); \ No newline at end of file diff --git a/src/apiutils.ts b/src/apiutils.ts index 9076934..4d2560a 100644 --- a/src/apiutils.ts +++ b/src/apiutils.ts @@ -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(); diff --git a/src/config.ts b/src/config.ts index 3a1270d..b3e4ece 100644 --- a/src/config.ts +++ b/src/config.ts @@ -88,7 +88,7 @@ export const defaultConfig: GalvanicConfiguration = { discord: null, auth: { secret: "CHANGE-ME-PLEASE", - timeout: 48, + timeout: 3, }, }; diff --git a/src/data/live/base.ts b/src/data/live/base.ts new file mode 100644 index 0000000..2c5b264 --- /dev/null +++ b/src/data/live/base.ts @@ -0,0 +1,25 @@ +import Profile from "../profiles.ts"; + +const loginLocks: Map = 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; \ No newline at end of file diff --git a/src/data/live/instances.ts b/src/data/live/instances.ts new file mode 100644 index 0000000..d02d4e4 --- /dev/null +++ b/src/data/live/instances.ts @@ -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> = new Map(); +const instanceMap: Map> = 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; \ No newline at end of file diff --git a/src/data/live/presence.ts b/src/data/live/presence.ts new file mode 100644 index 0000000..6cb83e3 --- /dev/null +++ b/src/data/live/presence.ts @@ -0,0 +1,8 @@ +class PresenceBase { + + + +} + +const Presence = new PresenceBase(); +export default Presence; \ No newline at end of file diff --git a/src/data/profiles.ts b/src/data/profiles.ts index 3214763..02ad23a 100644 --- a/src/data/profiles.ts +++ b/src/data/profiles.ts @@ -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"}); } } diff --git a/src/data/users.ts b/src/data/users.ts index fc87e0f..01c3fda 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -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() { diff --git a/src/db.ts b/src/db.ts index 7d78619..2f16967 100644 --- a/src/db.ts +++ b/src/db.ts @@ -66,6 +66,7 @@ export const KeyGroups = { Platforms: "platforms", DisplayName: "displayname", }, + Operators: "operators", Users: { Root: "users", Profiles: "profiles", diff --git a/src/routes/account/account.ts b/src/routes/account/account.ts index 55f387d..3367557 100644 --- a/src/routes/account/account.ts +++ b/src/routes/account/account.ts @@ -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); + + }, + +); \ No newline at end of file diff --git a/src/routes/api/PlayerReporting.ts b/src/routes/api/PlayerReporting.ts new file mode 100644 index 0000000..8b8772e --- /dev/null +++ b/src/routes/api/PlayerReporting.ts @@ -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({Message: ""}), + + (rq: express.Request, rs) => { + rs.sendStatus(204); + log.w(`Client sent hile report: '${rq.body.Message}'`); + }, + +); \ No newline at end of file diff --git a/src/routes/auth/connect.ts b/src/routes/auth/connect.ts index 34b0380..7bdee26 100644 --- a/src/routes/auth/connect.ts +++ b/src/routes/auth/connect.ts @@ -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({ + APIUtils.logBody, + APIUtils.checkBodyTypes({ grant_type: "", - account_id: "", client_id: "", client_secret: "", platform: "", @@ -42,25 +58,23 @@ route.router.post("/token", time: "", ver: "", asid: "", - platform_auth: "", + platform_auth: "" }), async ( rq: express.Request, rs: express.Response, ) => { - + function requestFailed(msg: string = "invalid_request") { rs.json({ error: msg, 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, + }); + } }, );