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:
21
res/script.js
Normal file
21
res/script.js
Normal 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'));
|
||||
@@ -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();
|
||||
|
||||
@@ -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
25
src/data/live/base.ts
Normal 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;
|
||||
89
src/data/live/instances.ts
Normal file
89
src/data/live/instances.ts
Normal 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;
|
||||
8
src/data/live/presence.ts
Normal file
8
src/data/live/presence.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
class PresenceBase {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const Presence = new PresenceBase();
|
||||
export default Presence;
|
||||
@@ -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"});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -66,6 +66,7 @@ export const KeyGroups = {
|
||||
Platforms: "platforms",
|
||||
DisplayName: "displayname",
|
||||
},
|
||||
Operators: "operators",
|
||||
Users: {
|
||||
Root: "users",
|
||||
Profiles: "profiles",
|
||||
|
||||
@@ -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);
|
||||
|
||||
},
|
||||
|
||||
);
|
||||
24
src/routes/api/PlayerReporting.ts
Normal file
24
src/routes/api/PlayerReporting.ts
Normal 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}'`);
|
||||
},
|
||||
|
||||
);
|
||||
@@ -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,25 +58,23 @@ route.router.post("/token",
|
||||
time: "",
|
||||
ver: "",
|
||||
asid: "",
|
||||
platform_auth: "",
|
||||
platform_auth: ""
|
||||
}),
|
||||
|
||||
async (
|
||||
rq: express.Request<NoBody, NoBody, TokenRequestBody>,
|
||||
rs: express.Response<TokenResponseBody>,
|
||||
) => {
|
||||
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user