From dcef09655209002ed1cc21c02d2e187a46c0a9c6 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 23 Aug 2025 13:58:52 +1000 Subject: [PATCH] API tokens (#201) * fix: small fixes to request util and version update endpoint * feat: api token creation and management * fix: lint * fix: remove unneeded sidebar component --- server/components/AccountSidebar.vue | 7 + server/components/Modal/CreateToken.vue | 267 ++++++++++++++++++ server/composables/request.ts | 52 ++-- server/drop-base | 2 +- server/i18n/locales/en_us.json | 28 +- server/layouts/admin.vue | 2 +- server/pages/account/tokens.vue | 229 +++++++++++++++ server/pages/admin/settings.vue | 68 +++++ server/pages/admin/settings/index.vue | 111 ++++---- server/pages/admin/settings/tokens.vue | 233 +++++++++++++++ .../migration.sql | 8 + server/prisma/models/auth.prisma | 2 + .../api/v1/admin/game/[id]/index.get.ts | 7 +- .../api/v1/admin/game/version/index.patch.ts | 47 ++- .../api/v1/admin/token/[id]/index.delete.ts | 23 ++ server/server/api/v1/admin/token/acls.get.ts | 9 + server/server/api/v1/admin/token/index.get.ts | 15 + .../server/api/v1/admin/token/index.post.ts | 38 +++ server/server/api/v1/token.get.ts | 6 + server/server/api/v1/user/token/index.post.ts | 33 +-- server/server/internal/acls/descriptions.ts | 2 +- 21 files changed, 1062 insertions(+), 127 deletions(-) create mode 100644 server/components/Modal/CreateToken.vue create mode 100644 server/pages/account/tokens.vue create mode 100644 server/pages/admin/settings.vue create mode 100644 server/pages/admin/settings/tokens.vue create mode 100644 server/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql create mode 100644 server/server/api/v1/admin/token/[id]/index.delete.ts create mode 100644 server/server/api/v1/admin/token/acls.get.ts create mode 100644 server/server/api/v1/admin/token/index.get.ts create mode 100644 server/server/api/v1/admin/token/index.post.ts create mode 100644 server/server/api/v1/token.get.ts diff --git a/server/components/AccountSidebar.vue b/server/components/AccountSidebar.vue index fa907aaa..2c0a3ece 100644 --- a/server/components/AccountSidebar.vue +++ b/server/components/AccountSidebar.vue @@ -45,6 +45,7 @@ import { LockClosedIcon, DevicePhoneMobileIcon, WrenchScrewdriverIcon, + CodeBracketIcon, } from "@heroicons/vue/24/outline"; import { UserIcon } from "@heroicons/vue/24/solid"; import type { Component } from "vue"; @@ -73,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ icon: BellIcon, count: notifications.value.length, }, + { + label: t("account.token.title"), + route: "/account/tokens", + prefix: "/account/tokens", + icon: CodeBracketIcon, + }, { label: t("account.settings"), route: "/account/settings", diff --git a/server/components/Modal/CreateToken.vue b/server/components/Modal/CreateToken.vue new file mode 100644 index 00000000..ed1c3cfa --- /dev/null +++ b/server/components/Modal/CreateToken.vue @@ -0,0 +1,267 @@ + + + diff --git a/server/composables/request.ts b/server/composables/request.ts index 8396b8d2..2283366d 100644 --- a/server/composables/request.ts +++ b/server/composables/request.ts @@ -46,10 +46,28 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { }); const request = requestParts.join("/"); + // If not in setup if (!getCurrentInstance()?.proxy) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Excessive stack depth comparing types - return await $fetch(request, opts); + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Excessive stack depth comparing types + return await $fetch(request, opts); + } catch (e) { + if (import.meta.client && opts?.failTitle) { + console.warn(e); + createModal( + ModalType.Notification, + { + title: opts.failTitle, + description: + (e as FetchError)?.statusMessage ?? (e as string).toString(), + //buttonText: $t("common.close"), + }, + (_, c) => c(), + ); + } + throw e; + } } const id = request.toString(); @@ -64,26 +82,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { } const headers = useRequestHeaders(["cookie", "authorization"]); - try { - const data = await $fetch(request, { - ...opts, - headers: { ...headers, ...opts?.headers }, - }); - if (import.meta.server) state.value = data; - return data; - } catch (e) { - if (import.meta.client && opts?.failTitle) { - createModal( - ModalType.Notification, - { - title: opts.failTitle, - description: - (e as FetchError)?.statusMessage ?? (e as string).toString(), - buttonText: $t("common.close"), - }, - (_, c) => c(), - ); - } - throw e; - } + const data = await $fetch(request, { + ...opts, + headers: { ...headers, ...opts?.headers }, + }); + if (import.meta.server) state.value = data; + return data; }; diff --git a/server/drop-base b/server/drop-base index 04125e89..4c42edf5 160000 --- a/server/drop-base +++ b/server/drop-base @@ -1 +1 @@ -Subproject commit 04125e89bef517411e103cdabcfa64a1bb563423 +Subproject commit 4c42edf5adfa755c33bc8ce7bf1ddec87a0963a8 diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json index 2356acb9..8fecfd58 100644 --- a/server/i18n/locales/en_us.json +++ b/server/i18n/locales/en_us.json @@ -19,6 +19,28 @@ "title": "Notifications", "unread": "Unread Notifications" }, + "token": { + "title": "API Tokens", + "subheader": "Manage your API tokens, and what they can access.", + "name": "API token name", + "nameDesc": "The name of the token, for reference.", + "namePlaceholder": "My New Token", + "acls": "ACLs/scopes", + "aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.", + "expiry": "Expiry", + "noExpiry": "No expiry", + "revoke": "Revoke", + "noTokens": "No tokens connected to your account.", + + "expiryMonth": "A month", + "expiry3Month": "3 months", + "expiry6Month": "6 months", + "expiryYear": "A year", + "expiry5Year": "5 years", + + "success": "Successfully created token.", + "successNote": "Make sure to copy it now, as it won't be shown again." + }, "settings": "Settings", "title": "Account Settings" }, @@ -241,7 +263,11 @@ "admin": { "admin": "Admin", "metadata": "Meta", - "settings": "Settings", + "settings": { + "title": "Settings", + "store": "Store", + "tokens": "API tokens" + }, "tasks": "Tasks", "users": "Users" }, diff --git a/server/layouts/admin.vue b/server/layouts/admin.vue index 24cef9b6..2a34d0be 100644 --- a/server/layouts/admin.vue +++ b/server/layouts/admin.vue @@ -200,7 +200,7 @@ const navigation: Array = [ icon: RectangleStackIcon, }, { - label: $t("header.admin.settings"), + label: $t("header.admin.settings.title"), route: "/admin/settings", prefix: "/admin/settings", icon: Cog6ToothIcon, diff --git a/server/pages/account/tokens.vue b/server/pages/account/tokens.vue new file mode 100644 index 00000000..4f9ee486 --- /dev/null +++ b/server/pages/account/tokens.vue @@ -0,0 +1,229 @@ + + + diff --git a/server/pages/admin/settings.vue b/server/pages/admin/settings.vue new file mode 100644 index 00000000..6d637d14 --- /dev/null +++ b/server/pages/admin/settings.vue @@ -0,0 +1,68 @@ + + + diff --git a/server/pages/admin/settings/index.vue b/server/pages/admin/settings/index.vue index 3a80db97..6402b634 100644 --- a/server/pages/admin/settings/index.vue +++ b/server/pages/admin/settings/index.vue @@ -1,68 +1,55 @@ diff --git a/server/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql b/server/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql new file mode 100644 index 00000000..c028e991 --- /dev/null +++ b/server/prisma/migrations/20250823005001_add_api_token_expiry/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "APIToken" ADD COLUMN "expiresAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/server/prisma/models/auth.prisma b/server/prisma/models/auth.prisma index 4b03c7c4..8749cf6c 100644 --- a/server/prisma/models/auth.prisma +++ b/server/prisma/models/auth.prisma @@ -45,6 +45,8 @@ model APIToken { acls String[] + expiresAt DateTime? + @@index([token]) } diff --git a/server/server/api/v1/admin/game/[id]/index.get.ts b/server/server/api/v1/admin/game/[id]/index.get.ts index 44693dc5..faf0f1cb 100644 --- a/server/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/server/api/v1/admin/game/[id]/index.get.ts @@ -17,11 +17,8 @@ export default defineEventHandler(async (h3) => { orderBy: { versionIndex: "asc", }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, + omit: { + dropletManifest: true, }, }, tags: true, diff --git a/server/server/api/v1/admin/game/version/index.patch.ts b/server/server/api/v1/admin/game/version/index.patch.ts index 38338408..54e05470 100644 --- a/server/server/api/v1/admin/game/version/index.patch.ts +++ b/server/server/api/v1/admin/game/version/index.patch.ts @@ -18,30 +18,55 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( const body = await readDropValidatedBody(h3, UpdateVersionOrder); const gameId = body.id; // We expect an array of the version names for this game - const versions = body.versions; + const unsortedVersions = await prisma.gameVersion.findMany({ + where: { + versionName: { in: body.versions }, + }, + select: { + versionName: true, + versionIndex: true, + delta: true, + platform: true, + }, + }); - const newVersions = await prisma.$transaction( - versions.map((versionName, versionIndex) => + const versions = body.versions + .map((e) => unsortedVersions.find((v) => v.versionName === e)) + .filter((e) => e !== undefined); + + if (versions.length !== unsortedVersions.length) + throw createError({ + statusCode: 500, + statusMessage: "Sorting versions yielded less results, somehow.", + }); + + // Validate the new order + const has: { [key: string]: boolean } = {}; + for (const version of versions) { + if (version.delta && !has[version.platform]) + throw createError({ + statusCode: 400, + statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`, + }); + has[version.platform] = true; + } + + await prisma.$transaction( + versions.map((version, versionIndex) => prisma.gameVersion.update({ where: { gameId_versionName: { gameId: gameId, - versionName: versionName, + versionName: version.versionName, }, }, data: { versionIndex: versionIndex, }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, - }, }), ), ); - return newVersions; + return versions; }, ); diff --git a/server/server/api/v1/admin/token/[id]/index.delete.ts b/server/server/api/v1/admin/token/[id]/index.delete.ts new file mode 100644 index 00000000..1e33730c --- /dev/null +++ b/server/server/api/v1/admin/token/[id]/index.delete.ts @@ -0,0 +1,23 @@ +import { APITokenMode } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const id = h3.context.params?.id; + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "No id in router params", + }); + + const deleted = await prisma.aPIToken.delete({ + where: { id: id, mode: APITokenMode.System }, + })!; + if (!deleted) + throw createError({ statusCode: 404, statusMessage: "Token not found" }); + + return; +}); diff --git a/server/server/api/v1/admin/token/acls.get.ts b/server/server/api/v1/admin/token/acls.get.ts new file mode 100644 index 00000000..09b4e0f8 --- /dev/null +++ b/server/server/api/v1/admin/token/acls.get.ts @@ -0,0 +1,9 @@ +import aclManager from "~/server/internal/acls"; +import { systemACLDescriptions } from "~/server/internal/acls/descriptions"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + return systemACLDescriptions; +}); diff --git a/server/server/api/v1/admin/token/index.get.ts b/server/server/api/v1/admin/token/index.get.ts new file mode 100644 index 00000000..2438a4be --- /dev/null +++ b/server/server/api/v1/admin/token/index.get.ts @@ -0,0 +1,15 @@ +import { APITokenMode } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const tokens = await prisma.aPIToken.findMany({ + where: { mode: APITokenMode.System }, + omit: { token: true }, + }); + + return tokens; +}); diff --git a/server/server/api/v1/admin/token/index.post.ts b/server/server/api/v1/admin/token/index.post.ts new file mode 100644 index 00000000..421fe8d1 --- /dev/null +++ b/server/server/api/v1/admin/token/index.post.ts @@ -0,0 +1,38 @@ +import { type } from "arktype"; +import { APITokenMode } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager, { systemACLs } from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateToken = type({ + name: "string", + acls: "string[] > 0", + expiry: "string.date.iso.parse?", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateToken); + + const invalidACLs = body.acls.filter( + (e) => systemACLs.findIndex((v) => e == v) == -1, + ); + if (invalidACLs.length > 0) + throw createError({ + statusCode: 400, + statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`, + }); + + const token = await prisma.aPIToken.create({ + data: { + mode: APITokenMode.System, + name: body.name, + acls: body.acls, + expiresAt: body.expiry ?? null, + }, + }); + + return token; +}); diff --git a/server/server/api/v1/token.get.ts b/server/server/api/v1/token.get.ts new file mode 100644 index 00000000..1ce53209 --- /dev/null +++ b/server/server/api/v1/token.get.ts @@ -0,0 +1,6 @@ +import aclManager from "~/server/internal/acls"; + +export default defineEventHandler(async (h3) => { + const acls = await aclManager.fetchAllACLs(h3); + return acls; +}); diff --git a/server/server/api/v1/user/token/index.post.ts b/server/server/api/v1/user/token/index.post.ts index b5fb661d..aafacf3c 100644 --- a/server/server/api/v1/user/token/index.post.ts +++ b/server/server/api/v1/user/token/index.post.ts @@ -1,30 +1,22 @@ +import { type } from "arktype"; import { APITokenMode } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import aclManager, { userACLs } from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +const CreateToken = type({ + name: "string", + acls: "string[] > 0", + expiry: "string.date.iso.parse?", +}).configure(throwingArktype); + export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication if (!userId) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const name: string = body.name; - const acls: string[] = body.acls; + const body = await readDropValidatedBody(h3, CreateToken); - if (!name || typeof name !== "string") - throw createError({ - statusCode: 400, - statusMessage: "Token name required", - }); - if (!acls || !Array.isArray(acls)) - throw createError({ statusCode: 400, statusMessage: "ACLs required" }); - - if (acls.length == 0) - throw createError({ - statusCode: 400, - statusMessage: "Token requires more than zero ACLs", - }); - - const invalidACLs = acls.filter( + const invalidACLs = body.acls.filter( (e) => userACLs.findIndex((v) => e == v) == -1, ); if (invalidACLs.length > 0) @@ -36,9 +28,10 @@ export default defineEventHandler(async (h3) => { const token = await prisma.aPIToken.create({ data: { mode: APITokenMode.User, - name: name, + name: body.name, userId: userId, - acls: acls, + acls: body.acls, + expiresAt: body.expiry ?? null, }, }); diff --git a/server/server/internal/acls/descriptions.ts b/server/server/internal/acls/descriptions.ts index 1f090a13..af1a007b 100644 --- a/server/server/internal/acls/descriptions.ts +++ b/server/server/internal/acls/descriptions.ts @@ -36,7 +36,7 @@ export const userACLDescriptions: ObjectFromList = { "library:remove": "Remove a game from your library.", "clients:read": "Read the clients connected to this account", - "clients:revoke": "", + "clients:revoke": "Remove clients connected to this account", "news:read": "Read the server's news articles.",