Removed web project (galvanic authentication support in IL2CPP universal patch)

Moved instance ID to header
User instances for profile management
.. other stuff
This commit is contained in:
2025-03-22 21:57:45 -04:00
parent 73e9b72ad4
commit 6cdd0946f4
42 changed files with 663 additions and 3833 deletions

View File

@@ -1,6 +1,11 @@
// @ts-types = "npm:@types/express"
import express from "express";
import Logging from "@proxnet/undead-logging";
import { decode } from "@gz/jwt";
import { Config } from "./config.ts";
import { AuthType, User, UserTokenFormat } from "./data/users.ts";
const config = Config.getConfig();
const log = new Logging('APIUtils');
@@ -29,8 +34,6 @@ export function generateRandomString(length: number) {
return randomString;
}
const instanceId = generateRandomString(128);
export function checkQueryTypes<T>(typeDef: T) {
return (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
for (const key in typeDef) {
@@ -58,11 +61,11 @@ export function checkBodyTypes<T>(typeDef: T) {
}
export function genericResponseFormat(failure: boolean, msg: string | null = null, data: object | null = null) {
return { failed: failure, instance: instanceId, message: msg, data: data };
return { failed: failure, message: msg, data: data };
}
export function genericResponse(failure: boolean, msg: string | null = null, data: object | null = null) {
return (_rq: express.Request, rs: express.Response) => {
rs.json({ failed: failure, instance: instanceId, message: msg, data: data });
rs.json({ failed: failure, message: msg, data: data });
};
}
type RecNetResponse = {
@@ -165,4 +168,45 @@ export class RateLimiter {
}
export async function UserAuthentication(rq: express.Request, rs: express.Response, nxt: express.NextFunction) {
function returnUnauthorized() {
rs.statusCode = 401;
rs.json(genericResponseFormat(true, 'Authorization required.'));
}
const token: string | undefined = rq.header('GalvanicAuth');
if (typeof token == 'undefined') {
returnUnauthorized();
return;
}
try {
const decodedToken = await decode<UserTokenFormat>(token, config.auth.secret, { algorithm: "HS512" });
const valid = ![
decodedToken.iss == config.web.publichost,
decodedToken.nbf < Math.round(Date.now() / 1000),
decodedToken.exp > Math.round(Date.now() / 1000),
decodedToken.typ == AuthType.Web
].includes(false);
if (valid) {
rs.locals.user = new User(decodedToken.sub);
nxt();
}
else {
returnUnauthorized();
return;
}
} catch (err) {
returnUnauthorized();
log.w(`User Authentication failed: ${err}`);
}
}
export type NoBody = Record<string | number | symbol, never>
export * as APIUtils from "./apiutils.ts"

View File

@@ -14,19 +14,22 @@ type RedisConfiguration = {
type WebConfiguration = {
port: number,
host: string,
nameserverHost: string,
secureNameserverHost: boolean
publichost: string,
securepublichost: boolean
}
type PublicConfiguration = {
serverName: string,
serverId: string,
owner: string,
motd: string,
levelScale: number,
maxLevels: number
patches: string[],
}
type LoggingConfiguration = {
notfound: boolean,
debug: boolean,
network: boolean
}
@@ -37,13 +40,12 @@ type DiscordConfiguration = {
guildId: string
}
type SecretConfiguration = {
authSecret: string
}
type RecaptchaConfiguration = {
sitekey: string,
secret: string
type AuthConfiguration = {
secret: string,
/**
* In Hours
*/
timeout: number
}
export type GalvanicConfiguration = {
@@ -52,8 +54,7 @@ export type GalvanicConfiguration = {
public: PublicConfiguration,
logging: LoggingConfiguration,
discord: DiscordConfiguration | null,
secrets: SecretConfiguration,
recaptcha: RecaptchaConfiguration | null
auth: AuthConfiguration
}
export const defaultConfig: GalvanicConfiguration = {
@@ -67,29 +68,32 @@ export const defaultConfig: GalvanicConfiguration = {
web: {
port: 3000,
host: "127.0.0.1",
nameserverHost: "127.0.0.1:3000",
secureNameserverHost: false
publichost: "127.0.0.1:3000",
securepublichost: false,
},
public: {
serverName: "Galvanic Corrosion",
serverId: "galvanic-corrosion-default",
owner: "John Doe",
motd: "The narwhal bacons at midnight",
motd: "A Galvanic Corrosion server.",
levelScale: 1,
maxLevels: 30
maxLevels: 30,
patches: [],
},
logging: {
notfound: false,
debug: false,
network: false
},
discord: null,
secrets: {
authSecret: "CHANGE-ME-PLEASE"
},
recaptcha: null
auth: {
secret: "CHANGE-ME-PLEASE",
timeout: 48
}
}
/** The current configuration. Read and parsed only during startup. */
let config: GalvanicConfiguration | undefined;
let config: GalvanicConfiguration;
try {
if (!configurationExists()) generateDefaultConfig();
config = JSON.parse(fs.readFileSync('./config.json').toString());
@@ -113,4 +117,6 @@ export function getConfig() {
return config;
}
export const devMode = Deno.args.includes('--dev');
export * as Config from './config.ts';

View File

@@ -1,48 +0,0 @@
import { encode, decode } from "@gz/jwt";
import { Config, GalvanicConfiguration } from "../config.ts";
import Logging from "@proxnet/undead-logging";
const log = new Logging("Auth");
const config = Config.getConfig() as GalvanicConfiguration;
type TokenFormat = {
iss: string;
sub: number;
nbf: number;
iat: number;
exp: number;
}
export class GameAuthContext {
valid: boolean | null = null;
#rawToken: string
playerId: number | null = null;
constructor(token: string) {
this.#rawToken = token;
}
async decode() {
try {
const decoded = await decode(this.#rawToken, config.secrets.authSecret) as TokenFormat;
this.playerId = decoded.sub || null;
const now = Math.round(Date.now() / 1000);
this.valid = true;
if (decoded.exp < now) this.valid = false;
if (decoded.nbf > now) this.valid = false;
} catch (e) {
this.valid = false;
log.w(`Token decode failed: ${(e as Error).stack}`);
}
}
}
export * as Authentication from "./auth.ts";

View File

@@ -59,7 +59,7 @@ export async function getAllGameConfigs() {
}
export function setGameConfig(key: string, value: string) {
return Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game));
return Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game), key, value);
}
export function getGameConfig(key: string) {
return Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game), key);

128
src/data/profiles.ts Normal file
View File

@@ -0,0 +1,128 @@
import { Redis } from "../db.ts";
import Dictionary from "./usernames.ts";
import { Config } from "../config.ts";
import { AuthType } from "./users.ts";
import * as JsonWebToken from "@gz/jwt";
const config = Config.getConfig();
interface ProfileInitOptions {
username: string
}
interface AccountExport {
accountId: number,
profileImage: string,
isJunior: boolean,
platforms: number,
username: string,
displayName: string
}
export type ProfileTokenFormat = {
iss: string;
sub: number;
nbf: number;
iat: number;
exp: number;
typ: AuthType;
}
class Profile {
static async getUniqueId() {
let id = Math.round(Math.random() * Math.pow(2, 31));
while ((await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Username))) >= 1)
id = await this.getUniqueId();
return id;
}
static async byName(name: string) {
const id = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name));
if (id == null) return null;
else return new Profile(parseInt(id, 10));
}
static async getUniqueUsername() {
let username = `${Dictionary.Adjectives[Math.floor(Math.random() * Dictionary.Adjectives.length)]}${Dictionary.Nouns[Math.floor(Math.random() * Dictionary.Nouns.length)]}${Math.round(Math.random() * 10000)}`
while ((await Profile.byName(username)) !== null) username = await this.getUniqueUsername();
return username;
}
static async init(options?: ProfileInitOptions) {
const optionsSpecified = typeof options !== 'undefined';
const newId = await this.getUniqueId();
const newUsername = optionsSpecified ? options.username : await this.getUniqueUsername();
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profile_Usernames, newUsername), newId);
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, newId.toString(), Redis.KeyGroups.Profiles.Username), newUsername);
return new Profile(newId);
}
// surely this can be written better
static getExportAccount(id: number): Promise<AccountExport | null> {
return new Promise((resolve, _reject) => {
Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Username)).then(val => {
if (val == null) resolve(null);
else {
const promises = {
profileImage: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.ProfileImage)),
isJunior: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Junior)),
platforms: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Platforms)),
displayName: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.DisplayName)),
username: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Username)),
}
Promise.all(Object.values(promises)).then((values) => {
resolve({
accountId: id,
profileImage: values[0] == null ? "DefaultProfileImage" : values[0],
isJunior: values[1] == null ? false : JSON.parse(values[1]),
platforms: values[2] == null ? 1 : JSON.parse(values[2]),
displayName: values[3] == null ? (values[4] == null ? "DATABASEERROR" : values[4]) : values[3],
username: values[4] == null ? "DATABASEERROR" : values[4],
});
});
}
});
});
}
static async getExportAccountsBulk(ids: number[]) {
const accs = await Promise.all(ids.map(val => this.getExportAccount(val)));
return accs.filter(val => val !== null);
}
#id: number;
constructor(id: number) {
this.#id = id;
}
getId() {
return this.#id;
}
async export() {
return await Profile.getExportAccount(this.#id);
}
async getToken() {
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.Web
};
return await JsonWebToken.encode(payload, config.auth.secret, { algorithm: "HS512" });
}
}
export default Profile;

