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:
@@ -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"
|
||||
@@ -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';
|
||||
@@ -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";
|
||||
@@ -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
128
src/data/profiles.ts
Normal 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;
|
||||
@@ -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
6
src/data/usernames.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
29
src/db.ts
29
src/db.ts
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
101
src/main.ts
101
src/main.ts
@@ -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
6
src/routes/account.ts
Normal 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);
|
||||
30
src/routes/account/account.ts
Normal file
30
src/routes/account/account.ts
Normal 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()
|
||||
});
|
||||
},
|
||||
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
8
src/routes/auth.ts
Normal 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);
|
||||
15
src/routes/auth/cachedlogin.ts
Normal file
15
src/routes/auth/cachedlogin.ts
Normal 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());
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
5
src/routes/auth/connect.ts
Normal file
5
src/routes/auth/connect.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { APIUtils } from "../../apiutils.ts";
|
||||
|
||||
export const route = APIUtils.createRouter("/connect");
|
||||
|
||||
//route.router.post()
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user