From b06ba4fd47c116efc79a07e1a007428e4bc0f8b7 Mon Sep 17 00:00:00 2001 From: zkldi Date: Thu, 10 Jun 2021 20:13:32 +0100 Subject: [PATCH] Huge API Permissions update --- server/@types/express/index.d.ts | 6 +- server/pnpm-lock.yaml | 6 +- server/scripts/set-indexes.ts | 1 + server/src/external/mongo/db.ts | 6 +- server/src/lib/constants/tachi.ts | 1 + server/src/server/middleware/auth.ts | 152 +++++++++++++++ .../middleware/require-logged-in.test.ts | 6 +- .../server/middleware/require-logged-in.ts | 18 +- server/src/server/middleware/type-require.ts | 4 +- .../server/router/api/v1/auth/auth.test.ts | 8 +- server/src/server/router/api/v1/auth/auth.ts | 20 +- .../server/router/api/v1/auth/router.test.ts | 1 - .../src/server/router/api/v1/auth/router.ts | 18 +- .../router/api/v1/import/router.test.ts | 2 +- .../src/server/router/api/v1/import/router.ts | 4 +- .../router/api/v1/status/router.test.ts | 2 +- .../src/server/router/api/v1/status/router.ts | 9 + .../server/router/ir/barbatos/router.test.ts | 6 +- .../src/server/router/ir/barbatos/router.ts | 9 +- .../server/router/ir/beatoraja/auth.test.ts | 80 +------- server/src/server/router/ir/beatoraja/auth.ts | 43 +--- .../router/ir/beatoraja/charts/router.test.ts | 4 +- .../router/ir/beatoraja/charts/router.ts | 4 +- .../server/router/ir/beatoraja/router.test.ts | 183 +++++------------- .../src/server/router/ir/beatoraja/router.ts | 66 +------ .../router/ir/chunitachi/router.test.ts | 21 +- .../src/server/router/ir/chunitachi/router.ts | 7 +- .../router/ir/direct-manual/router.test.ts | 2 +- .../server/router/ir/direct-manual/router.ts | 31 +-- .../server/router/ir/fervidex/router.test.ts | 2 +- .../src/server/router/ir/fervidex/router.ts | 45 +++-- server/src/server/router/ir/usc/router.ts | 52 +---- server/src/server/router/router.ts | 4 +- .../{fake-session.ts => fake-auth.ts} | 12 ++ server/src/test-utils/mock-db/api-tokens.json | 10 + .../mock-db/beatoraja-auth-tokens.json | 4 - server/src/utils/types.ts | 4 +- 37 files changed, 373 insertions(+), 480 deletions(-) create mode 100644 server/src/server/middleware/auth.ts rename server/src/test-utils/{fake-session.ts => fake-auth.ts} (65%) create mode 100644 server/src/test-utils/mock-db/api-tokens.json delete mode 100644 server/src/test-utils/mock-db/beatoraja-auth-tokens.json diff --git a/server/@types/express/index.d.ts b/server/@types/express/index.d.ts index 9f91389ca..c8f799876 100644 --- a/server/@types/express/index.d.ts +++ b/server/@types/express/index.d.ts @@ -1,6 +1,7 @@ import { Session, SessionData } from "express-session"; -import { SYMBOL_TachiData } from "../../src/lib/constants/tachi"; +import { SYMBOL_TachiAPIData, SYMBOL_TachiData } from "../../src/lib/constants/tachi"; import { TachiRequestData, TachiSessionData } from "../../src/utils/types"; +import { APITokenDocument } from "tachi-common"; export {}; @@ -18,6 +19,9 @@ declare global { session: Session & Partial; [SYMBOL_TachiData]?: Partial; + // even though this is technically *not* present on every request + // it's always assigned in the main router, so its functionally equivalent. + [SYMBOL_TachiAPIData]: APITokenDocument; } } } diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 1ba08928f..225a3b5d5 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -80,7 +80,7 @@ dependencies: rate-limit-redis: 2.1.0 redis: 3.1.2 rimraf: 3.0.2 - tachi-common: github.com/zkldi/tachi-common/cd7dd96729fd8d70b55165c90fb2bc62521ee293 + tachi-common: github.com/zkldi/tachi-common/137fb72499a2ee6a98791ffea3961d0c69bf24b7 typescript: 4.3.2 winston: 3.3.3 @@ -4445,8 +4445,8 @@ packages: engines: {node: '>=6'} dev: true - github.com/zkldi/tachi-common/cd7dd96729fd8d70b55165c90fb2bc62521ee293: - resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/cd7dd96729fd8d70b55165c90fb2bc62521ee293} + github.com/zkldi/tachi-common/137fb72499a2ee6a98791ffea3961d0c69bf24b7: + resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/137fb72499a2ee6a98791ffea3961d0c69bf24b7} name: kamaitachi-common version: 0.1.0 dependencies: diff --git a/server/scripts/set-indexes.ts b/server/scripts/set-indexes.ts index dfd5588fa..21be2530c 100644 --- a/server/scripts/set-indexes.ts +++ b/server/scripts/set-indexes.ts @@ -83,6 +83,7 @@ const staticIndexes: Partial> = { "bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)], "beatoraja-auth-tokens": [index({ token: 1 }, UNIQUE)], "usc-auth-tokens": [index({ token: 1 }, UNIQUE)], + "api-tokens": [index({ token: 1 }, UNIQUE)], }; const indexes: Partial> = staticIndexes; diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 29672801d..8abc32358 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -7,8 +7,8 @@ import { ImportDocument, TierlistParent, InviteCodeDocument, + APITokenDocument, TierlistDataDocument, - GenericAuthDocument, MilestoneDocument, NotificationDocument, FolderChartLookup, @@ -129,10 +129,8 @@ const db = { "iidx-eam-scores": monkDB.get("iidx-eam-scores"), "game-stats": monkDB.get("game-stats"), "kai-auth-tokens": monkDB.get("kai-auth-tokens"), - "usc-auth-tokens": monkDB.get("usc-auth-tokens"), - "beatoraja-auth-tokens": monkDB.get("beatoraja-auth-tokens"), "bms-course-lookup": monkDB.get("bms-course-lookup"), - + "api-tokens": monkDB.get("api-tokens"), "orphan-scores": monkDB.get("orphan-scores"), }; diff --git a/server/src/lib/constants/tachi.ts b/server/src/lib/constants/tachi.ts index 9f26030a9..3657780f4 100644 --- a/server/src/lib/constants/tachi.ts +++ b/server/src/lib/constants/tachi.ts @@ -1 +1,2 @@ export const SYMBOL_TachiData = Symbol("tachi-request-patch"); +export const SYMBOL_TachiAPIData = Symbol("tachi-api-auth-info"); diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts new file mode 100644 index 000000000..f62c8174f --- /dev/null +++ b/server/src/server/middleware/auth.ts @@ -0,0 +1,152 @@ +import { RequestHandler } from "express"; +import db from "../../external/mongo/db"; +import { SYMBOL_TachiAPIData } from "../../lib/constants/tachi"; +import { SplitAuthorizationHeader } from "../../utils/misc"; +import { APITokenDocument, APIPermissions } from "tachi-common"; +import CreateLogCtx from "../../lib/logger/logger"; + +const logger = CreateLogCtx(__filename); + +const GuestToken: APITokenDocument = { + token: null, + userID: null, + identifier: "Guest Token", + permissions: {}, +}; + +export const AllPermissions: Record = { + "create:goal": true, + "manage:goal": true, + "submit:score": true, +}; + +/** + * Sets the permissions for this request, alongside the user that is making the request. + * + * If this request was made with a valid Session Token, then a "self-key" is + * set as the request token. + * + * If this request was made with a valid Authorization: Bearer , then the + * corresponding key is set as the request token. + * + * If this request was made with no auth headers or session tokens, then a guest + * token is set as the request token, with no permissions. + * + * This is set on req[SYMBOL_TachiAPIData]. + */ +export const SetRequestPermissions: RequestHandler = async (req, res, next) => { + if (req.session.tachi?.userID) { + req[SYMBOL_TachiAPIData] = { + userID: req.session.tachi.userID, + identifier: `Session-Key ${req.session.tachi.userID}`, + token: null, + permissions: AllPermissions, + }; + } + + const header = req.header("Authorization"); + + // if no auth was attempted, default to the guest token. + if (!header) { + req[SYMBOL_TachiAPIData] = GuestToken; + return next(); + } + + const { token, type } = SplitAuthorizationHeader(header); + + if (type !== "Bearer") { + return res.status(400).json({ + success: false, + description: "Invalid Authorization Type - Expected Bearer.", + }); + } + + if (!token) { + return res.status(401).json({ + success: false, + description: "Invalid token.", + }); + } + + const apiTokenData = await db["api-tokens"].findOne({ + token, + }); + + if (!apiTokenData) { + return res.status(401).json({ + success: false, + description: "The provided API token does not correspond with any key in the database.", + }); + } + + req[SYMBOL_TachiAPIData] = { + userID: apiTokenData.userID, + token, + permissions: apiTokenData.permissions, + identifier: apiTokenData.identifier, + }; + + return next(); +}; + +/** + * Returns a middleware that enforces the request has the necessary permissions. + * @param perms - Rest Parameter. The set of permissions necessary to use this endpoint. + * @returns A middleware function. + */ +export const RequirePermissions = + (...perms: APIPermissions[]): RequestHandler => + (req, res, next) => { + if (!req[SYMBOL_TachiAPIData]) { + logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`); + return res.status(500).json({ + success: false, + description: "An internal error has occured.", + }); + } + + const missingPerms = []; + for (const perm of perms) { + if (!req[SYMBOL_TachiAPIData]!.permissions[perm]) { + missingPerms.push(perm); + } + } + + if (missingPerms.length > 0) { + logger.info( + `IP ${req.ip} - userID ${ + req[SYMBOL_TachiAPIData].userID + } had insufficient permissions for request ${req.method} ${ + req.url + }. ${missingPerms.join(", ")}` + ); + return res.status(401).json({ + success: false, + description: `You are missing the following permissions necessary for this request: ${missingPerms.join( + ", " + )}`, + }); + } + + return next(); + }; + +export const RequireNotGuest: RequestHandler = (req, res, next) => { + if (!req[SYMBOL_TachiAPIData]) { + logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`); + return res.status(500).json({ + success: false, + description: "An internal error has occured.", + }); + } + + if (!req[SYMBOL_TachiAPIData].userID) { + logger.info(`Request to ${req.method} ${req.url} was attempted by guest.`); + return res.status(401).json({ + success: false, + description: "This endpoint requires authentication.", + }); + } + + return next(); +}; diff --git a/server/src/server/middleware/require-logged-in.test.ts b/server/src/server/middleware/require-logged-in.test.ts index dc8dbe512..87104524d 100644 --- a/server/src/server/middleware/require-logged-in.test.ts +++ b/server/src/server/middleware/require-logged-in.test.ts @@ -1,10 +1,10 @@ -import { RequireLoggedIn } from "./require-logged-in"; +import { RequireLoggedInSession } from "./require-logged-in"; import expMiddlewareMock from "express-request-mock"; import t from "tap"; t.test("#RequireLoggedIn", (t) => { t.test("Should reject users that are not logged in.", async (t) => { - const { res } = await expMiddlewareMock(RequireLoggedIn, { + const { res } = await expMiddlewareMock(RequireLoggedInSession, { session: { tachi: null, }, @@ -21,7 +21,7 @@ t.test("#RequireLoggedIn", (t) => { }); t.test("Should allow users that are logged in.", async (t) => { - const { res } = await expMiddlewareMock(RequireLoggedIn, { + const { res } = await expMiddlewareMock(RequireLoggedInSession, { session: { tachi: { userID: 1, diff --git a/server/src/server/middleware/require-logged-in.ts b/server/src/server/middleware/require-logged-in.ts index b6c4dc210..bcb3ea72e 100644 --- a/server/src/server/middleware/require-logged-in.ts +++ b/server/src/server/middleware/require-logged-in.ts @@ -1,9 +1,9 @@ -import { NextFunction, Request, Response } from "express"; +import { RequestHandler } from "express"; import CreateLogCtx from "../../lib/logger/logger"; const logger = CreateLogCtx(__filename); -export function RequireLoggedIn(req: Request, res: Response, next: NextFunction) { +export const RequireLoggedInSession: RequestHandler = (req, res, next) => { if (!req.session.tachi?.userID) { logger.info(`Received unauthorised request from ${req.ip} from ${req.originalUrl}`); @@ -14,4 +14,16 @@ export function RequireLoggedIn(req: Request, res: Response, next: NextFunction) } next(); -} +}; + +export const RequireNotLoggedInSession: RequestHandler = (req, res, next) => { + if (req.session.tachi?.userID) { + logger.info(`Dual log-in attempted from ${req.session.tachi.userID}`); + return res.status(409).json({ + success: false, + description: `You cannot perform this while logged in.`, + }); + } + + return next(); +}; diff --git a/server/src/server/middleware/type-require.ts b/server/src/server/middleware/type-require.ts index 7915d24ba..5a6ecfbc8 100644 --- a/server/src/server/middleware/type-require.ts +++ b/server/src/server/middleware/type-require.ts @@ -13,7 +13,7 @@ export const RequireBokutachi: RequestHandler = (req, res, next) => { return res.status(404).send({ success: false, - description: `The route ${req.route} is only available on Bokutachi.`, + description: `The route ${req.url} is only available on Bokutachi.`, }); }; @@ -29,6 +29,6 @@ export const RequireKamaitachi: RequestHandler = (req, res, next) => { return res.status(404).send({ success: false, - description: `The route ${req.route} is only available on Kamaitachi.`, + description: `The route ${req.url} is only available on Kamaitachi.`, }); }; diff --git a/server/src/server/router/api/v1/auth/auth.test.ts b/server/src/server/router/api/v1/auth/auth.test.ts index 09442c582..eda18bf7e 100644 --- a/server/src/server/router/api/v1/auth/auth.test.ts +++ b/server/src/server/router/api/v1/auth/auth.test.ts @@ -1,4 +1,4 @@ -import { CreateInviteCode, AddNewInvite, ReinstateInvite, ValidateCaptcha } from "./auth"; +import { AddNewInvite, ReinstateInvite, ValidateCaptcha } from "./auth"; import t from "tap"; import db from "../../../../../external/mongo/db"; import ResetDBState from "../../../../../test-utils/resets"; @@ -33,12 +33,6 @@ t.test("#ReinstateInvite", (t) => { t.end(); }); -t.test("#CreateInviteCode", (t) => { - t.match(CreateInviteCode(), /^[0-9a-f]{40}$/u, "Invite should be a 40 character hex string."); - - t.end(); -}); - t.test("#AddNewInvite", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index 5037ac898..4be777857 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -1,11 +1,6 @@ import crypto from "crypto"; import bcrypt from "bcrypt"; -import { - InviteCodeDocument, - PrivateUserDocument, - PublicAPIKeyDocument, - PublicUserDocument, -} from "tachi-common"; +import { InviteCodeDocument, PrivateUserDocument, PublicUserDocument } from "tachi-common"; import { InsertResult } from "monk"; import db from "../../../../../external/mongo/db"; import { GetNextCounterValue } from "../../../../../utils/db"; @@ -13,6 +8,7 @@ import CreateLogCtx from "../../../../../lib/logger/logger"; import { FormatUserDoc } from "../../../../../utils/user"; import nodeFetch from "../../../../../utils/fetch"; import { CAPTCHA_SECRET_KEY } from "../../../../../lib/setup/config"; +import { Random20Hex } from "../../../../../utils/misc"; const logger = CreateLogCtx(__filename); @@ -21,10 +17,6 @@ const BCRYPT_SALT_ROUNDS = 12; export const ValidatePassword = (self: unknown) => (typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more."; -export function CreateInviteCode(): string { - return crypto.randomBytes(20).toString("hex"); -} - /** * Compares a plaintext string of a users password to a hash. * @param plaintext The provided user input. @@ -49,7 +41,7 @@ export function ReinstateInvite(inviteDoc: InviteCodeDocument) { } export async function AddNewInvite(user: PublicUserDocument) { - const code = CreateInviteCode(); + const code = Random20Hex(); const result = await db.invites.insert({ code, @@ -104,9 +96,6 @@ export async function AddNewUser( customBanner: false, customPfp: false, lastSeen: Date.now(), // lol - permissions: { - admin: false, // lol (2) - }, }; return db.users.insert(userDoc); @@ -123,7 +112,8 @@ export async function ValidateCaptcha( if (r.status !== 200) { logger.verbose(`Failed GCaptcha response ${r.status}, ${r.body}`); + return false; } - return r.status === 200; + return true; } diff --git a/server/src/server/router/api/v1/auth/router.test.ts b/server/src/server/router/api/v1/auth/router.test.ts index c0abfe08a..bebf01dde 100644 --- a/server/src/server/router/api/v1/auth/router.test.ts +++ b/server/src/server/router/api/v1/auth/router.test.ts @@ -1,5 +1,4 @@ import t from "tap"; -import db from "../../../../../external/mongo/db"; import { CloseAllConnections } from "../../../../../test-utils/close-connections"; import mockApi from "../../../../../test-utils/mock-api"; import ResetDBState from "../../../../../test-utils/resets"; diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 34ff6e1d1..62aa586bc 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -16,7 +16,7 @@ import { import db from "../../../../../external/mongo/db"; import CreateLogCtx from "../../../../../lib/logger/logger"; import prValidate from "../../../../middleware/prudence-validate"; -import { RequireLoggedIn } from "../../../../middleware/require-logged-in"; +import { RequireLoggedInSession } from "../../../../middleware/require-logged-in"; const logger = CreateLogCtx(__filename); @@ -24,20 +24,6 @@ const router: Router = Router({ mergeParams: true }); const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u; -/** - * Utility for checking whether you are logged in or not. - * @name POST /api/v1/auth/status - */ -router.post("/status", RequireLoggedIn, (req, res) => - res.status(200).json({ - success: true, - description: "Logged In.", - body: { - userID: req.session.tachi!.userID, - }, - }) -); - /** * Logs in a user. * @name POST /api/v1/auth/login @@ -240,7 +226,7 @@ router.post( * Logs out the requesting user. * @name POST /api/v1/auth/logout */ -router.post("/logout", RequireLoggedIn, (req, res) => { +router.post("/logout", RequireLoggedInSession, (req, res) => { req.session.destroy(() => 0); return res.status(200).json({ diff --git a/server/src/server/router/api/v1/import/router.test.ts b/server/src/server/router/api/v1/import/router.test.ts index c2890df8d..5759913de 100644 --- a/server/src/server/router/api/v1/import/router.test.ts +++ b/server/src/server/router/api/v1/import/router.test.ts @@ -8,7 +8,7 @@ import { } from "../../../../../test-utils/test-data"; import { CloseAllConnections } from "../../../../../test-utils/close-connections"; import { RequireNeutralAuthentication } from "../../../../../test-utils/api-common"; -import { CreateFakeAuthCookie } from "../../../../../test-utils/fake-session"; +import { CreateFakeAuthCookie } from "../../../../../test-utils/fake-auth"; import ResetDBState from "../../../../../test-utils/resets"; import db from "../../../../../external/mongo/db"; import { SetIndexes } from "../../../../../../scripts/set-indexes"; diff --git a/server/src/server/router/api/v1/import/router.ts b/server/src/server/router/api/v1/import/router.ts index 19d470b94..2db1ed66f 100644 --- a/server/src/server/router/api/v1/import/router.ts +++ b/server/src/server/router/api/v1/import/router.ts @@ -5,7 +5,7 @@ import Prudence from "prudence"; import { GetUserWithIDGuaranteed } from "../../../../../utils/user"; import CreateLogCtx, { KtLogger } from "../../../../../lib/logger/logger"; import prValidate from "../../../../middleware/prudence-validate"; -import { RequireLoggedIn } from "../../../../middleware/require-logged-in"; +import { RequireLoggedInSession } from "../../../../middleware/require-logged-in"; import ScoreImportFatalError from "../../../../../lib/score-import/framework/score-importing/score-import-error"; import { SIXTEEN_MEGABTYES } from "../../../../../lib/constants/filesize"; import { ExpressWrappedScoreImportMain } from "../../../../../lib/score-import/framework/express-wrapper"; @@ -34,7 +34,7 @@ const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware( */ router.post( "/file", - RequireLoggedIn, + RequireLoggedInSession, RequireKamaitachi, // This is only useful for Kamaitachi, so. ParseMultipartScoredata, prValidate( diff --git a/server/src/server/router/api/v1/status/router.test.ts b/server/src/server/router/api/v1/status/router.test.ts index 538e2dcf9..bb62b2679 100644 --- a/server/src/server/router/api/v1/status/router.test.ts +++ b/server/src/server/router/api/v1/status/router.test.ts @@ -1,5 +1,5 @@ import { FormatVersion } from "../../../../../lib/constants/version"; -import { CreateFakeAuthCookie } from "../../../../../test-utils/fake-session"; +import { CreateFakeAuthCookie } from "../../../../../test-utils/fake-auth"; import mockApi from "../../../../../test-utils/mock-api"; import t from "tap"; import { CloseAllConnections } from "../../../../../test-utils/close-connections"; diff --git a/server/src/server/router/api/v1/status/router.ts b/server/src/server/router/api/v1/status/router.ts index 252519fec..c6e2097b4 100644 --- a/server/src/server/router/api/v1/status/router.ts +++ b/server/src/server/router/api/v1/status/router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { SYMBOL_TachiAPIData } from "../../../../../lib/constants/tachi"; import { FormatVersion } from "../../../../../lib/constants/version"; const router: Router = Router({ mergeParams: true }); @@ -15,6 +16,10 @@ router.get("/", (req, res) => { body: { serverTime: Date.now(), version: FormatVersion(), + // converts {foo: true, bar: false, baz: true} into [foo, baz] + permissions: Object.entries(req[SYMBOL_TachiAPIData].permissions) + .filter((e) => e[1]) + .map((e) => e[0]), echo, }, }); @@ -32,6 +37,10 @@ router.post("/", (req, res) => { body: { serverTime: Date.now(), version: FormatVersion(), + // converts {foo: true, bar: false, baz: true} into [foo, baz] + permissions: Object.entries(req[SYMBOL_TachiAPIData].permissions) + .filter((e) => e[1]) + .map((e) => e[0]), echo, }, }); diff --git a/server/src/server/router/ir/barbatos/router.test.ts b/server/src/server/router/ir/barbatos/router.test.ts index c790cc250..8eaabaeae 100644 --- a/server/src/server/router/ir/barbatos/router.test.ts +++ b/server/src/server/router/ir/barbatos/router.test.ts @@ -1,7 +1,6 @@ import t from "tap"; -import { RequireNeutralAuthentication } from "../../../../test-utils/api-common"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; -import { CreateFakeAuthCookie } from "../../../../test-utils/fake-session"; +import { CreateFakeAuthCookie } from "../../../../test-utils/fake-auth"; import ResetDBState from "../../../../test-utils/resets"; import mockApi from "../../../../test-utils/mock-api"; import { TestingBarbatosScore } from "../../../../test-utils/test-data"; @@ -12,9 +11,6 @@ t.test("POST /ir/barbatos/score/submit", async (t) => { t.beforeEach(ResetDBState); - // @TODO NEEDS TO USE PROPER AUTHENTICATION!!! - RequireNeutralAuthentication("/ir/barbatos/score/submit", "POST"); - t.test("Should import a valid score", async (t) => { const res = await mockApi .post("/ir/barbatos/score/submit") diff --git a/server/src/server/router/ir/barbatos/router.ts b/server/src/server/router/ir/barbatos/router.ts index a82ad8576..6c1dc656a 100644 --- a/server/src/server/router/ir/barbatos/router.ts +++ b/server/src/server/router/ir/barbatos/router.ts @@ -1,17 +1,20 @@ import { Router } from "express"; import { GetUserWithIDGuaranteed } from "../../../../utils/user"; -import { RequireLoggedIn } from "../../../middleware/require-logged-in"; import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; import { ParseBarbatosSingle } from "../../../../lib/score-import/import-types/ir/barbatos/parser"; +import { RequirePermissions } from "../../../middleware/auth"; +import { SYMBOL_TachiAPIData } from "../../../../lib/constants/tachi"; const router: Router = Router({ mergeParams: true }); +router.use(RequirePermissions("submit:score")); + /** * Submits a single score document from Barbatos clients. * @name POST /ir/barbatos/score/submit */ -router.post("/score/submit", RequireLoggedIn, async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID); +router.post("/score/submit", async (req, res) => { + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData]!.userID!); const responseData = await ExpressWrappedScoreImportMain( userDoc, diff --git a/server/src/server/router/ir/beatoraja/auth.test.ts b/server/src/server/router/ir/beatoraja/auth.test.ts index 22eb5f92f..990585fc1 100644 --- a/server/src/server/router/ir/beatoraja/auth.test.ts +++ b/server/src/server/router/ir/beatoraja/auth.test.ts @@ -1,15 +1,13 @@ import t from "tap"; -import { ValidateAuthToken, ValidateIRClientVersion } from "./auth"; +import { ValidateIRClientVersion } from "./auth"; import expMiddlewareMock from "express-request-mock"; -import ResetDBState from "../../../../test-utils/resets"; -import { SYMBOL_TachiData } from "../../../../lib/constants/tachi"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; t.test("#ValidateIRClientVersion", (t) => { t.test("Should reject clients that are not supported", async (t) => { const { res } = await expMiddlewareMock(ValidateIRClientVersion, { headers: { - "X-TachiIR-Version": "1.2.0", + "X-BokutachiIR-Version": "1.2.0", }, }); @@ -17,7 +15,7 @@ t.test("#ValidateIRClientVersion", (t) => { t.equal(res.statusCode, 400); t.equal(json.success, false); - t.match(json.description, /Invalid TachiIR client version/u); + t.match(json.description, /Invalid BokutachiIR client version/u); t.end(); }); @@ -29,7 +27,7 @@ t.test("#ValidateIRClientVersion", (t) => { t.equal(res.statusCode, 400); t.equal(json.success, false); - t.match(json.description, /Invalid TachiIR client version/u); + t.match(json.description, /Invalid BokutachiIR client version/u); t.end(); }); @@ -37,7 +35,7 @@ t.test("#ValidateIRClientVersion", (t) => { t.test("Should accept 2.0.0", async (t) => { const { res } = await expMiddlewareMock(ValidateIRClientVersion, { headers: { - "X-TachiIR-Version": "2.0.0", + "X-BokutachiIR-Version": "2.0.0", }, }); @@ -49,72 +47,4 @@ t.test("#ValidateIRClientVersion", (t) => { t.end(); }); -t.test("#ValidateAuthToken", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should reject invalid auth types", async (t) => { - const { res } = await expMiddlewareMock(ValidateAuthToken, { - headers: { - authorization: "NOTBEARER token", - }, - }); - - const json = res._getJSONData(); - - t.equal(res.statusCode, 400); - t.equal(json.success, false); - t.match(json.description, /Invalid Authorization Type/u); - - t.end(); - }); - - t.test("Should reject unknown auth tokens", async (t) => { - const { res } = await expMiddlewareMock(ValidateAuthToken, { - headers: { - authorization: "Bearer InvalidToken", - }, - }); - - const json = res._getJSONData(); - - t.equal(res.statusCode, 401); - t.equal(json.success, false); - t.match(json.description, /Unauthorised/u); - - t.end(); - }); - - t.test("Should reject no auth tokens", async (t) => { - const { res } = await expMiddlewareMock(ValidateAuthToken, {}); - - const json = res._getJSONData(); - - t.equal(res.statusCode, 400); - t.equal(json.success, false); - t.match(json.description, /No Authorization provided/u); - - t.end(); - }); - - t.test("Should allow existing auth tokens", async (t) => { - const { res, req } = await expMiddlewareMock(ValidateAuthToken, { - headers: { - authorization: "Bearer token", - }, - }); - - t.equal(res.statusCode, 200); - - t.hasStrict( - req[SYMBOL_TachiData]?.beatorajaAuthDoc, - { token: "token", userID: 1 }, - "Should attach the authDoc to the request TachiData" - ); - - t.end(); - }); - - t.end(); -}); - t.teardown(CloseAllConnections); diff --git a/server/src/server/router/ir/beatoraja/auth.ts b/server/src/server/router/ir/beatoraja/auth.ts index b7405b1f3..d6ae92ba6 100644 --- a/server/src/server/router/ir/beatoraja/auth.ts +++ b/server/src/server/router/ir/beatoraja/auth.ts @@ -1,51 +1,12 @@ -import { GenericAuthDocument } from "tachi-common"; import { RequestHandler } from "express"; -import db from "../../../../external/mongo/db"; -import { SplitAuthorizationHeader } from "../../../../utils/misc"; -import { AssignToReqTachiData } from "../../../../utils/req-tachi-data"; - -export const ValidateAuthToken: RequestHandler = async (req, res, next) => { - const header = req.header("Authorization"); - - if (!header) { - return res.status(400).json({ - success: false, - description: `No Authorization provided.`, - }); - } - - const { type, token } = SplitAuthorizationHeader(header); - - if (type !== "Bearer") { - return res.status(400).json({ - success: false, - description: `Invalid Authorization Type.`, - }); - } - - const beatorajaAuthDoc = (await db["beatoraja-auth-tokens"].findOne({ - token, - })) as GenericAuthDocument | null; - - if (!beatorajaAuthDoc) { - return res.status(401).json({ - success: false, - description: "Unauthorised.", - }); - } - - AssignToReqTachiData(req, { beatorajaAuthDoc }); - - return next(); -}; export const ValidateIRClientVersion: RequestHandler = (req, res, next) => { - const header = req.header("X-TachiIR-Version"); + const header = req.header("X-BokutachiIR-Version"); if (header !== "2.0.0") { return res.status(400).json({ success: false, - description: "Invalid TachiIR client version.", + description: "Invalid BokutachiIR client version.", }); } diff --git a/server/src/server/router/ir/beatoraja/charts/router.test.ts b/server/src/server/router/ir/beatoraja/charts/router.test.ts index cc73d4ca8..2c9267ee6 100644 --- a/server/src/server/router/ir/beatoraja/charts/router.test.ts +++ b/server/src/server/router/ir/beatoraja/charts/router.test.ts @@ -35,7 +35,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { const res = await mockApi .get(`/ir/beatoraja/charts/${GAZER_SHA256}/scores`) - .set("X-TachiIR-Version", "2.0.0") + .set("X-BokutachiIR-Version", "2.0.0") .set("Authorization", "Bearer token"); t.equal(res.status, 200); @@ -46,7 +46,7 @@ t.test("GET /ir/beatoraja/charts/:chartSHA256/scores", (t) => { t.test("Should return 404 if chart doesnt exist", async (t) => { const res = await mockApi .get(`/ir/beatoraja/charts/INVALID/scores`) - .set("X-TachiIR-Version", "2.0.0") + .set("X-BokutachiIR-Version", "2.0.0") .set("Authorization", "Bearer token"); t.equal(res.status, 404); diff --git a/server/src/server/router/ir/beatoraja/charts/router.ts b/server/src/server/router/ir/beatoraja/charts/router.ts index cfe210e2c..fc89f5208 100644 --- a/server/src/server/router/ir/beatoraja/charts/router.ts +++ b/server/src/server/router/ir/beatoraja/charts/router.ts @@ -1,7 +1,7 @@ import { ChartDocument, PBScoreDocument } from "tachi-common"; import { Router, RequestHandler } from "express"; import db from "../../../../../external/mongo/db"; -import { SYMBOL_TachiData } from "../../../../../lib/constants/tachi"; +import { SYMBOL_TachiAPIData, SYMBOL_TachiData } from "../../../../../lib/constants/tachi"; import { TachiPBScoreToBeatorajaFormat } from "./convert-scores"; import { AssignToReqTachiData } from "../../../../../utils/req-tachi-data"; @@ -41,7 +41,7 @@ router.get("/scores", async (req, res) => { // @optimisable - This should be solved with a couple queries and a hashmap. const beatorajaScores = await Promise.all( scores.map((e) => - TachiPBScoreToBeatorajaFormat(e, chart, req[SYMBOL_TachiData]!.beatorajaAuthDoc!.userID) + TachiPBScoreToBeatorajaFormat(e, chart, req[SYMBOL_TachiAPIData]!.userID!) ) ); diff --git a/server/src/server/router/ir/beatoraja/router.test.ts b/server/src/server/router/ir/beatoraja/router.test.ts index f433c9b17..8f4c38264 100644 --- a/server/src/server/router/ir/beatoraja/router.test.ts +++ b/server/src/server/router/ir/beatoraja/router.test.ts @@ -6,119 +6,26 @@ import ResetDBState from "../../../../test-utils/resets"; import { GetKTDataJSON } from "../../../../test-utils/test-data"; import deepmerge from "deepmerge"; -t.test("POST /ir/beatoraja/login", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should correctly return a token for correct credentials", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/login") - .send({ - username: "test_zkldi", - password: "password", - }) - .set("X-TachiIR-Version", "2.0.0"); - - t.equal(res.status, 200); - t.match(res.body.body.token, /^[0-9a-f]{40}$/u, "Should return a 20 byte long token."); - - t.end(); - }); - - t.test("Should reject invalid passwords", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/login") - .send({ - username: "test_zkldi", - password: "invalid", - }) - .set("X-TachiIR-Version", "2.0.0"); - - t.equal(res.status, 401); - t.equal(res.body.success, false); - t.equal(res.body.body, undefined); - - t.end(); - }); - - t.test("Should reject X-TachiIR-Versions", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/login") - .send({ - username: "test_zkldi", - password: "password", - }) - .set("X-TachiIR-Version", "1.2.0"); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal(res.body.body, undefined); - - const res2 = await mockApi.post("/ir/beatoraja/login").send({ - username: "test_zkldi", - password: "password", - }); - - t.equal(res2.status, 400); - t.equal(res2.body.success, false); - t.equal(res2.body.body, undefined); - - t.end(); - }); - - t.test("Should reject users it cannot find.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/login") - .send({ - username: "invalid", - password: "password", - }) - .set("X-TachiIR-Version", "2.0.0"); - - t.equal(res.status, 404); - t.equal(res.body.success, false); - t.equal(res.body.body, undefined); - - t.end(); - }); - - t.test("Should reject invalid request bodies.", async (t) => { - const res = await mockApi - .post("/ir/beatoraja/login") - .send({}) - .set("X-TachiIR-Version", "2.0.0"); - - t.equal(res.status, 400); - t.equal(res.body.success, false); - t.equal(res.body.body, undefined); - - const res2 = await mockApi - .post("/ir/beatoraja/login") - .send({ - username: { $where: "for(;;){}" }, - password: 123, - }) - .set("X-TachiIR-Version", "2.0.0"); - - t.equal(res2.status, 400); - t.equal(res2.body.success, false); - t.equal(res2.body.body, undefined); - - t.end(); - }); - - t.end(); -}); - t.test("POST /ir/beatoraja/submit-score", (t) => { t.beforeEach(ResetDBState); + t.beforeEach(() => + db["api-tokens"].insert({ + userID: 1, + identifier: "Mock API Beatoraja Token", + permissions: { + "submit:score": true, + }, + token: "mock_token", + }) + ); const scoreReq = GetKTDataJSON("./beatoraja/base.json"); t.test("Should import a valid score.", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(scoreReq); t.equal(res.status, 200); @@ -154,8 +61,8 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { t.test("Should return an error if invalid client.", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(scoreReq, { client: "INVALID" })); t.equal(res.status, 400); @@ -169,8 +76,8 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { t.test("Should return an error if invalid score.", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(scoreReq, { score: { exscore: -1 } })); t.equal(res.status, 400); @@ -184,8 +91,8 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { t.test("Should return an error if invalid chart.", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(scoreReq, { chart: { title: null } })); t.equal(res.status, 400); @@ -254,12 +161,22 @@ const courseScore = { t.test("POST /ir/beatoraja/submit-course", (t) => { t.beforeEach(ResetDBState); + t.beforeEach(() => + db["api-tokens"].insert({ + userID: 1, + identifier: "Mock API Beatoraja Token", + permissions: { + "submit:score": true, + }, + token: "mock_token", + }) + ); t.test("Should accept a valid clear", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(courseScore); t.equal(res.status, 200); @@ -276,8 +193,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should silently reject a fail", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(courseScore, { score: { clear: "Failed" } })); t.equal(res.status, 200); @@ -290,8 +207,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject scores with no charts", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(courseScore, { course: { charts: [] } }, { arrayMerge: (d, s) => s })); t.equal(res.status, 400); @@ -304,8 +221,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject scores with invalid chart documents", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send(deepmerge(courseScore, { course: { charts: [1, 2, 3, 4] } })); t.equal(res.status, 400); @@ -318,8 +235,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject scores with too many chart documents", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge(courseScore, { course: { charts: [{ md5: "a" }, { md5: "a" }, { md5: "a" }, { md5: "a" }] }, @@ -336,8 +253,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject scores not on LN mode", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge(courseScore, { score: { @@ -356,8 +273,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject too few constraints", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge( courseScore, @@ -380,8 +297,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject non-array constraints", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge( courseScore, @@ -404,8 +321,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject too many constraints", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge( courseScore, @@ -428,8 +345,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject invalid constraints", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge( courseScore, @@ -452,8 +369,8 @@ t.test("POST /ir/beatoraja/submit-course", (t) => { t.test("Should reject invalid constraints for 3", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-course") - .set("X-TachiIR-Version", "2.0.0") - .set("Authorization", "Bearer token") + .set("X-BokutachiIR-Version", "2.0.0") + .set("Authorization", "Bearer mock_token") .send( deepmerge( courseScore, diff --git a/server/src/server/router/ir/beatoraja/router.ts b/server/src/server/router/ir/beatoraja/router.ts index d55830d1c..f6e2e3929 100644 --- a/server/src/server/router/ir/beatoraja/router.ts +++ b/server/src/server/router/ir/beatoraja/router.ts @@ -1,18 +1,12 @@ import { Router } from "express"; import db from "../../../../external/mongo/db"; -import { SYMBOL_TachiData } from "../../../../lib/constants/tachi"; +import { SYMBOL_TachiAPIData } from "../../../../lib/constants/tachi"; import CreateLogCtx, { KtLogger } from "../../../../lib/logger/logger"; import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; import { ParseBeatorajaSingle } from "../../../../lib/score-import/import-types/ir/beatoraja/parser"; import { UpdateClassIfGreater } from "../../../../utils/class"; -import { Random20Hex } from "../../../../utils/misc"; -import { - GetUserWithIDGuaranteed, - PRIVATEINFO_GetUserCaseInsensitive, -} from "../../../../utils/user"; -import prValidate from "../../../middleware/prudence-validate"; -import { PasswordCompare } from "../../api/v1/auth/auth"; -import { ValidateAuthToken, ValidateIRClientVersion } from "./auth"; +import { GetUserWithIDGuaranteed } from "../../../../utils/user"; +import { ValidateIRClientVersion } from "./auth"; import chartsRouter from "./charts/router"; const logger = CreateLogCtx(__filename); @@ -21,63 +15,13 @@ const router: Router = Router({ mergeParams: true }); router.use(ValidateIRClientVersion); -/** - * Takes a username and password and returns a unique auth token for the user - * to make ir requests with. - * @name POST /ir/beatoraja/login - */ -router.post( - "/login", - prValidate({ - username: "string", - password: "string", - }), - async (req, res) => { - const userDoc = await PRIVATEINFO_GetUserCaseInsensitive(req.body.username); - - if (!userDoc) { - return res.status(404).json({ - success: false, - description: `The user ${req.body.username} does not exist.`, - }); - } - - const validPassword = await PasswordCompare(req.body.password, userDoc.password); - - if (!validPassword) { - return res.status(401).json({ - success: false, - description: `Invalid password.`, - }); - } - - // User is who they claim to be. - const token = Random20Hex(); - - await db["beatoraja-auth-tokens"].insert({ - userID: userDoc.id, - token, - }); - - return res.status(200).json({ - success: true, - description: `Successfully created auth token.`, - body: { - token, - }, - }); - } -); - -router.use(ValidateAuthToken); - /** * Submits a beatoraja score to Kamaitachi. If the chart is unavailable, * store it as a new chart alongside the new score. * @name POST /ir/beatoraja/submit-score */ router.post("/submit-score", async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiData]!.beatorajaAuthDoc!.userID); + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData]!.userID!); const ParserFunction = (logger: KtLogger) => ParseBeatorajaSingle(req.body, logger); @@ -220,7 +164,7 @@ router.post("/submit-course", async (req, res) => { }); } - const userID = req[SYMBOL_TachiData]!.beatorajaAuthDoc!.userID; + const userID = req[SYMBOL_TachiAPIData]!.userID!; const result = await UpdateClassIfGreater( userID, diff --git a/server/src/server/router/ir/chunitachi/router.test.ts b/server/src/server/router/ir/chunitachi/router.test.ts index b66a64c02..c120cee3b 100644 --- a/server/src/server/router/ir/chunitachi/router.test.ts +++ b/server/src/server/router/ir/chunitachi/router.test.ts @@ -1,26 +1,22 @@ import t from "tap"; import db from "../../../../external/mongo/db"; -import { RequireNeutralAuthentication } from "../../../../test-utils/api-common"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; -import { CreateFakeAuthCookie } from "../../../../test-utils/fake-session"; +import { InsertFakeTokenWithAllPerms } from "../../../../test-utils/fake-auth"; import mockApi from "../../../../test-utils/mock-api"; import ResetDBState from "../../../../test-utils/resets"; import { GetKTDataJSON } from "../../../../test-utils/test-data"; import deepmerge from "deepmerge"; -t.test("POST /ir/chunitachi/import", async (t) => { +t.test("POST /ir/chunitachi/import", (t) => { t.beforeEach(ResetDBState); - - const cookie = await CreateFakeAuthCookie(mockApi); - - RequireNeutralAuthentication("/ir/chunitachi/import", "POST"); + t.beforeEach(InsertFakeTokenWithAllPerms("mock_token")); const chunitachiBody = GetKTDataJSON("./batch-manual/chunitachi.json"); t.test("Should work for CHUNITACHI requests", async (t) => { const res = await mockApi .post("/ir/chunitachi/import") - .set("Cookie", cookie) + .set("Authorization", `Bearer mock_token`) .send(chunitachiBody); t.equal(res.body.success, true, "Should be successful"); @@ -35,7 +31,10 @@ t.test("POST /ir/chunitachi/import", async (t) => { }); t.test("Should reject invalid batch-manual", async (t) => { - const res = await mockApi.post("/ir/chunitachi/import").set("Cookie", cookie).send({}); + const res = await mockApi + .post("/ir/chunitachi/import") + .set("Authorization", `Bearer mock_token`) + .send({}); t.equal(res.body.success, false, "Should not be successful"); @@ -45,7 +44,7 @@ t.test("POST /ir/chunitachi/import", async (t) => { t.test("Should reject batch-manual requests if game is not chunithm", async (t) => { const res = await mockApi .post("/ir/chunitachi/import") - .set("Cookie", cookie) + .set("Authorization", `Bearer mock_token`) .send(deepmerge(chunitachiBody, { head: { game: "iidx" } })); t.equal(res.body.success, false, "Should not be successful"); @@ -56,7 +55,7 @@ t.test("POST /ir/chunitachi/import", async (t) => { t.test("Should reject batch-manual requests if service is not Chunitachi", async (t) => { const res = await mockApi .post("/ir/chunitachi/import") - .set("Cookie", cookie) + .set("Authorization", `Bearer mock_token`) .send(deepmerge(chunitachiBody, { head: { service: "foo bar" } })); t.equal(res.body.success, false, "Should not be successful"); diff --git a/server/src/server/router/ir/chunitachi/router.ts b/server/src/server/router/ir/chunitachi/router.ts index 81143ec48..0727c50a7 100644 --- a/server/src/server/router/ir/chunitachi/router.ts +++ b/server/src/server/router/ir/chunitachi/router.ts @@ -1,8 +1,9 @@ import { Router } from "express"; import { GetUserWithIDGuaranteed } from "../../../../utils/user"; -import { RequireLoggedIn } from "../../../middleware/require-logged-in"; import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; import ParseDirectManual from "../../../../lib/score-import/import-types/ir/direct-manual/parser"; +import { RequirePermissions } from "../../../middleware/auth"; +import { SYMBOL_TachiAPIData } from "../../../../lib/constants/tachi"; const router: Router = Router({ mergeParams: true }); @@ -10,8 +11,8 @@ const router: Router = Router({ mergeParams: true }); * Submits a single score document from Chunitachi clients. * @name POST /ir/chunitachi/score/submit */ -router.post("/import", RequireLoggedIn, async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID); +router.post("/import", RequirePermissions("submit:score"), async (req, res) => { + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData].userID!); if (req.body?.head?.game !== "chunithm") { return res.status(400).json({ diff --git a/server/src/server/router/ir/direct-manual/router.test.ts b/server/src/server/router/ir/direct-manual/router.test.ts index 690422678..fe2fb8863 100644 --- a/server/src/server/router/ir/direct-manual/router.test.ts +++ b/server/src/server/router/ir/direct-manual/router.test.ts @@ -2,7 +2,7 @@ import t from "tap"; import db from "../../../../external/mongo/db"; import { RequireNeutralAuthentication } from "../../../../test-utils/api-common"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; -import { CreateFakeAuthCookie } from "../../../../test-utils/fake-session"; +import { CreateFakeAuthCookie } from "../../../../test-utils/fake-auth"; import mockApi from "../../../../test-utils/mock-api"; import ResetDBState from "../../../../test-utils/resets"; import { GetKTDataJSON } from "../../../../test-utils/test-data"; diff --git a/server/src/server/router/ir/direct-manual/router.ts b/server/src/server/router/ir/direct-manual/router.ts index 46f93f648..110997ad1 100644 --- a/server/src/server/router/ir/direct-manual/router.ts +++ b/server/src/server/router/ir/direct-manual/router.ts @@ -1,8 +1,10 @@ import { Router } from "express"; import { GetUserWithIDGuaranteed } from "../../../../utils/user"; -import { RequireLoggedIn } from "../../../middleware/require-logged-in"; +import { RequireLoggedInSession } from "../../../middleware/require-logged-in"; import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; import ParseDirectManual from "../../../../lib/score-import/import-types/ir/direct-manual/parser"; +import { SYMBOL_TachiAPIData } from "../../../../lib/constants/tachi"; +import { RequirePermissions } from "../../../middleware/auth"; const router: Router = Router({ mergeParams: true }); @@ -10,19 +12,24 @@ const router: Router = Router({ mergeParams: true }); * Imports scores in ir/json:direct-manual form. * @name POST /ir/direct-manual/import */ -router.post("/import", RequireLoggedIn, async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID); +router.post( + "/import", + RequirePermissions("submit:score"), + RequireLoggedInSession, + async (req, res) => { + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData].userID!); - const intent = req.header("X-User-Intent"); + const intent = req.header("X-User-Intent"); - const responseData = await ExpressWrappedScoreImportMain( - userDoc, - !!intent, - "ir/direct-manual", - (logger) => ParseDirectManual(req.body, logger) - ); + const responseData = await ExpressWrappedScoreImportMain( + userDoc, + !!intent, + "ir/direct-manual", + (logger) => ParseDirectManual(req.body, logger) + ); - return res.status(responseData.statusCode).json(responseData.body); -}); + return res.status(responseData.statusCode).json(responseData.body); + } +); export default router; diff --git a/server/src/server/router/ir/fervidex/router.test.ts b/server/src/server/router/ir/fervidex/router.test.ts index 08c763c69..c2732782e 100644 --- a/server/src/server/router/ir/fervidex/router.test.ts +++ b/server/src/server/router/ir/fervidex/router.test.ts @@ -1,7 +1,7 @@ import t from "tap"; import db from "../../../../external/mongo/db"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; -import { CreateFakeAuthCookie } from "../../../../test-utils/fake-session"; +import { CreateFakeAuthCookie } from "../../../../test-utils/fake-auth"; import mockApi from "../../../../test-utils/mock-api"; import ResetDBState from "../../../../test-utils/resets"; import { GetKTDataJSON } from "../../../../test-utils/test-data"; diff --git a/server/src/server/router/ir/fervidex/router.ts b/server/src/server/router/ir/fervidex/router.ts index cbe0119b7..6538e4f6d 100644 --- a/server/src/server/router/ir/fervidex/router.ts +++ b/server/src/server/router/ir/fervidex/router.ts @@ -3,12 +3,14 @@ import { UpdateClassIfGreater } from "../../../../utils/class"; import { GetUserWithIDGuaranteed } from "../../../../utils/user"; import { ParseEA3SoftID } from "../../../../utils/ea3id"; import { EXT_HEROIC_VERSE, MODEL_INFINITAS_2, REV_2DXBMS } from "../../../../lib/constants/ea3id"; -import { RequireLoggedIn } from "../../../middleware/require-logged-in"; +import { RequireLoggedInSession } from "../../../middleware/require-logged-in"; import { ExpressWrappedScoreImportMain } from "../../../../lib/score-import/framework/express-wrapper"; import { ParseFervidexStatic } from "../../../../lib/score-import/import-types/ir/fervidex-static/parser"; import { ParseFervidexSingle } from "../../../../lib/score-import/import-types/ir/fervidex/parser"; import { Playtypes, integer } from "tachi-common"; import CreateLogCtx from "../../../../lib/logger/logger"; +import { SYMBOL_TachiAPIData } from "../../../../lib/constants/tachi"; +import { RequirePermissions } from "../../../middleware/auth"; const logger = CreateLogCtx(__filename); @@ -19,7 +21,7 @@ const ValidateFervidexHeader: RequestHandler = (req, res, next) => { if (!agent) { logger.debug( - `Rejected fervidex client with no agent from user ${req.session.tachi!.userID}.` + `Rejected fervidex client with no agent from user ${req[SYMBOL_TachiAPIData].userID!}.` ); return res.status(400).json({ success: false, @@ -29,9 +31,9 @@ const ValidateFervidexHeader: RequestHandler = (req, res, next) => { if (!agent.startsWith("fervidex/")) { logger.info( - `Rejected fervidex client with invalid agent ${agent} from user ${ - req.session.tachi!.userID - }.` + `Rejected fervidex client with invalid agent ${agent} from user ${req[ + SYMBOL_TachiAPIData + ].userID!}.` ); return res.status(400).json({ success: false, @@ -43,9 +45,9 @@ const ValidateFervidexHeader: RequestHandler = (req, res, next) => { if (!versions.every((e) => !Number.isNaN(e))) { logger.info( - `Rejected fervidex client with agent ${agent} for NaN-like versions from user ${ - req.session.tachi!.userID - }.` + `Rejected fervidex client with agent ${agent} for NaN-like versions from user ${req[ + SYMBOL_TachiAPIData + ].userID!}.` ); return res.status(400).json({ success: false, @@ -55,7 +57,9 @@ const ValidateFervidexHeader: RequestHandler = (req, res, next) => { // version.minor if (versions[1] < 3) { - logger.debug(`Rejected outdated fervidex client from user ${req.session.tachi!.userID}.`); + logger.debug( + `Rejected outdated fervidex client from user ${req[SYMBOL_TachiAPIData].userID!}.` + ); return res.status(400).json({ success: false, description: `Versions of fervidex < 1.3.0 are not supported.`, @@ -69,7 +73,9 @@ const RequireInf2ModelHeader: RequestHandler = (req, res, next) => { const swModel = req.header("X-Software-Model"); if (!swModel) { - logger.debug(`Rejected empty X-Software-Model from user ${req.session.tachi!.userID}.`); + logger.debug( + `Rejected empty X-Software-Model from user ${req[SYMBOL_TachiAPIData].userID!}.` + ); return res.status(400).json({ success: false, description: `Invalid X-Software-Model.`, @@ -80,7 +86,7 @@ const RequireInf2ModelHeader: RequestHandler = (req, res, next) => { const softID = ParseEA3SoftID(swModel); if (softID.model !== MODEL_INFINITAS_2) { - logger.debug(`Rejected non-inf2 model from user ${req.session.tachi!.userID}.`); + logger.debug(`Rejected non-inf2 model from user ${req[SYMBOL_TachiAPIData].userID!}.`); return res.status(400).send({ success: false, description: "This endpoint is only available for INF2 clients.", @@ -101,7 +107,9 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => { const swModel = req.header("X-Software-Model"); if (!swModel) { - logger.debug(`Rejected empty X-Software Model from user ${req.session.tachi!.userID}.`); + logger.debug( + `Rejected empty X-Software Model from user ${req[SYMBOL_TachiAPIData].userID!}.` + ); return res.status(400).json({ success: false, description: `Invalid X-Software-Model.`, @@ -124,9 +132,8 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => { if (softID.ext !== EXT_HEROIC_VERSE) { logger.info( - `Rejected invalid Software Model ${softID.ext} from user ${ - req.session.tachi!.userID - }.` + `Rejected invalid Software Model ${softID.ext} from user ${req[SYMBOL_TachiAPIData] + .userID!}.` ); return res.status(400).json({ success: false, @@ -144,7 +151,7 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => { return next(); }; -router.use(RequireLoggedIn, ValidateFervidexHeader, ValidateModelHeader); +router.use(RequirePermissions("submit:score"), ValidateFervidexHeader, ValidateModelHeader); /** * Submits all of a users data to Kamaitachi. This data is extremely minimal, @@ -155,7 +162,7 @@ router.use(RequireLoggedIn, ValidateFervidexHeader, ValidateModelHeader); * @name POST /ir/fervidex/profile/submit */ router.post("/profile/submit", RequireInf2ModelHeader, async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID); + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData].userID!); const headers = { // guaranteed to exist because of RequireInf2ModelHeader @@ -180,7 +187,7 @@ router.post("/profile/submit", RequireInf2ModelHeader, async (req, res) => { * @name POST /ir/fervidex/score/submit */ router.post("/score/submit", ValidateModelHeader, async (req, res) => { - const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID); + const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIData].userID!); const model = req.header("X-Software-Model"); @@ -243,7 +250,7 @@ router.post("/class/submit", ValidateModelHeader, async (req, res) => { const playtype: Playtypes["iidx"] = req.body.play_style === 0 ? "SP" : "DP"; const r = await UpdateClassIfGreater( - req.session.tachi!.userID, + req[SYMBOL_TachiAPIData].userID!, "iidx", playtype, "dan", diff --git a/server/src/server/router/ir/usc/router.ts b/server/src/server/router/ir/usc/router.ts index 8cabff9c7..3c86ff9eb 100644 --- a/server/src/server/router/ir/usc/router.ts +++ b/server/src/server/router/ir/usc/router.ts @@ -1,6 +1,6 @@ import { Router, RequestHandler } from "express"; import { FindChartOnSHA256 } from "../../../../utils/queries/charts"; -import { SYMBOL_TachiData } from "../../../../lib/constants/tachi"; +import { SYMBOL_TachiAPIData, SYMBOL_TachiData } from "../../../../lib/constants/tachi"; import db from "../../../../external/mongo/db"; import { ChartDocument, @@ -19,7 +19,7 @@ import { CreateMulterSingleUploadMiddleware } from "../../../middleware/multer-u import { AssignToReqTachiData } from "../../../../utils/req-tachi-data"; import { StoreCDN } from "../../../../lib/cdn/cdn"; import { ONE_MEGABYTE } from "../../../../lib/constants/filesize"; -import crypto from "crypto"; +import { RequirePermissions } from "../../../middleware/auth"; const logger = CreateLogCtx(__filename); @@ -40,43 +40,6 @@ const STATUS_CODES = { // as the HTTP code is used to determine whether the server received the request properly, // rather than the result of the request. -const ValidateUSCRequest: RequestHandler = async (req, res, next) => { - const token = req.header("Authorization"); - - if (!token) { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "No auth token provided.", - }); - } - - const splitToken = token.split(" "); - - if (splitToken.length !== 2 || splitToken[0] !== "Bearer") { - return res.status(200).json({ - statusCode: STATUS_CODES.BAD_REQ, - description: "Invalid Authorization Header. Expected Bearer ", - }); - } - - const uscAuthDoc = await db["usc-auth-tokens"].findOne({ - token: splitToken[1], - }); - - if (!uscAuthDoc) { - return res.status(200).json({ - statusCode: STATUS_CODES.UNAUTH, - description: "Unauthorized.", - }); - } - - AssignToReqTachiData(req, { uscAuthDoc }); - - return next(); -}; - -router.use(ValidateUSCRequest); - /** * Used to check your connection to the server, and receive some basic information. * https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html @@ -88,7 +51,7 @@ router.get("/", (req, res) => description: "IR Request Successful.", body: { serverTime: Math.floor(Date.now() / 1000), - serverName: "Kamaitachi BLACK", + serverName: "Bokutachi", irVersion: "0.3.1-a", }, }) @@ -235,7 +198,7 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => * https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html * @name POST /ir/usc/scores */ -router.post("/scores", async (req, res) => { +router.post("/scores", RequirePermissions("submit:score"), async (req, res) => { if (typeof req.body.chart !== "object" || req.body.chart === null) { return res.status(200).json({ statusCode: STATUS_CODES.BAD_REQ, @@ -262,10 +225,10 @@ router.post("/scores", async (req, res) => { }); } - const userDoc = await GetUserWithID(req[SYMBOL_TachiData]!.uscAuthDoc!.userID); + const userDoc = await GetUserWithID(req[SYMBOL_TachiAPIData]!.userID!); if (!userDoc) { - logger.severe(`User ${req[SYMBOL_TachiData]!.uscAuthDoc!.userID} as no parent userDoc?`); + logger.severe(`User ${req[SYMBOL_TachiAPIData]!.userID!} as no parent userDoc?`); return res.status(200).json({ statusCode: STATUS_CODES.SERVER_ERROR, description: "An internal server error has occured.", @@ -317,6 +280,7 @@ router.post("/scores", async (req, res) => { */ router.post( "/replays", + RequirePermissions("submit:score"), CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger), async (req, res) => { if (typeof req.body.identifier !== "string") { @@ -334,7 +298,7 @@ router.post( } const correspondingScore = await db.scores.findOne({ - userID: req[SYMBOL_TachiData]!.uscAuthDoc!.userID, + userID: req[SYMBOL_TachiAPIData]!.userID!, game: "usc", scoreID: req.body.identifier, }); diff --git a/server/src/server/router/router.ts b/server/src/server/router/router.ts index e6cb0f8a7..a5c2617c4 100644 --- a/server/src/server/router/router.ts +++ b/server/src/server/router/router.ts @@ -1,11 +1,13 @@ import { Router } from "express"; +import { SetRequestPermissions } from "../middleware/auth"; import { RateLimitMiddleware } from "../middleware/rate-limiter"; import apiRouterV1 from "./api/v1/router"; import irRouter from "./ir/router"; const router: Router = Router({ mergeParams: true }); -router.use(RateLimitMiddleware); +// Add APIAuth and RateLimiting +router.use(SetRequestPermissions, RateLimitMiddleware); router.use("/api/v1", apiRouterV1); router.use("/ir", irRouter); diff --git a/server/src/test-utils/fake-session.ts b/server/src/test-utils/fake-auth.ts similarity index 65% rename from server/src/test-utils/fake-session.ts rename to server/src/test-utils/fake-auth.ts index 7667f0983..1ad752638 100644 --- a/server/src/test-utils/fake-session.ts +++ b/server/src/test-utils/fake-auth.ts @@ -1,6 +1,8 @@ import ResetDBState from "./resets"; import CreateLogCtx from "../lib/logger/logger"; import supertest from "supertest"; +import { AllPermissions } from "../server/middleware/auth"; +import db from "../external/mongo/db"; const logger = CreateLogCtx(__filename); @@ -21,3 +23,13 @@ export async function CreateFakeAuthCookie(mockApi: supertest.SuperTest + db["api-tokens"].insert({ + userID: 1, + identifier: "Mock API Token", + permissions: AllPermissions, + token, + }); +} diff --git a/server/src/test-utils/mock-db/api-tokens.json b/server/src/test-utils/mock-db/api-tokens.json new file mode 100644 index 000000000..17cf24b8f --- /dev/null +++ b/server/src/test-utils/mock-db/api-tokens.json @@ -0,0 +1,10 @@ +[ + { + "userID": 1, + "token": "fake_api_token", + "permissions": { + "submit:goal": true + }, + "identifier": "Mock Token For Testing" + } +] \ No newline at end of file diff --git a/server/src/test-utils/mock-db/beatoraja-auth-tokens.json b/server/src/test-utils/mock-db/beatoraja-auth-tokens.json deleted file mode 100644 index b2916f1dd..000000000 --- a/server/src/test-utils/mock-db/beatoraja-auth-tokens.json +++ /dev/null @@ -1,4 +0,0 @@ -[{ - "userID": 1, - "token": "token" -}] \ No newline at end of file diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index f6e161f3c..35e6ecab1 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -1,4 +1,4 @@ -import { integer, GenericAuthDocument, ChartDocument } from "tachi-common"; +import { integer, ChartDocument } from "tachi-common"; declare module "express-session" { // Inject additional properties on express-session @@ -33,9 +33,7 @@ export type EmptyObject = Record; * Data that may be monkey-patched onto req.tachi. This holds things such as middleware results. */ export interface TachiRequestData { - uscAuthDoc?: GenericAuthDocument; uscChartDoc?: ChartDocument<"usc:Single">; - beatorajaAuthDoc?: GenericAuthDocument; beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">; }