User generatable API tokens (#2621)

This commit is contained in:
Kalle
2025-11-09 11:07:20 +02:00
committed by GitHub
parent 47e9262d40
commit ff4402d9aa
38 changed files with 1045 additions and 20 deletions

View 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);
});
});

View 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);
}

View 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;
};

View 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);
});
});

View 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);
}

View 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,
};
};

View 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>
);
}