View File

@@ -1,45 +0,0 @@
import Logging from "@proxnet/undead-logging";
import { Config } from "../config.ts";
const log = new Logging("ReCAPTCHA");
type SiteVerifyParams = {
secret: string,
response: string,
remoteip?: string
}
type SiteVerifyResponse = {
success: boolean,
challenge_ts: string,
hostname: string,
"error-codes": string[]
}
class ReCAPTCHABase {
async siteVerify(response: string, remoteip?: string) {
const config = Config.getConfig();
if (typeof config == 'undefined') return null;
if (config.recaptcha == null) {
log.e("Tried to verify ReCAPTCHA, but the config is null!");
return null;
}
const body: SiteVerifyParams = {
secret: config.recaptcha.secret,
response: response,
remoteip: remoteip
}
const res = await fetch('https://google.com/recaptcha/api/siteverify', {
method: "POST",
body: JSON.stringify(body)
});
const resBody = await res.json() as SiteVerifyResponse;
return resBody.success
}
}
const Recaptcha = new ReCAPTCHABase();
export default Recaptcha;

6
src/data/usernames.ts Normal file
View File

@@ -0,0 +1,6 @@
const Dictionary = {
Adjectives: ["Amazing","Adventurous","Affable","Agreeable","Ambitious","Amicable","Animated","Approachable","Articulate","Astute","Attractive","Authentic","Benevolent","Blissful","Bold","Bright","Buoyant","Calm","Captivating","Charismatic","Cheerful","Clever","Compassionate","Confident","Considerate","Content","Cooperative","Courageous","Creative","Cultured","Curious","Dashing","Dazzling","Dedicated","Delightful","Dependable","Determined","Diligent","Dynamic","Earnest","Easygoing","Ebullient","Effervescent","Empathetic","Enchanting","Endearing","Energetic","Engaging","Enthusiastic","Exuberant","Fantastic","Fearless","Fervent","Friendly","Funny","Generous","Gentle","Genuine","Gracious","Grateful","Harmonious","Heartwarming","Helpful","Honest","Humble","Humorous","Imaginative","Impeccable","Incisive","Incredible","Independent","Industrious","Ingenious","Insightful","Intelligent","Intuitive","Invigorating","Jovial","Jubilant","Just","Kind","Knowledgeable","Likable","Lively","Lovable","Loving","Loyal","Luminous","Magnetic","Marvelous","Masterful","Mature","Merciful","Methodical","Meticulous","Mindful","Motivated","Natural","Nurturing","Observant","Optimistic","Outgoing","Passionate","Patient","Peaceful","Perceptive","Perseverant","Persistent","Persuasive","Personable","Philanthropic","Placid","Playful","Pleasant","Poised","Positive","Powerful","Pragmatic","Proactive","Proficient","Prudent","Punctual","Purposeful","Radiant","Rational","Real","Receptive","Reflective","Reliable","Resilient","Resourceful","Respectful","Responsible","Robust","Sagacious","Serene","Sincere","Skillful","Smart","Sociable","Spirited","Splendid","Spontaneous","Steady","Sterling","Strong","Sublime","Successful","Supportive","Sympathetic","Talented","Tenacious","Thoughtful","Tireless","Tolerant","Tough","Tranquil","Trustworthy","Unassuming","Understanding","Unique","Unpretentious","Upbeat","Valiant","Vibrant","Virtuous","Visionary","Vivacious","Warmhearted","Welcoming","Wise","Witty","Wonderful","Zealous"],
Nouns: ["Nomad","Solstice","Elysium","Horizon","Catalyst","Luminescence","Utopia","Eclipse","Nebula","Arcadia","Apex","Harmony","Zenith","Radiant","Infinity","Echo","Quasar","Cascade","Empyrean","Nebula","Odyssey","Aether","Empower","Zephyr","Vibrance","Astral","Jubilant","Ascendancy","Zen","Nebulous","Ecliptic","Stellar","Quantum","Ethereal","Nexus","Synergy","Quantum","Enigma","Luminous","Epoch","Serendipity","Zenithal","Paragon","Panorama","Maverick","Voyager","Luminary","Catalyst","Phoenix","Dynamo","Zenith","Nexus","Pinnacle","Rhapsody","Serenity","Quantum","Apex","Harmony","Odyssey","Endeavor","Visionary","Epoch","Renaissance","Panache","Jubilee","Resonance","Zen","Nimbus","Ethereal","Cascade","Radiance","Synchronicity","Nebula","Equinox","Pulsar","Apex","Ethos","Wanderlust","Zenith","Nebula","Vertex","Equinox","Odyssey","Pantheon","Elysian","Nebulous","Quantum","Harmonic","Luminance","Paragon","Radiant","Epoch","Vortex","Celestia","Infinitum","Empyrean","Zephyr","Nimbus","Seraphic","Enigma","Synergy","Ecliptic","Utopian","Phoenix","Catalyst","Euphoria","Astral","Nebula","Ethereal","Zenith","Nexus","Empower","Panorama","Cascade","Quantum","Jubilant","Zen","Radiance","Labyrinth"]
}
export default Dictionary;

