From 98e50a6d57ebb48fd0b5fec148aa14aeaf95a1dd Mon Sep 17 00:00:00 2001 From: Rory& Date: Tue, 21 Jul 2026 15:43:51 +0200 Subject: Move authentication enforcement to route middleware, move route middleware file --- src/api/middlewares/Authentication.ts | 46 +++------ src/api/middlewares/Route.ts | 184 ++++++++++++++++++++++++++++++++++ src/api/middlewares/index.ts | 1 + src/api/util/handlers/route.ts | 175 -------------------------------- src/api/util/index.ts | 1 - 5 files changed, 199 insertions(+), 208 deletions(-) create mode 100644 src/api/middlewares/Route.ts delete mode 100644 src/api/util/handlers/route.ts diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index a71dfdf1..fe57880a 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -20,7 +20,7 @@ import { NextFunction, Request, Response } from "express"; import { HTTPError } from "lambert-server/HTTPError"; import { Session, User } from "@spacebar/database"; import { Random } from "@spacebar/extensions"; -import { checkToken, Rights, UserTokenData } from "@spacebar/util"; +import { checkToken, DiscordApiErrors, Rights, UserTokenData } from "@spacebar/util"; export const NO_AUTHORIZATION_ROUTES = [ // Authentication routes @@ -77,6 +77,7 @@ declare global { session?: Session; rights: Rights; fingerprint?: string; + isAuthenticated: boolean; } } } @@ -96,33 +97,16 @@ export async function Authentication(req: Request, res: Response, next: NextFunc `__sb_sessid=${(req.fingerprint = Random.getString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 32))}; Secure; HttpOnly; SameSite=None; Path=/`, ); - if ( - NO_AUTHORIZATION_ROUTES.some((x) => { - if (typeof x !== "string") { - return x.test(req.method + " " + url); - } + await handleAuthentication(req); - const fullRoute = req.method + " " + url; - - if (req.method === "HEAD") { - const urlPart = x.split(" ").slice(1).join(" "); - if (urlPart.endsWith("/")) { - return url.startsWith(urlPart); - } else { - return url === urlPart; - } - } - - if (x.endsWith("/")) { - return fullRoute.startsWith(x); - } else { - return fullRoute === x; - } - }) - ) - return next(); + return next(); +} - if (!req.headers.authorization) return next(new HTTPError("Missing Authorization Header", 401)); +export async function handleAuthentication(req: Request) { + if (!req.headers.authorization) { + req.isAuthenticated = false; + return; + } try { const { decoded, user, session } = (req.tokenData = await checkToken(req.headers.authorization, { @@ -136,11 +120,9 @@ export async function Authentication(req: Request, res: Response, next: NextFunc req.user = user; req.session = session; req.rights = new Rights(Number(user.rights)); - return next(); - } catch (error) { - if (error instanceof HTTPError) { - return next(error); - } - return next(new HTTPError(error!.toString(), 400)); + req.isAuthenticated = true; + } catch (e) { + req.isAuthenticated = false; + console.error("[Authentication] Token was provided, but was invalid:", e); } } diff --git a/src/api/middlewares/Route.ts b/src/api/middlewares/Route.ts new file mode 100644 index 00000000..69c2b25e --- /dev/null +++ b/src/api/middlewares/Route.ts @@ -0,0 +1,184 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { DiscordApiErrors, EVENT, FieldErrors, PermissionResolvable, Permissions, RightResolvable, Rights, SpacebarApiErrors, getPermission, getRights } from "@spacebar/util"; +import { AnyValidateFunction } from "ajv/dist/core"; +import { NextFunction, Request, Response } from "express"; +import { ajv } from "@spacebar/schemas"; +import { BigNumber } from "bignumber.js"; + +const ignoredRequestSchemas = [ + // skip validation for settings proto JSON updates - TODO: figure out if this even possible to fix? + "SettingsProtoUpdateJsonSchema", +]; + +declare global { + // TODO: fix this + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + permission?: Permissions; + } + } +} + +export type RouteResponse = { + status?: number; + body?: `${string}Response`; + headers?: Record; +}; +export type stripNulls = { [key: string]: true | stripNulls }; +export interface RouteOptions { + permission?: PermissionResolvable; + right?: RightResolvable; + requestBody?: `${string}Schema`; // typescript interface name + responses?: { + [status: number]: { + // body?: `${string}Response`; + body?: string; + }; + }; + stripNulls?: stripNulls | true; + event?: EVENT | EVENT[]; + summary?: string; + description?: string; + query?: { + [key: string]: { + type: string; + required?: boolean; + description?: string; + values?: string[]; + }; + }; + deprecated?: boolean; + spacebarOnly?: boolean; + // test?: { + // response?: RouteResponse; + // body?: unknown; + // path?: string; + // event?: EVENT | EVENT[]; + // headers?: Record; + // }; + + /** + * @defaultValue "required" + */ + authentication?: "never" | "optional" | "required"; +} +export function stripNull(obj: object) { + for (const [key, value] of Object.entries(obj)) { + if (value instanceof Object || (value && !value.__proto__)) { + stripNull(value); + } else if (value === null) { + //@ts-expect-error this is fine + delete obj[key]; + } + } +} +// eslint-disable-next-line +export function followNullPath(obj1: any, nullObj: stripNulls) { + for (const [key, value] of Object.entries(nullObj)) { + if (key in obj1) + if (value instanceof Object) { + if (obj1[key] instanceof Object) + //@ts-expect-error this works lol + followNullPath(obj1[key], nullObj[key]); + else delete obj1[key]; + } else if (obj1[key] instanceof Object) { + stripNull(obj1[key]); + } + } +} +//It's pretty safe to assume numbers over the number limit aren't really meant to be numbers, so we turn them to strings. +export function bigNumberToString(obj1: unknown) { + if (obj1 && typeof obj1 === "object") { + for (const [key, value] of Object.entries(obj1)) { + if (typeof value === "object") { + if (value instanceof BigNumber) { + //@ts-expect-error this is fine lol + obj1[key] = value.toString(); + } + bigNumberToString(value); + } + } + } +} +export function route(opts: RouteOptions) { + let validate: AnyValidateFunction | undefined; + if (opts.requestBody) { + try { + validate = ajv.getSchema(opts.requestBody); + } catch (e) { + console.error("AJV getSchema failed!"); + throw e; + } + + if (!validate) throw new Error(`Body schema ${opts.requestBody} not found`); + } + + opts.authentication ??= "required"; + + return async (req: Request, res: Response, next: NextFunction) => { + if (opts.authentication === "required" && !req.isAuthenticated) throw DiscordApiErrors.UNAUTHORIZED; + + if (opts.permission) { + const { guild_id, channel_id } = req.params as { [key: string]: string }; + req.permission = await getPermission(req.user_id, guild_id, channel_id); + + const requiredPerms = Array.isArray(opts.permission) ? opts.permission : [opts.permission]; + requiredPerms.forEach((perm) => { + // bitfield comparison: check if user lacks certain permission + if (!req.permission!.has(new Permissions(perm))) { + throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(perm as string); + } + }); + } + + if (opts.right) { + const required = new Rights(opts.right); + req.rights = await getRights(req.user_id); + + if (!req.rights || !req.rights.has(required)) { + throw SpacebarApiErrors.MISSING_RIGHTS.withParams(opts.right as string); + } + } + bigNumberToString(req.body); + + if (validate && !ignoredRequestSchemas.includes(opts.requestBody!)) { + if (opts.stripNulls) { + if (opts.stripNulls === true) stripNull(req.body); + else followNullPath(req.body, opts.stripNulls); + } + + const valid = validate(req.body); + if (!valid) { + const fields: Record = {}; + validate.errors?.forEach( + (x) => + (fields[x.instancePath.slice(1)] = { + code: x.keyword, + message: x.message || "", + }), + ); + if (process.env.LOG_VALIDATION_ERRORS) console.log(`[VALIDATION ERROR] ${req.method} ${req.originalUrl} - SCHEMA='${opts.requestBody}' -`, validate?.errors); + throw FieldErrors(fields, validate.errors!); + } + } + next(); + }; +} diff --git a/src/api/middlewares/index.ts b/src/api/middlewares/index.ts index a2bb00b3..22651b53 100644 --- a/src/api/middlewares/index.ts +++ b/src/api/middlewares/index.ts @@ -22,4 +22,5 @@ export * from "./CORS"; export * from "./ErrorHandler"; export * from "./ImageProxy"; export * from "./RateLimit"; +export * from "./Route"; export * from "./Translation"; diff --git a/src/api/util/handlers/route.ts b/src/api/util/handlers/route.ts deleted file mode 100644 index c00a959d..00000000 --- a/src/api/util/handlers/route.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -import { DiscordApiErrors, EVENT, FieldErrors, PermissionResolvable, Permissions, RightResolvable, Rights, SpacebarApiErrors, getPermission, getRights } from "@spacebar/util"; -import { AnyValidateFunction } from "ajv/dist/core"; -import { NextFunction, Request, Response } from "express"; -import { ajv } from "@spacebar/schemas"; -import { BigNumber } from "bignumber.js"; - -const ignoredRequestSchemas = [ - // skip validation for settings proto JSON updates - TODO: figure out if this even possible to fix? - "SettingsProtoUpdateJsonSchema", -]; - -declare global { - // TODO: fix this - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - permission?: Permissions; - } - } -} - -export type RouteResponse = { - status?: number; - body?: `${string}Response`; - headers?: Record; -}; -export type stripNulls = { [key: string]: true | stripNulls }; -export interface RouteOptions { - permission?: PermissionResolvable; - right?: RightResolvable; - requestBody?: `${string}Schema`; // typescript interface name - responses?: { - [status: number]: { - // body?: `${string}Response`; - body?: string; - }; - }; - stripNulls?: stripNulls | true; - event?: EVENT | EVENT[]; - summary?: string; - description?: string; - query?: { - [key: string]: { - type: string; - required?: boolean; - description?: string; - values?: string[]; - }; - }; - deprecated?: boolean; - spacebarOnly?: boolean; - // test?: { - // response?: RouteResponse; - // body?: unknown; - // path?: string; - // event?: EVENT | EVENT[]; - // headers?: Record; - // }; -} -export function stripNull(obj: object) { - for (const [key, value] of Object.entries(obj)) { - if (value instanceof Object || (value && !value.__proto__)) { - stripNull(value); - } else if (value === null) { - //@ts-expect-error this is fine - delete obj[key]; - } - } -} -// eslint-disable-next-line -export function followNullPath(obj1: any, nullObj: stripNulls) { - for (const [key, value] of Object.entries(nullObj)) { - if (key in obj1) - if (value instanceof Object) { - if (obj1[key] instanceof Object) - //@ts-expect-error this works lol - followNullPath(obj1[key], nullObj[key]); - else delete obj1[key]; - } else if (obj1[key] instanceof Object) { - stripNull(obj1[key]); - } - } -} -//It's pretty safe to assume numbers over the number limit aren't really meant to be numbers, so we turn them to strings. -export function bigNumberToString(obj1: unknown) { - if (obj1 && typeof obj1 === "object") { - for (const [key, value] of Object.entries(obj1)) { - if (typeof value === "object") { - if (value instanceof BigNumber) { - //@ts-expect-error this is fine lol - obj1[key] = value.toString(); - } - bigNumberToString(value); - } - } - } -} -export function route(opts: RouteOptions) { - let validate: AnyValidateFunction | undefined; - if (opts.requestBody) { - try { - validate = ajv.getSchema(opts.requestBody); - } catch (e) { - console.error("AJV getSchema failed!"); - throw e; - } - - if (!validate) throw new Error(`Body schema ${opts.requestBody} not found`); - } - - return async (req: Request, res: Response, next: NextFunction) => { - if (opts.permission) { - const { guild_id, channel_id } = req.params as { [key: string]: string }; - req.permission = await getPermission(req.user_id, guild_id, channel_id); - - const requiredPerms = Array.isArray(opts.permission) ? opts.permission : [opts.permission]; - requiredPerms.forEach((perm) => { - // bitfield comparison: check if user lacks certain permission - if (!req.permission!.has(new Permissions(perm))) { - throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(perm as string); - } - }); - } - - if (opts.right) { - const required = new Rights(opts.right); - req.rights = await getRights(req.user_id); - - if (!req.rights || !req.rights.has(required)) { - throw SpacebarApiErrors.MISSING_RIGHTS.withParams(opts.right as string); - } - } - bigNumberToString(req.body); - - if (validate && !ignoredRequestSchemas.includes(opts.requestBody!)) { - if (opts.stripNulls) { - if (opts.stripNulls === true) stripNull(req.body); - else followNullPath(req.body, opts.stripNulls); - } - - const valid = validate(req.body); - if (!valid) { - const fields: Record = {}; - validate.errors?.forEach( - (x) => - (fields[x.instancePath.slice(1)] = { - code: x.keyword, - message: x.message || "", - }), - ); - if (process.env.LOG_VALIDATION_ERRORS) console.log(`[VALIDATION ERROR] ${req.method} ${req.originalUrl} - SCHEMA='${opts.requestBody}' -`, validate?.errors); - throw FieldErrors(fields, validate.errors!); - } - } - next(); - }; -} diff --git a/src/api/util/index.ts b/src/api/util/index.ts index b57d263a..5fb5a684 100644 --- a/src/api/util/index.ts +++ b/src/api/util/index.ts @@ -19,7 +19,6 @@ export * from "./utility/ipAddress"; export * from "./handlers/Message"; export * from "./utility/passwordStrength"; -export * from "./handlers/route"; export * from "./handlers/Voice"; export * from "./utility/captcha"; export * from "./utility/EmbedHandlers"; -- cgit 1.5.1