172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
import { Context, Next } from "@hono/hono";
|
|
import { type HonoEnv } from "./types.ts";
|
|
import Logging from "@proxnet/undead-logging";
|
|
import z from "zod";
|
|
import { verify } from "@hono/hono/jwt";
|
|
import Server from "../server/server.ts";
|
|
import { TokenFormat } from "../server/platforms/types.ts";
|
|
import { HTTPStatus, httpStatusText } from "@oneday/http-status";
|
|
import { ContentfulStatusCode } from "@hono/hono/utils/http-status";
|
|
import { env } from "../env.ts";
|
|
|
|
const log = new Logging("APIUtils");
|
|
|
|
export function statusResponse(c: Context, code: HTTPStatus, msg?: string) {
|
|
return c.json(genericResponse(code < 400, msg ?? httpStatusText(code)), code as ContentfulStatusCode);
|
|
}
|
|
|
|
export function genericResponse(success: boolean, msg?: string, data?: null) {
|
|
return { success, msg, data }
|
|
};
|
|
|
|
export function successResponse(success: boolean, error: string) {
|
|
return (c: Context) => {
|
|
return c.json({ success, error });
|
|
}
|
|
}
|
|
|
|
const authHeaderSchema = z.string().transform((arg, ctx) => {
|
|
const split = arg.split(' ');
|
|
for (const item of split) if (item.length < 6) {
|
|
ctx.addIssue("Authorization header is invalid");
|
|
return;
|
|
}
|
|
return split[1];
|
|
});
|
|
export async function authenticate(c: Context<HonoEnv>, nxt: Next) {
|
|
const secret = env["SECRET"];
|
|
if (!secret) return c.json(genericResponse(false, "Internal Server Error"), 500);
|
|
|
|
const authHeader = authHeaderSchema.safeParse(c.req.header('Authorization'));
|
|
if (authHeader.success) {
|
|
|
|
try {
|
|
const payload = JSON.parse(JSON.stringify(await verify(authHeader.data ? authHeader.data : 'not a valid token', secret)));
|
|
const profile = await Server.Profiles.get((payload as TokenFormat).sub);
|
|
if (!profile) return c.json(genericResponse(false, "Internal Server Error"), 500);
|
|
|
|
c.set('profile', profile);
|
|
return await nxt();
|
|
} catch (err) {
|
|
log.w(`Authentication failed: ${(err as Error).stack}`);
|
|
return c.json(genericResponse(false, "Internal Server Error"), 500);
|
|
}
|
|
|
|
} else return c.json(genericResponse(false, "Authorization required"), 401);
|
|
}
|
|
|
|
export function recNetResult(success: boolean, error?: string) {
|
|
return {success, error};
|
|
}
|
|
export function recNetResultResponse(c: Context<HonoEnv>, status: HTTPStatus, success: boolean, error?: string) {
|
|
return c.json(recNetResult(success, error), status as ContentfulStatusCode);
|
|
}
|
|
|
|
export function generateRandomString(length: number) {
|
|
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
let randomString = "";
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
const randomIndex = Math.floor(Math.random() * characters.length);
|
|
randomString += characters.charAt(randomIndex);
|
|
}
|
|
|
|
return randomString;
|
|
}
|
|
|
|
export class RateLimiter {
|
|
#intervalId: number;
|
|
|
|
#hitLimit: number;
|
|
|
|
#addressHits: Map<string, number> = new Map();
|
|
|
|
/**
|
|
* @param interval In seconds: rate at which hit counts will be cleared
|
|
* @param limit Number of hits (inclusive) before requests are blocked
|
|
*/
|
|
constructor(interval: number = 60, limit: number = 10) {
|
|
this.#hitLimit = limit;
|
|
|
|
this.#intervalId = setInterval(() => {
|
|
this.#addressHits.clear();
|
|
}, interval * 1000);
|
|
|
|
Deno.addSignalListener("SIGINT", () => {
|
|
this.#close();
|
|
});
|
|
}
|
|
|
|
#addressIncrement(address: string) {
|
|
const hits = this.#addressHits.get(address);
|
|
if (hits) this.#addressHits.set(address, hits + 1);
|
|
else this.#addressHits.set(address, 1);
|
|
}
|
|
|
|
#getAddressHits(address: string) {
|
|
const hits = this.#addressHits.get(address);
|
|
if (hits) return hits;
|
|
else {
|
|
this.#addressHits.set(address, 1);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
middle() {
|
|
return (
|
|
c: Context<HonoEnv>,
|
|
next: Next
|
|
) => {
|
|
const address = c.get('srcAddr');
|
|
if (address == '127.0.0.1' || address == '::1') return next();
|
|
|
|
this.#addressIncrement(address);
|
|
|
|
const hits = this.#getAddressHits(address);
|
|
if (hits && hits > this.#hitLimit) return c.json(recNetResult(false, "Rate Limited. Please try again later."), 429);
|
|
else return next();
|
|
};
|
|
}
|
|
|
|
#close() {
|
|
clearInterval(this.#intervalId);
|
|
}
|
|
}
|
|
|
|
const loginLockBodySchema = z.object({
|
|
LoginLock: z.uuidv4()
|
|
});
|
|
export const loginLockMiddleware = async (c: Context<HonoEnv>, nxt: Next) => {
|
|
function unauthorized() {
|
|
return statusResponse(c, HTTPStatus.Unauthorized);
|
|
}
|
|
|
|
if (c.req.header("Content-Type") !== "application/x-www-form-urlencoded") return unauthorized();
|
|
try {
|
|
const form = await c.req.formData();
|
|
|
|
const body = await loginLockBodySchema.safeParseAsync(Object.fromEntries(form.entries()));
|
|
if (body.success) {
|
|
if (typeof c.get('profile') == 'undefined') {
|
|
log.w(`Profile was not set, cannot validate LoginLock. Was the request authorized?`);
|
|
return statusResponse(c, HTTPStatus.InternalServerError);
|
|
}
|
|
|
|
const profile = c.get('profile');
|
|
|
|
const loginLock = await profile.Matchmaking.getLoginLock();
|
|
if (!loginLock) await profile.Matchmaking.setLoginLock(body.data.LoginLock);
|
|
else if (body.data.LoginLock !== loginLock) {
|
|
log.w(`LoginLock did not match. The token for this profile could be compromised or the client is an unknown state.`);
|
|
return unauthorized();
|
|
}
|
|
|
|
return await nxt();
|
|
} else {
|
|
log.w(`LoginLock parse failed: ${JSON.stringify(body.error)}`);
|
|
return unauthorized();
|
|
}
|
|
} catch {
|
|
return unauthorized();
|
|
}
|
|
} |