mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
User generatable API tokens (#2621)
This commit is contained in:
117
app/features/api/ApiRepository.server.test.ts
Normal file
117
app/features/api/ApiRepository.server.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as ApiRepository from "./ApiRepository.server";
|
||||
|
||||
describe("findTokenByUserId", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns undefined when user has no token", async () => {
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("finds existing token for user", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
|
||||
const result = await ApiRepository.findTokenByUserId(1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.userId).toBe(1);
|
||||
expect(result?.token).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns correct token for specific user", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
|
||||
expect(result1?.token).toBe(token1.token);
|
||||
expect(result2?.token).toBe(token2.token);
|
||||
expect(result1?.token).not.toBe(result2?.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateToken", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("creates new token for user", async () => {
|
||||
const result = await ApiRepository.generateToken(1);
|
||||
|
||||
expect(result.token).toBeDefined();
|
||||
expect(typeof result.token).toBe("string");
|
||||
expect(result.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("deletes existing token before creating new one", async () => {
|
||||
const firstToken = await ApiRepository.generateToken(1);
|
||||
const secondToken = await ApiRepository.generateToken(1);
|
||||
|
||||
expect(firstToken.token).not.toBe(secondToken.token);
|
||||
|
||||
const storedToken = await ApiRepository.findTokenByUserId(1);
|
||||
expect(storedToken?.token).toBe(secondToken.token);
|
||||
});
|
||||
|
||||
test("generates unique tokens for different users", async () => {
|
||||
const token1 = await ApiRepository.generateToken(1);
|
||||
const token2 = await ApiRepository.generateToken(2);
|
||||
const token3 = await ApiRepository.generateToken(3);
|
||||
|
||||
expect(token1.token).not.toBe(token2.token);
|
||||
expect(token1.token).not.toBe(token3.token);
|
||||
expect(token2.token).not.toBe(token3.token);
|
||||
});
|
||||
|
||||
test("replaces only the specific user's token", async () => {
|
||||
const user1FirstToken = await ApiRepository.generateToken(1);
|
||||
const user2Token = await ApiRepository.generateToken(2);
|
||||
const user1SecondToken = await ApiRepository.generateToken(1);
|
||||
|
||||
const result1 = await ApiRepository.findTokenByUserId(1);
|
||||
const result2 = await ApiRepository.findTokenByUserId(2);
|
||||
|
||||
expect(result1?.token).toBe(user1SecondToken.token);
|
||||
expect(result1?.token).not.toBe(user1FirstToken.token);
|
||||
expect(result2?.token).toBe(user2Token.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allApiTokens", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns empty array when no tokens exist", async () => {
|
||||
const result = await ApiRepository.allApiTokens();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns array of token strings", async () => {
|
||||
await ApiRepository.generateToken(1);
|
||||
|
||||
const result = await ApiRepository.allApiTokens();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.every((token) => typeof token === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
81
app/features/api/ApiRepository.server.ts
Normal file
81
app/features/api/ApiRepository.server.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "~/db/sql";
|
||||
|
||||
const API_TOKEN_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Finds an API token for the given user ID.
|
||||
* @returns API token record if found, undefined otherwise
|
||||
*/
|
||||
export function findTokenByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("ApiToken")
|
||||
.selectAll()
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new API token for the given user.
|
||||
* Deletes any existing token for the user before creating a new one.
|
||||
* @returns Object containing the newly generated token
|
||||
*/
|
||||
export function generateToken(userId: number) {
|
||||
const token = nanoid(API_TOKEN_LENGTH);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("ApiToken").where("userId", "=", userId).execute();
|
||||
|
||||
return trx
|
||||
.insertInto("ApiToken")
|
||||
.values({
|
||||
userId,
|
||||
token,
|
||||
})
|
||||
.returning("token")
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all valid API tokens from users with API access.
|
||||
* Includes tokens from users with the isApiAccesser flag enabled (includes supporters tier 2+),
|
||||
* or users who are ADMIN, ORGANIZER, or STREAMER members of established tournament organizations.
|
||||
* @returns Array of valid API token strings
|
||||
*/
|
||||
export async function allApiTokens() {
|
||||
const tokens = await db
|
||||
.selectFrom("ApiToken")
|
||||
.innerJoin("User", "User.id", "ApiToken.userId")
|
||||
.leftJoin(
|
||||
"TournamentOrganizationMember",
|
||||
"TournamentOrganizationMember.userId",
|
||||
"ApiToken.userId",
|
||||
)
|
||||
.leftJoin(
|
||||
"TournamentOrganization",
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
)
|
||||
.select("ApiToken.token")
|
||||
// NOTE: permissions logic also exists in checkUserHasApiAccess function
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("User.isApiAccesser", "=", 1),
|
||||
eb("User.isTournamentOrganizer", "=", 1),
|
||||
eb("User.patronTier", ">=", 2),
|
||||
eb.and([
|
||||
eb("TournamentOrganization.isEstablished", "=", 1),
|
||||
eb.or([
|
||||
eb("TournamentOrganizationMember.role", "=", "ADMIN"),
|
||||
eb("TournamentOrganizationMember.role", "=", "ORGANIZER"),
|
||||
eb("TournamentOrganizationMember.role", "=", "STREAMER"),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.groupBy("ApiToken.token")
|
||||
.execute();
|
||||
|
||||
return tokens.map((row) => row.token);
|
||||
}
|
||||
41
app/features/api/actions/api.server.ts
Normal file
41
app/features/api/actions/api.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod/v4";
|
||||
import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseRequestPayload, successToast } from "~/utils/remix.server";
|
||||
import { _action } from "~/utils/zod";
|
||||
import * as ApiRepository from "../ApiRepository.server";
|
||||
import { checkUserHasApiAccess } from "../core/perms";
|
||||
|
||||
const apiActionSchema = z.object({
|
||||
_action: _action("GENERATE"),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: apiActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const hasApiAccess = await checkUserHasApiAccess(user);
|
||||
if (!hasApiAccess) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
switch (data._action) {
|
||||
case "GENERATE": {
|
||||
await ApiRepository.generateToken(user.id);
|
||||
|
||||
await refreshApiTokensCache();
|
||||
|
||||
successToast("API token generated successfully");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid action");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
170
app/features/api/core/perms.test.ts
Normal file
170
app/features/api/core/perms.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { add } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as ApiRepository from "../ApiRepository.server";
|
||||
import { checkUserHasApiAccess } from "./perms";
|
||||
|
||||
describe("Permission logic consistency between allApiTokens and checkUserHasApiAccess", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(10);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("both functions grant access for isApiAccesser flag", async () => {
|
||||
await AdminRepository.makeApiAccesserByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("both functions grant access for isTournamentOrganizer flag", async () => {
|
||||
await AdminRepository.makeTournamentOrganizerByUserId(1);
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("both functions grant access for patronTier >= 2", async () => {
|
||||
await AdminRepository.forcePatron({
|
||||
id: 1,
|
||||
patronTier: 2,
|
||||
patronSince: new Date(),
|
||||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("both functions deny access for patronTier < 2", async () => {
|
||||
await AdminRepository.forcePatron({
|
||||
id: 1,
|
||||
patronTier: 1,
|
||||
patronSince: new Date(),
|
||||
patronTill: add(new Date(), { months: 3 }),
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(1);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(1);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(0);
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
test("both functions grant access for ADMIN/ORGANIZER/STREAMER of established org", async () => {
|
||||
const org = await TournamentOrganizationRepository.create({
|
||||
ownerId: 1,
|
||||
name: "Test Org",
|
||||
});
|
||||
|
||||
await TournamentOrganizationRepository.updateIsEstablished(org.id, true);
|
||||
|
||||
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
|
||||
|
||||
for (const role of ["ADMIN", "ORGANIZER", "STREAMER"] as const) {
|
||||
const userId = role === "ADMIN" ? 2 : role === "ORGANIZER" ? 3 : 4;
|
||||
|
||||
await TournamentOrganizationRepository.update({
|
||||
id: org.id,
|
||||
name: orgData!.name,
|
||||
description: orgData!.description,
|
||||
socials: orgData!.socials,
|
||||
members: [{ userId, role, roleDisplayName: null }],
|
||||
series: [],
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(userId);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(userId);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(hasAccess).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("both functions deny access for MEMBER of established org", async () => {
|
||||
const org = await TournamentOrganizationRepository.create({
|
||||
ownerId: 1,
|
||||
name: "Test Org",
|
||||
});
|
||||
|
||||
await TournamentOrganizationRepository.updateIsEstablished(org.id, true);
|
||||
|
||||
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
|
||||
await TournamentOrganizationRepository.update({
|
||||
id: org.id,
|
||||
name: orgData!.name,
|
||||
description: orgData!.description,
|
||||
socials: orgData!.socials,
|
||||
members: [{ userId: 2, role: "MEMBER", roleDisplayName: null }],
|
||||
series: [],
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(0);
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
test("both functions deny access for ADMIN of non-established org", async () => {
|
||||
const org = await TournamentOrganizationRepository.create({
|
||||
ownerId: 1,
|
||||
name: "Test Org",
|
||||
});
|
||||
|
||||
const orgData = await TournamentOrganizationRepository.findBySlug(org.slug);
|
||||
await TournamentOrganizationRepository.update({
|
||||
id: org.id,
|
||||
name: orgData!.name,
|
||||
description: orgData!.description,
|
||||
socials: orgData!.socials,
|
||||
members: [{ userId: 2, role: "ADMIN", roleDisplayName: null }],
|
||||
series: [],
|
||||
badges: [],
|
||||
});
|
||||
|
||||
await ApiRepository.generateToken(2);
|
||||
const tokens = await ApiRepository.allApiTokens();
|
||||
|
||||
const user = await UserRepository.findLeanById(2);
|
||||
const hasAccess = await checkUserHasApiAccess(user!);
|
||||
|
||||
expect(tokens).toHaveLength(0);
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
23
app/features/api/core/perms.ts
Normal file
23
app/features/api/core/perms.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
|
||||
/**
|
||||
* Checks whether a user has permission to access the API.
|
||||
* A user has API access if they either have the API_ACCESSER role (includes supporters),
|
||||
* or are an admin/organizer/streamer of an established tournament organization.
|
||||
*
|
||||
* @param user - The authenticated user to check permissions for
|
||||
* @returns True if the user has API access, false otherwise
|
||||
*/
|
||||
export async function checkUserHasApiAccess(user: AuthenticatedUser) {
|
||||
// NOTE: permissions logic also exists in ApiRepository.allApiTokens function
|
||||
if (user.roles.includes("API_ACCESSER")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const orgs = await TournamentOrganizationRepository.findByUserId(user.id, {
|
||||
roles: ["ADMIN", "ORGANIZER", "STREAMER"],
|
||||
});
|
||||
|
||||
return orgs.some((org) => org.isEstablished);
|
||||
}
|
||||
24
app/features/api/loaders/api.server.ts
Normal file
24
app/features/api/loaders/api.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ApiRepository from "../ApiRepository.server";
|
||||
import { checkUserHasApiAccess } from "../core/perms";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const hasApiAccess = await checkUserHasApiAccess(user);
|
||||
|
||||
if (!hasApiAccess) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
apiToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiToken = await ApiRepository.findTokenByUserId(user.id);
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
apiToken: apiToken?.token ?? null,
|
||||
};
|
||||
};
|
||||
84
app/features/api/routes/api.tsx
Normal file
84
app/features/api/routes/api.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { EyeIcon } from "~/components/icons/Eye";
|
||||
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { action } from "../actions/api.server";
|
||||
import { loader } from "../loaders/api.server";
|
||||
export { loader, action };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "API Access",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function ApiPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<div>
|
||||
<h1 className="text-lg">{t("common:api.title")}</h1>
|
||||
<p className="text-sm">
|
||||
<Trans t={t} i18nKey="common:api.description">
|
||||
Generate an API token to access the sendou.ink API. See the
|
||||
<Link to="/docs/dev/api.md" className="text-theme">
|
||||
API documentation
|
||||
</Link>
|
||||
for available endpoints, usage examples and guidelines to follow.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!data.hasAccess ? (
|
||||
<div>
|
||||
<FormMessage type="info">{t("common:api.noAccess")}</FormMessage>
|
||||
</div>
|
||||
) : data.apiToken ? (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<label>{t("common:api.tokenLabel")}</label>
|
||||
<CopyToClipboardPopover
|
||||
url={data.apiToken}
|
||||
trigger={
|
||||
<SendouButton icon={<EyeIcon />}>
|
||||
{t("common:api.revealButton")}
|
||||
</SendouButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("common:api.regenerate.heading")}
|
||||
submitButtonText={t("common:api.regenerate.confirm")}
|
||||
fields={[["_action", "GENERATE"]]}
|
||||
>
|
||||
<SendouButton
|
||||
className="mx-auto"
|
||||
variant="outlined"
|
||||
icon={<RefreshArrowsIcon />}
|
||||
>
|
||||
{t("common:api.regenerate.button")}
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
) : (
|
||||
<form method="post">
|
||||
<SubmitButton _action="GENERATE">
|
||||
{t("common:api.generate")}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user