View File

@@ -1,101 +1,106 @@
import * as bcrypt from "bcrypt";
import { Redis } from "../db.ts";
import Logging from "@proxnet/undead-logging";
const log = new Logging("UserConstruct");
import * as JsonWebToken from "@gz/jwt";
import { Config } from "../config.ts";
import Profile from "./profiles.ts";
type UserInitOptions = {
username: string,
password: string,
client_id: string,
pubkey: string
}
type UserCreatedObj = {
user: User,
backupcode: string
user: User
}
function randomASCII() {
const codes = crypto.getRandomValues(new Uint8Array(512));
const filteredCodes = codes.filter(val => (val >= 48 && val <= 57) || (val >= 65 && val <= 90) || (val >= 97 && val <= 122) );
let str = String.fromCharCode(...filteredCodes);
if (str.length < 32) str = randomASCII();
return str.substring(0, 32);
export enum AuthType {
Game,
Web
}
export type UserTokenFormat = {
iss: string;
sub: string;
nbf: number;
iat: number;
exp: number;
typ: AuthType;
}
const config = Config.getConfig();
export class User {
static async exists(username: string) {
return (await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Usernames, username))) == 1;
static async exists(id: string) {
return (await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Users.Root, id, Redis.KeyGroups.Users.Pubkey))) == 1;
}
/**
* Create a user
* @returns A `UserCreatedObj` with a reference to the new user if one was created, else `null` if the username already exists.
* @returns A `User` if one was created, else `null` if the `client_id` already exists.
*/
static async init(options: UserInitOptions) {
if (await User.exists(options.username)) return null;
if (await User.exists(options.client_id)) return null;
const uuid = crypto.randomUUID();
const backup = randomASCII();
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Usernames, options.username), uuid);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.Username), options.username);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.BackupCode), backup);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.Password), await bcrypt.hash(options.password));
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, options.client_id, Redis.KeyGroups.Users.Pubkey), options.pubkey);
const user = new User(uuid);
const res: UserCreatedObj = {
user: user,
backupcode: backup
}
return res;
const user = new User(options.client_id);
return user;
}
/**
* Get a User by their username
* @returns A `User` is one was found, else `null`
*/
static async byName(username: string) {
const uuid = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Usernames, username));
if (uuid == null) return null;
else return new User(uuid);
}
#client_id: string;
#uuid: string;
constructor(uuid: string) {
this.#uuid = uuid;
constructor(client_id: string) {
this.#client_id = client_id;
}
getUuid() {
return this.#uuid;
return this.#client_id;
}
async validatePassword(password: string) {
const hash = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Password));
if (hash == null) throw new Error(`Hash for user ${this.#uuid} was not found`);
return await bcrypt.compare(password, hash);
async exists() {
return (await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Pubkey))) >= 1;
}
async setPassword(password: string) {
const hash = await bcrypt.hash(password);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Password), hash);
async getToken() {
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
};
return await JsonWebToken.encode(payload, config.auth.secret, {algorithm: "HS512"});
}
async getBackupCode() {
return await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.BackupCode));
async exportAssociatedProfiles() {
const profiles = await this.getAssociatedProfiles();
return await Profile.getExportAccountsBulk(profiles.values().toArray());
}
async getAssociatedProfiles() {
const list = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles));
const list = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Profiles));
return new Set<number>(list.filter(val => !Number.isNaN(parseInt(val, 10))).map(val => parseInt(val, 10)));
}
async removeAssociatedProfile(id: number) {
await Redis.Database.srem(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles), id);
await Redis.Database.srem(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Profiles), id);
}
async addAssociatedProfile(id: number) {
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles), id);
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Profiles), id);
}
async addAssociatedPlatformId(id: string) {
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.PlatformIds), id);
}
async addNonce(str: string) {
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Nonces), str);
}
async hasNonce(str: string) {
return (await Redis.Database.sismember(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#client_id, Redis.KeyGroups.Users.Nonces), str)) >= 1;
}
}

View File

@@ -20,11 +20,11 @@ Deno.addSignalListener('SIGINT', () => {
});
export const Database = new Redis({
port: config?.redis.port,
host: config?.redis.host,
username: config?.redis.username == "" ? undefined : config?.redis.username,
password: config?.redis.password == "" ? undefined : config?.redis.password,
db: config?.redis.db,
port: config.redis.port,
host: config.redis.host,
username: config.redis.username == "" ? undefined : config.redis.username,
password: config.redis.password == "" ? undefined : config.redis.password,
db: config.redis.db,
lazyConnect: true
});
Database.on('connect', async () => {
@@ -35,7 +35,7 @@ Database.on('connect', async () => {
});
});
Database.on('connecting', () => {
log.i('Connecting to Redis..');
log.n('Connecting to Redis..');
});
Database.on('error', (err) => {
log.e(`Redis error: ${err.stack}`);
@@ -50,18 +50,21 @@ export const KeyGroups = {
Dynamic: "dynamic",
Game: "game"
},
Ids: "profile-ids",
Profile_Usernames: "profile-usernames",
Profiles: {
Root: "profiles"
Root: "profiles",
Username: "username",
ProfileImage: "profileImage",
Junior: "isJunior",
Platforms: "platforms",
DisplayName: "displayname"
},
Usernames: "usernames",
Users: {
Root: "users",
Username: "username",
Password: "password",
BackupCode: "backupcode",
Profiles: "profiles",
Meta: "meta"
Pubkey: "pubkey",
Nonces: "nonces",
PlatformIds: "associatedPlatforms"
}
}
export * as Redis from "./db.ts";

View File

@@ -14,7 +14,7 @@ export const client = new discord.Client({ intents: [discord.GatewayIntentBits.G
client.once(discord.Events.ClientReady, client => {
log.i(`Logged in to Discord as "${client.user.tag}"`);
client.user?.setActivity(config?.public.motd, { type: discord.ActivityType.Custom });
client.user?.setActivity(config.public.motd, { type: discord.ActivityType.Custom });
});
let shuttingDown = false;
@@ -27,12 +27,12 @@ Deno.addSignalListener('SIGINT', () => {
});
export function login() {
if (config?.discord?.token == Config.defaultConfig.discord?.token) {
if (config.discord?.token == Config.defaultConfig.discord?.token) {
log.i('Discord not configured, ignoring');
return;
}
log.i(`Creating Discord connection..`);
client.login(config?.discord?.token);
client.login(config.discord?.token);
}
export * as Discord from "./discord.ts";

View File

@@ -1,48 +0,0 @@
import { KeyGroups, Redis } from "./db.ts";
export enum ResultType {
Found,
NotFound
}
type ConfigResult = {
Status: ResultType,
Data: string | null
}
type ConfigMResult = {
Status: ResultType,
Data: (string | null)[] | null
}
/** Get a dyamic config. */
export async function getConfig(key: string) {
const res: ConfigResult = {
Status: ResultType.Found,
Data: null
}
const data = await Redis.Database.get(`${KeyGroups.Config.Root}:${KeyGroups.Config.Dynamic}:${key}`);
if (data == null) res.Status = ResultType.NotFound;
else res.Data = data;
return res;
}
export async function mgetConfig(...keys: string[]) {
const res: ConfigMResult = {
Status: ResultType.Found,
Data: null
}
const data = await Redis.Database.mget(...keys);
res.Data = data;
return res;
}
/** Set a dynamic config. */
export async function setConfig(key: string, value: string) {
await Redis.Database.set(`${KeyGroups.Config.Root}:${KeyGroups.Config.Dynamic}:${key}`, value);
}
export * as DynamicConfig from "./dynamicconfig.ts";

View File

@@ -1,11 +1,13 @@
import * as Log from "@proxnet/undead-logging";
import * as Config from "./config.ts";
// @ts-types = 'npm:@types/express'
import express from "express";
import { Database } from "./db.ts";
import { APIUtils } from "./apiutils.ts";
import { Discord } from "./discord.ts";
import { User } from "./data/users.ts";
import { generateRandomString } from "./apiutils.ts";
// @ts-types = "npm:@types/express"
import express from "express";
const instanceId = generateRandomString(64);
const log = new Log.default("Main");
@@ -17,54 +19,18 @@ if (typeof config == 'undefined') {
log.e('Cannot start: Configuration is undefined');
Deno.exit(1);
}
if (config.secrets.authSecret == Config.defaultConfig.secrets.authSecret) {
if (config.auth.secret == Config.defaultConfig.auth.secret) {
log.e(`Cannot start: Auth secret is default. Please change 'secrets.authSecret' in 'config.json'`);
Deno.exit(1);
}
if (config.public.serverId == Config.defaultConfig.public.serverId) {
log.e(`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`);
Deno.exit(1);
}
Log.MessageTypeVisibility.Network = config.logging.network;
Log.MessageTypeVisibility.Debug = config.logging.debug;
const port = config.web.port;
const host = config.web.host;
log.i(`Starting HTTP server on http://${host}:${port}`);
const app = express();
app.disable('etag');
app.disable('x-powered-by');
app.use((rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
rs.locals.auth = null;
log.n(`${APIUtils.getSrcIpDefault(rq)} ${rq.method} ${rq.originalUrl}`);
nxt();
});
app.get('/', APIUtils.genericResponse(false, `${config?.public.serverName} - ${config?.public.motd}`));
app.get('/debug', async (_rq, rs) => {
const user = await User.init({ username: "testuser123", password: "foopass123" });
log.i(String(user == null));
rs.sendStatus(200);
});
// content routes
const nameserverRouter = await import('./routes/nameserver.ts');
const apiRouter = await import('./routes/api.ts');
const userRouter = await import('./routes/user.ts');
app.use(nameserverRouter.route.path, nameserverRouter.route.router);
app.use(apiRouter.route.path, apiRouter.route.router);
app.use(userRouter.route.path, userRouter.route.router);
app.use((rq: express.Request, rs: express.Response) => {
log.e(`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
rs.statusCode = 404;
rs.json(APIUtils.genericResponseFormat(true, 'Endpoint not found. Check your syntax and/or method.'));
});
try {
Database.connect();
} catch (err) {
@@ -72,9 +38,53 @@ try {
Deno.exit(1);
}
const port = config.web.port;
const host = config.web.host;
log.n(`Starting HTTP server on http://${host}:${port}`);
const app = express();
app.disable('etag');
app.disable('x-powered-by');
app.use((rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
rs.setHeader('Instance', instanceId)
log.n(`${APIUtils.getSrcIpDefault(rq)} ${rq.method} ${rq.originalUrl}`);
nxt();
});
app.get('/info', (_rq, rs) => {
rs.json({
name: config.public.serverName,
id: config.public.serverId,
motd: config.public.motd,
patches: config.public.patches
});
});
// content routes
const nameserverRouter = await import('./routes/nameserver.ts');
const apiRouter = await import('./routes/api.ts');
const userRouter = await import('./routes/user.ts');
const authRouter = await import('./routes/auth.ts');
const accountRouter = await import('./routes/account.ts');
app.use(nameserverRouter.route.path, nameserverRouter.route.router);
app.use(apiRouter.route.path, apiRouter.route.router);
app.use(userRouter.route.path, userRouter.route.router);
app.use(authRouter.route.path, authRouter.route.router);
app.use(accountRouter.route.path, accountRouter.route.router);
app.use((rq: express.Request, rs: express.Response) => {
log.e(`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
rs.statusCode = 404;
rs.json(APIUtils.genericResponseFormat(true, 'Endpoint not found. Check your syntax and/or method.'));
});
try {
const http = app.listen(config.web.port, config.web.host, () => {
log.n(`Listening on http://${config.web.host}`);
log.n(`Listening on http://${config.web.host}:${config.web.port}`);
let shuttingDown = false;
Deno.addSignalListener('SIGINT', () => {
@@ -83,7 +93,6 @@ try {
log.i(`Shutting down`);
http.close();
http.removeAllListeners();
});
});
} catch (err) {

6
src/routes/account.ts Normal file
View File

@@ -0,0 +1,6 @@
import { APIUtils } from "../apiutils.ts";
import { route as AccountRoute } from "./account/account.ts";
export const route = APIUtils.createRouter('/accountservice');
route.router.use(AccountRoute.path, AccountRoute.router);

View File

@@ -0,0 +1,30 @@
import { APIUtils } from "../../apiutils.ts";
import express from "express";
import Profile from "../../data/profiles.ts";
export const route = APIUtils.createRouter("/account");
interface CreateAccountRequestBody {
platform: string,
platformId: string,
deviceId: string
}
route.router.post('/create',
APIUtils.UserAuthentication,
express.urlencoded({ extended: true }),
APIUtils.checkBodyTypes<CreateAccountRequestBody>({platform: "", platformId: "", deviceId: ""}),
async (_rq, rs) => {
const newAcc = await Profile.init();
rs.locals.user.addAssociatedProfile(newAcc.getId());
rs.json({
success: true,
value: await newAcc.export()
});
},
);

View File

@@ -1,8 +1,10 @@
import { route as VersionCheckRoute } from "./api/versioncheck.ts";
import { route as ConfigRoute } from "./api/config.ts";
import { route as GameConfig } from "./api/gameconfigs.ts";
import { APIUtils } from "../apiutils.ts";
export const route = APIUtils.createRouter('/api');
route.router.use(VersionCheckRoute.path, VersionCheckRoute.router);
route.router.use(ConfigRoute.path, ConfigRoute.router);
route.router.use(ConfigRoute.path, ConfigRoute.router);
route.router.use(GameConfig.path, GameConfig.router);

View File

@@ -5,9 +5,9 @@ export const route = APIUtils.createRouter('/versioncheck');
const validVersion = '20191120';
enum VersionStatus {
UpdateRequired,
ValidForPlay,
ValidForMenu,
ValidForPlay
UpdateRequired
}
type ValidVersionResponse = {
VersionStatus: VersionStatus
@@ -15,7 +15,8 @@ type ValidVersionResponse = {
route.router.get('/v4', (rq, rs) => {
const requestedVer = rq.query['v'];
if (typeof requestedVer == 'undefined') {
const pQuery = rq.query['p'];
if (typeof requestedVer == 'undefined' || typeof pQuery == 'undefined') {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, 'One or more query parameters were not found.'));
}

8
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { APIUtils } from "../apiutils.ts";
import { route as CachedLoginRoute } from "./auth/cachedlogin.ts";
import { route as ConnectRoute } from "./auth/connect.ts";
export const route = APIUtils.createRouter('/authservice');
route.router.use(CachedLoginRoute.path, CachedLoginRoute.router);
route.router.use(ConnectRoute.path, ConnectRoute.router);

View File

@@ -0,0 +1,15 @@
import { APIUtils } from "../../apiutils.ts";
export const route = APIUtils.createRouter("/cachedlogin");
route.router.get('/forplatformid/:platformtype/:platformid',
APIUtils.UserAuthentication,
async (_rq, rs) => {
rs.json(await rs.locals.user.exportAssociatedProfiles());
}
);

View File

@@ -0,0 +1,5 @@
import { APIUtils } from "../../apiutils.ts";
export const route = APIUtils.createRouter("/connect");
//route.router.post()

View File

@@ -2,7 +2,7 @@ import { APIUtils } from "../apiutils.ts";
import { Config } from "../config.ts";
const config = Config.getConfig() as Config.GalvanicConfiguration;
const protocol = config.web.secureNameserverHost ? 'https' : 'http';
const protocol = config.web.securepublichost ? 'https' : 'http';
export const route = APIUtils.createRouter('/ns');
@@ -21,17 +21,17 @@ type NameserverHosts = {
}
const nameserver: NameserverHosts = {
Auth: `${protocol}://${config.web.nameserverHost}/auth`,
API: `${protocol}://${config.web.nameserverHost}`,
WWW: `${protocol}://${config.web.nameserverHost}`,
Notifications: `${protocol}://${config.web.nameserverHost}/notify`,
Images: `${protocol}://${config.web.nameserverHost}/img`,
CDN: `${protocol}://${config.web.nameserverHost}/cdn`,
Commerce: `${protocol}://${config.web.nameserverHost}/commerce`,
Matchmaking: `${protocol}://${config.web.nameserverHost}/match`,
Storage: `${protocol}://${config.web.nameserverHost}/storage`,
Chat: `${protocol}://${config.web.nameserverHost}/chat`,
Leaderboard: `${protocol}://${config.web.nameserverHost}/leaderboard`
Auth: `${protocol}://${config.web.publichost}/auth`,
API: `${protocol}://${config.web.publichost}`,
WWW: `${protocol}://${config.web.publichost}`,
Notifications: `${protocol}://${config.web.publichost}/notify`,
Images: `${protocol}://${config.web.publichost}/img`,
CDN: `${protocol}://${config.web.publichost}/cdn`,
Commerce: `${protocol}://${config.web.publichost}/commerce`,
Matchmaking: `${protocol}://${config.web.publichost}/match`,
Storage: `${protocol}://${config.web.publichost}/storage`,
Chat: `${protocol}://${config.web.publichost}/chat`,
Leaderboard: `${protocol}://${config.web.publichost}/leaderboard`
}
route.router.get('*', (_rq, rs) => {

View File

@@ -1,53 +1,107 @@
import { APIUtils, getSrcIpDefault } from "../apiutils.ts";
import { APIUtils, NoBody } from "../apiutils.ts";
// @ts-types = "npm:@types/express"
import express from "express";
import { User } from "../data/users.ts";
import Recaptcha from "../data/recaptcha.ts";
import { Config } from "../config.ts";
import crypto from "node:crypto";
import Logging from "@proxnet/undead-logging";
const log = new Logging("UserRoute");
const config = Config.getConfig();
export const route = APIUtils.createRouter('/user');
type CreateUserBody = {
username: string,
password: string,
recaptcha: string
interface AuthRequestSec {
timestamp: number,
nonce: string,
server_id: string
}
type CreatedUserResponse = {
uuid: string,
backupcode: string
interface AuthRequestRoot {
client_id: string,
message: AuthRequestSec,
signature: string,
pubkey: string
}
const rateLimit = new APIUtils.RateLimiter(10, 1);
const rateLimit = new APIUtils.RateLimiter(60, 1);
route.router.post('/create',
route.router.post('/auth',
rateLimit.middle(),
express.json(),
APIUtils.checkBodyTypes<CreateUserBody>({ username: "test", password: "test", recaptcha: "test" }),
APIUtils.checkBodyTypes<AuthRequestRoot>({
client_id: "asdf",
message: {
timestamp: 0,
nonce: "asdf",
server_id: "asdf"
},
signature: "asdf",
pubkey: "asdf"
}),
async (rq, rs) => {
const body = rq.body as CreateUserBody;
async (rq: express.Request<NoBody, NoBody, AuthRequestRoot>, rs: express.Response) => {
const recaptchaStatus = await Recaptcha.siteVerify(body.recaptcha, getSrcIpDefault(rq));
if (recaptchaStatus) {
const userinit = await User.init({ username: body.username, password: body.password });
if (userinit == null) {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, "Username is already taken"));
} else {
function authFailed(msg: string) {
rs.json(APIUtils.genericResponseFormat(true, msg));
}
const res: CreatedUserResponse = {
uuid: userinit.user.getUuid(),
backupcode: userinit.backupcode
}
rs.json(APIUtils.genericResponseFormat(false, "User created successfully", res));
if (rq.body.message.server_id !== config.public.serverId) {
log.w(`Auth request failed (serverId mismatch), config error?\n given ID: '${rq.body.message.server_id}'\n our ID: '${config.public.serverId}'`);
authFailed('Authentication request not intended for this server.');
return;
}
try {
const verify = crypto.createVerify('SHA256');
verify.update(JSON.stringify(rq.body.message));
verify.end();
const publicKey = await crypto.subtle.importKey(
"spki",
(Uint8Array.from(atob(rq.body.pubkey), c => c.charCodeAt(0))).buffer,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"]
);
const messageBytes = new TextEncoder().encode(JSON.stringify(rq.body.message));
const signatureBytes = Uint8Array.from(atob(rq.body.signature), c => c.charCodeAt(0));
const isValid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
signatureBytes.buffer,
messageBytes
);
if (!isValid) {
log.w(`Auth failed for clientId '${rq.body.client_id}'`);
authFailed('Authentication request failed.');
return;
}
} catch (err) {
log.d(`Error when verifying auth request: ${err}`);
authFailed('Authentication request failed.');
return;
}
else {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, "ReCAPTCHA error"));
let user = new User(rq.body.client_id);
if (!(await user.exists())) {
const obj = await User.init({ client_id: rq.body.client_id, pubkey: rq.body.pubkey });
if (obj == null) {
rs.sendStatus(500);
return;
} else user = obj;
}
if (await user.hasNonce(rq.body.message.nonce)) {
log.w(`Client '${rq.body.client_id}' has already used nonce. Replay attack?`);
authFailed('Authentication request failed.');
return;
} else user.addNonce(rq.body.message.nonce);
const token = await user.getToken();
rs.json({ token: token });
}
);

View File

@@ -1,9 +1,11 @@
import { Authentication } from "../data/auth.ts";
import Profile from "../data/profiles.ts";
import { User } from "../data/users.ts";
declare global {
namespace Express {
interface Locals {
auth: Authentication.GameAuthContext | null
profile: Profile
user: User
}
}
}