diff --git a/app/features/admin/AdminRepository.server.test.ts b/app/features/admin/AdminRepository.server.test.ts index f01f8aadc..60e006045 100644 --- a/app/features/admin/AdminRepository.server.test.ts +++ b/app/features/admin/AdminRepository.server.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, test } from "vitest"; +import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory"; import * as BuildFactory from "~/db/seed/factories/BuildFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; import * as TrophyFactory from "~/db/seed/factories/TrophyFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { db } from "~/db/sql"; +import * as ApiRepository from "~/features/api/ApiRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { databaseTimestampNow } from "~/utils/dates"; import * as AdminRepository from "./AdminRepository.server"; @@ -322,6 +324,29 @@ describe("banUser", () => { expect(modInfo?.banLogs[0].bannedReason).toBe("First ban"); expect(modInfo?.banLogs[1].bannedReason).toBe("Updated ban reason"); }); + + test("revokes the banned user's API tokens", async () => { + await ApiTokenFactory.create({ userId: users.id(1), type: "read" }); + await ApiTokenFactory.create({ userId: users.id(1), type: "write" }); + await ApiTokenFactory.create({ userId: users.id(2), type: "read" }); + + await AdminRepository.banUser({ + userId: users.id(1), + banned: 1, + bannedReason: "Test ban", + bannedByUserId: users.id(2), + }); + + expect( + await ApiRepository.findTokenByUserId(users.id(1), "read"), + ).toBeUndefined(); + expect( + await ApiRepository.findTokenByUserId(users.id(1), "write"), + ).toBeUndefined(); + expect( + await ApiRepository.findTokenByUserId(users.id(2), "read"), + ).toBeDefined(); + }); }); describe("unbanUser", () => { diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index adb89b184..5e85018de 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -399,6 +399,7 @@ export async function findAllBannedUsers() { return result; } +/** Bans the user, logging it unless it's an automatic ban. Revokes their API tokens. */ export function banUser({ userId, banned, @@ -433,6 +434,8 @@ export function banUser({ }) .execute(); } + + await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute(); }); } diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index b27425362..dea74f36e 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; +import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -136,6 +137,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); await refreshBannedCache(); + await refreshApiTokensCache(); sendUserBannedWebhook({ bannedUser, diff --git a/app/features/api-public/api-auth-middleware.server.test.ts b/app/features/api-public/api-auth-middleware.server.test.ts new file mode 100644 index 000000000..7cd69ff06 --- /dev/null +++ b/app/features/api-public/api-auth-middleware.server.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as ApiTokenFactory from "~/db/seed/factories/ApiTokenFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { refreshBannedCache } from "~/features/ban/core/banned.server"; +import { apiAuthMiddleware } from "./api-auth-middleware.server"; +import { refreshApiTokensCache } from "./api-public-utils.server"; + +const users = UserFactory.pool(); +const allowedUserId = () => users.id(1); +const bannedUserId = () => users.id(2); + +const NEXT_RESPONSE_BODY = "next() reached"; + +const callMiddleware = ( + token: string, + { method = "GET" }: { method?: string } = {}, +) => + apiAuthMiddleware( + { + request: new Request("http://app.com/api/tournament/1/teams", { + method, + headers: { Authorization: `Bearer ${token}` }, + }), + context: {}, + }, + async () => new Response(NEXT_RESPONSE_BODY), + ); + +const tokenOf = async (userId: number) => { + const { token } = await ApiTokenFactory.create({ userId, type: "write" }); + await refreshApiTokensCache(); + + return token; +}; + +describe("apiAuthMiddleware", () => { + beforeEach(async () => { + await users.create(2, null, { roles: ["API_ACCESSER"] }); + await UserFactory.grant(bannedUserId(), { + ban: { banned: 1, bannedReason: null, bannedByUserId: null }, + }); + await refreshBannedCache(); + }); + + test("lets a token of a user in good standing through", async () => { + const response = await callMiddleware(await tokenOf(allowedUserId())); + + expect(await response.text()).toBe(NEXT_RESPONSE_BODY); + }); + + test.each(["GET", "POST"])( + "rejects a banned user's token (%s)", + async (method) => { + const response = await callMiddleware(await tokenOf(bannedUserId()), { + method, + }); + + expect(response.status).toBe(403); + }, + ); +}); diff --git a/app/features/api-public/api-auth-middleware.server.ts b/app/features/api-public/api-auth-middleware.server.ts index 1b70535a2..f76ab561a 100644 --- a/app/features/api-public/api-auth-middleware.server.ts +++ b/app/features/api-public/api-auth-middleware.server.ts @@ -1,4 +1,5 @@ import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server"; +import { userIsBanned } from "~/features/ban/core/banned.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { getTokenInfo } from "./api-public-utils.server"; @@ -48,6 +49,10 @@ export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => { return Response.json({ error: "Invalid token" }, { status: 401 }); } + if (userIsBanned(tokenInfo.userId)) { + return Response.json({ error: "User is banned" }, { status: 403 }); + } + if (request.method === "POST" && tokenInfo.type !== "write") { return Response.json({ error: "Write token required" }, { status: 403 }); } diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index efd3fc87a..3fd1f43a7 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -1,6 +1,7 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; +import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; @@ -144,6 +145,7 @@ export const action: ActionFunction = async ({ request, url }) => { }); await refreshBannedCache(); + await refreshApiTokensCache(); throw redirect(SUSPENDED_PAGE); }