diff --git a/next.config.ts b/next.config.ts index 467703f29..ce134be8f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -208,6 +208,9 @@ if (isProd && cloudMode) { /** @type {import('next').NextConfig} */ export default withNextIntl({ reactStrictMode: false, + devIndicators: { + position: 'bottom-right', + }, env: { apiUrl, basePath, diff --git a/prisma/migrations/26_add_api_key/migration.sql b/prisma/migrations/26_add_api_key/migration.sql new file mode 100644 index 000000000..31085ac62 --- /dev/null +++ b/prisma/migrations/26_add_api_key/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "api_key" ( + "api_key_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "name" VARCHAR(255) NOT NULL, + "key_hash" VARCHAR(128) NOT NULL, + "key_prefix" VARCHAR(20) NOT NULL, + "last_used_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_key_pkey" PRIMARY KEY ("api_key_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_key_key_hash_key" ON "api_key"("key_hash"); + +-- CreateIndex +CREATE INDEX "api_key_user_id_idx" ON "api_key"("user_id"); + +-- AddForeignKey +ALTER TABLE "api_key" ADD CONSTRAINT "api_key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 113c67e99..a57873235 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,10 +32,26 @@ model User { twoFactorBackupCodes TwoFactorBackupCode[] twoFactorOtpUseds TwoFactorOtpUsed[] twoFactorRateLimit TwoFactorRateLimit? + apiKeys ApiKey[] @@map("user") } +model ApiKey { + id String @id() @map("api_key_id") @db.Uuid + userId String @map("user_id") @db.Uuid + name String @db.VarChar(255) + keyHash String @unique @map("key_hash") @db.VarChar(128) + keyPrefix String @map("key_prefix") @db.VarChar(20) + lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(6) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("api_key") +} + model Session { id String @id() @map("session_id") @db.Uuid websiteId String @map("website_id") @db.Uuid diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 023f53b8c..34b26c98f 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -425,7 +425,13 @@ "two-factor-reset": "Reset 2FA", "two-factor-global-active-tooltip": "Global 2FA is enabled", "two-factor-verify": "Verify", - "two-factor-login": "Two-factor authentication" + "two-factor-login": "Two-factor authentication", + "api-keys": "API keys", + "api-key": "API key", + "create-api-key": "Create API key", + "last-used": "Last used", + "never": "Never", + "copy": "Copy" }, "message": { "action-confirmation": "Type {confirmation} in the box below to confirm.", @@ -510,6 +516,8 @@ "two-factor-error-no-pending-setup": "No pending two-factor authentication setup found.", "two-factor-error-not-configured": "Two-factor authentication is not available because the server is missing a valid TWO_FACTOR_ENCRYPTION_KEY. Contact your administrator for help.", "two-factor-locked-message": "Too many failed attempts. Try again at {time}.", - "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app." + "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app.", + "api-key-created": "Copy your API key now. You won't be able to see it again.", + "no-api-keys": "You have not created any API keys." } } diff --git a/public/openapi.json b/public/openapi.json index 62cde911a..bb22993c1 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -670,7 +670,7 @@ "securitySchemes": { "bearerAuth": { "bearerFormat": "JWT", - "description": "Token returned by POST /api/auth/login.", + "description": "Token returned by POST /api/auth/login, or an API key (`umami_…`) created under Settings → API keys (self-hosted only).", "scheme": "bearer", "type": "http" }, @@ -7084,6 +7084,21 @@ "application/json": { "schema": { "properties": { + "apiKey": { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "shareToken": { "properties": { "boardId": { @@ -7218,6 +7233,301 @@ "x-umami-source": "src/app/api/me/contract.generated.ts" } }, + "/api/me/api-keys": { + "get": { + "operationId": "getMeApiKeys", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "lastUsedAt": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "createdAt", + "name", + "keyPrefix", + "lastUsedAt" + ], + "type": "object" + }, + "type": "array" + } + } + }, + "description": "The operation completed successfully." + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unauthorized", + "message": "Unauthorized.", + "status": 401 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Unauthorized." + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "not-found", + "message": "Not found.", + "status": 404 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get me api keys", + "tags": [ + "Account" + ], + "x-umami-audience": "public", + "x-umami-contract": "explicit", + "x-umami-source": "src/app/api/me/api-keys/contract.generated.ts" + }, + "post": { + "operationId": "postMeApiKeys", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "maximum": 255, + "minimum": 1 + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "key", + "id", + "createdAt", + "name", + "keyPrefix" + ], + "type": "object" + } + } + }, + "description": "The operation completed successfully." + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "bad-request", + "message": "Bad request.", + "status": 400 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Bad request." + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unauthorized", + "message": "Unauthorized.", + "status": 401 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Unauthorized." + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "not-found", + "message": "Not found.", + "status": 404 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create or update me api keys", + "tags": [ + "Account" + ], + "x-umami-audience": "public", + "x-umami-contract": "explicit", + "x-umami-source": "src/app/api/me/api-keys/contract.generated.ts" + } + }, + "/api/me/api-keys/{keyId}": { + "delete": { + "operationId": "deleteMeApiKeysKeyId", + "parameters": [ + { + "in": "path", + "name": "keyId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "const": true + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "The operation completed successfully." + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unauthorized", + "message": "Unauthorized.", + "status": 401 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Unauthorized." + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "not-found", + "message": "Not found.", + "status": 404 + } + }, + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete me api keys key id", + "tags": [ + "Account" + ], + "x-umami-audience": "public", + "x-umami-contract": "explicit", + "x-umami-source": "src/app/api/me/api-keys/[keyId]/contract.generated.ts" + } + }, "/api/me/password": { "post": { "operationId": "postMePassword", @@ -16226,6 +16536,9 @@ "properties": { "_count": { "properties": { + "apiKeys": { + "type": "number" + }, "boards": { "type": "number" }, @@ -16271,10 +16584,51 @@ "twoFactorAuth", "twoFactorBackupCodes", "twoFactorOtpUseds", - "twoFactorRateLimit" + "twoFactorRateLimit", + "apiKeys" ], "type": "object" }, + "apiKeys": { + "items": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "keyHash": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "lastUsedAt": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "createdAt", + "name", + "keyHash", + "keyPrefix", + "lastUsedAt" + ], + "type": "object" + }, + "type": "array" + }, "boards": { "items": { "properties": { @@ -17007,6 +17361,7 @@ "boards", "twoFactorBackupCodes", "twoFactorOtpUseds", + "apiKeys", "_count" ], "type": "object" diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx index 115f75bbf..fe40e33f4 100644 --- a/src/app/(main)/settings/SettingsNav.tsx +++ b/src/app/(main)/settings/SettingsNav.tsx @@ -2,7 +2,7 @@ import { Column, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; import { IconLabel } from '@/components/common/IconLabel'; import Link from '@/components/common/Link'; import { useMessages, useNavigation } from '@/components/hooks'; -import { ArrowLeft, Settings2, ShieldCheck, UserCircle, Users } from '@/components/icons'; +import { ArrowLeft, KeyRound, Settings2, ShieldCheck, UserCircle, Users } from '@/components/icons'; export function SettingsNav({ isCollapsed, @@ -16,7 +16,7 @@ export function SettingsNav({ const items = [ { - label: t(labels.application), + label: t(labels.settings), items: [ { id: 'preferences', @@ -24,11 +24,6 @@ export function SettingsNav({ path: renderUrl('/settings/preferences'), icon: , }, - ], - }, - { - label: t(labels.account), - items: [ { id: 'profile', label: t(labels.profile), @@ -47,6 +42,12 @@ export function SettingsNav({ path: renderUrl('/settings/security'), icon: , }, + { + id: 'api-keys', + label: t(labels.apiKeys), + path: renderUrl('/settings/api-keys'), + icon: , + }, ], }, ]; diff --git a/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx b/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx new file mode 100644 index 000000000..163b2d027 --- /dev/null +++ b/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx @@ -0,0 +1,30 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { ApiKeyAddForm } from './ApiKeyAddForm'; + +export function ApiKeyAddButton({ onSave }: { onSave?: () => void }) { + const { t, labels } = useMessages(); + const { touch } = useModified(); + + const handleSave = () => { + touch('api-keys'); + onSave?.(); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx b/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx new file mode 100644 index 000000000..6ff03a7c2 --- /dev/null +++ b/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx @@ -0,0 +1,70 @@ +import { + Alert, + AlertTitle, + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Icon, + Row, + TextField, +} from '@umami/react-zen'; +import { useState } from 'react'; +import { CopyButton } from '@/components/common/CopyButton'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { AlertTriangle } from '@/components/icons'; + +export function ApiKeyAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { + const { t, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery('/me/api-keys'); + const [createdKey, setCreatedKey] = useState(null); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async result => { + setCreatedKey(result.key); + onSave?.(); + }, + }); + }; + + if (createdKey) { + return ( + + + + + + {t(messages.apiKeyCreated)} + + + + + + + + + + ); + } + + return ( +
+ + + + + + + {t(labels.save)} + + +
+ ); +} diff --git a/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx b/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx new file mode 100644 index 000000000..205c1477e --- /dev/null +++ b/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx @@ -0,0 +1,47 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; + +export function ApiKeyDeleteButton({ + keyId, + name, + onSave, +}: { + keyId: string; + name: string; + onSave?: () => void; +}) { + const { t, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/me/api-keys/${keyId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('api-keys'); + onSave?.(); + close(); + }, + }); + }; + + return ( + } title={t(labels.confirm)} variant="quiet" width="400px"> + {({ close }) => ( + {chunks}, + })} + isLoading={isPending} + error={getErrorMessage(error)} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={t(labels.delete)} + buttonVariant="danger" + /> + )} + + ); +} diff --git a/src/app/(main)/settings/api-keys/ApiKeysPage.tsx b/src/app/(main)/settings/api-keys/ApiKeysPage.tsx new file mode 100644 index 000000000..ead200164 --- /dev/null +++ b/src/app/(main)/settings/api-keys/ApiKeysPage.tsx @@ -0,0 +1,30 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useConfig, useMessages } from '@/components/hooks'; +import { ApiKeyAddButton } from './ApiKeyAddButton'; +import { ApiKeysTable } from './ApiKeysTable'; + +export function ApiKeysPage() { + const { t, labels } = useMessages(); + const { cloudMode } = useConfig(); + + if (cloudMode) { + return null; + } + + return ( + + + + + + + + + + + ); +} diff --git a/src/app/(main)/settings/api-keys/ApiKeysTable.tsx b/src/app/(main)/settings/api-keys/ApiKeysTable.tsx new file mode 100644 index 000000000..5c95dd461 --- /dev/null +++ b/src/app/(main)/settings/api-keys/ApiKeysTable.tsx @@ -0,0 +1,46 @@ +import { DataColumn, DataTable, Text } from '@umami/react-zen'; +import { DateDistance } from '@/components/common/DateDistance'; +import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { type ApiKeyData, useApiKeysQuery, useMessages } from '@/components/hooks'; +import { KeyRound } from '@/components/icons'; +import { ApiKeyDeleteButton } from './ApiKeyDeleteButton'; + +export function ApiKeysTable() { + const { t, labels, messages } = useMessages(); + const { data, isLoading, error } = useApiKeysQuery(); + + return ( + ( + } description={t(messages.noApiKeys)} /> + )} + > + + + + {(row: ApiKeyData) => {row.keyPrefix}…} + + + {(row: ApiKeyData) => } + + + {(row: ApiKeyData) => + row.lastUsedAt ? ( + + ) : ( + {t(labels.never)} + ) + } + + + {(row: ApiKeyData) => } + + + + ); +} diff --git a/src/app/(main)/settings/api-keys/page.tsx b/src/app/(main)/settings/api-keys/page.tsx new file mode 100644 index 000000000..9d664662b --- /dev/null +++ b/src/app/(main)/settings/api-keys/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { ApiKeysPage } from './ApiKeysPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'API keys', +}; diff --git a/src/app/api/me/api-keys/[keyId]/contract.generated.ts b/src/app/api/me/api-keys/[keyId]/contract.generated.ts new file mode 100644 index 000000000..4596c9378 --- /dev/null +++ b/src/app/api/me/api-keys/[keyId]/contract.generated.ts @@ -0,0 +1,80 @@ +// Generated by `pnpm openapi:contracts`. Do not edit directly. + +import type { ZodOpenApiOperationObject } from 'zod-openapi'; +import { defineOperation } from '@/openapi/operation'; + +const operation1 = defineOperation({ + method: 'delete', + path: '/api/me/api-keys/{keyId}', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'deleteMeApiKeysKeyId', + summary: 'Delete me api keys key id', + tags: ['Account'], + parameters: [ + { + name: 'keyId', + in: 'path', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'The operation completed successfully.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { + const: true, + }, + }, + required: ['ok'], + }, + }, + }, + }, + '401': { + description: 'Unauthorized.', + content: { + 'application/json': { + example: { + error: { + message: 'Unauthorized.', + code: 'unauthorized', + status: 401, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + '404': { + description: 'Not found.', + content: { + 'application/json': { + example: { + error: { + message: 'Not found.', + code: 'not-found', + status: 404, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + }, + } as ZodOpenApiOperationObject, +}); + +export const operations = [operation1] as const; diff --git a/src/app/api/me/api-keys/[keyId]/route.test.ts b/src/app/api/me/api-keys/[keyId]/route.test.ts new file mode 100644 index 000000000..1ad4b4e04 --- /dev/null +++ b/src/app/api/me/api-keys/[keyId]/route.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { DELETE } from './route'; + +const mocks = vi.hoisted(() => ({ + deleteApiKey: vi.fn(), +})); + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/queries/prisma/apiKey', () => ({ + deleteApiKey: mocks.deleteApiKey, +})); + +const parseRequestMock = vi.mocked(parseRequest); + +function deleteRequest(keyId = 'key-1') { + return DELETE(new Request(`http://localhost/api/me/api-keys/${keyId}`, { method: 'DELETE' }), { + params: Promise.resolve({ keyId }), + }); +} + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('CLOUD_MODE', ''); + parseRequestMock.mockReset(); + mocks.deleteApiKey.mockReset(); + + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + error: undefined, + }); +}); + +test('DELETE removes a key owned by the current user', async () => { + mocks.deleteApiKey.mockResolvedValue(1); + + const response = await deleteRequest(); + + expect(response.status).toBe(200); + expect(mocks.deleteApiKey).toHaveBeenCalledWith('key-1', 'user-1'); +}); + +test('DELETE returns 404 when the key does not belong to the user', async () => { + mocks.deleteApiKey.mockResolvedValue(0); + + const response = await deleteRequest('someone-elses-key'); + + expect(response.status).toBe(404); +}); + +test('DELETE returns 404 in cloud mode', async () => { + vi.stubEnv('CLOUD_MODE', '1'); + + const response = await deleteRequest(); + + expect(response.status).toBe(404); + expect(mocks.deleteApiKey).not.toHaveBeenCalled(); +}); diff --git a/src/app/api/me/api-keys/[keyId]/route.ts b/src/app/api/me/api-keys/[keyId]/route.ts new file mode 100644 index 000000000..161808ce1 --- /dev/null +++ b/src/app/api/me/api-keys/[keyId]/route.ts @@ -0,0 +1,26 @@ +import { isApiKeyEnabled } from '@/lib/api-key'; +import { parseRequest } from '@/lib/request'; +import { notFound, ok } from '@/lib/response'; +import { deleteApiKey } from '@/queries/prisma/apiKey'; + +export async function DELETE(request: Request, { params }: { params: Promise<{ keyId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (!isApiKeyEnabled()) { + return notFound(); + } + + const { keyId } = await params; + + const count = await deleteApiKey(keyId, auth.user.id); + + if (!count) { + return notFound(); + } + + return ok(); +} diff --git a/src/app/api/me/api-keys/contract.generated.ts b/src/app/api/me/api-keys/contract.generated.ts new file mode 100644 index 000000000..953e17401 --- /dev/null +++ b/src/app/api/me/api-keys/contract.generated.ts @@ -0,0 +1,198 @@ +// Generated by `pnpm openapi:contracts`. Do not edit directly. + +import type { ZodOpenApiOperationObject } from 'zod-openapi'; +import { defineOperation } from '@/openapi/operation'; + +const operation1 = defineOperation({ + method: 'get', + path: '/api/me/api-keys', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'getMeApiKeys', + summary: 'Get me api keys', + tags: ['Account'], + responses: { + '200': { + description: 'The operation completed successfully.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'date-time', + }, + name: { + type: 'string', + }, + keyPrefix: { + type: 'string', + }, + lastUsedAt: { + type: 'string', + format: 'date-time', + }, + }, + required: ['id', 'createdAt', 'name', 'keyPrefix', 'lastUsedAt'], + }, + }, + }, + }, + }, + '401': { + description: 'Unauthorized.', + content: { + 'application/json': { + example: { + error: { + message: 'Unauthorized.', + code: 'unauthorized', + status: 401, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + '404': { + description: 'Not found.', + content: { + 'application/json': { + example: { + error: { + message: 'Not found.', + code: 'not-found', + status: 404, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + }, + } as ZodOpenApiOperationObject, +}); + +const operation2 = defineOperation({ + method: 'post', + path: '/api/me/api-keys', + audience: 'public', + auth: 'bearer', + operation: { + operationId: 'postMeApiKeys', + summary: 'Create or update me api keys', + tags: ['Account'], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { + minimum: 1, + maximum: 255, + }, + }, + required: ['name'], + }, + }, + }, + }, + responses: { + '200': { + description: 'The operation completed successfully.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + key: { + type: 'string', + }, + id: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'date-time', + }, + name: { + type: 'string', + }, + keyPrefix: { + type: 'string', + }, + }, + required: ['key', 'id', 'createdAt', 'name', 'keyPrefix'], + }, + }, + }, + }, + '400': { + description: 'Bad request.', + content: { + 'application/json': { + example: { + error: { + message: 'Bad request.', + code: 'bad-request', + status: 400, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + '401': { + description: 'Unauthorized.', + content: { + 'application/json': { + example: { + error: { + message: 'Unauthorized.', + code: 'unauthorized', + status: 401, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + '404': { + description: 'Not found.', + content: { + 'application/json': { + example: { + error: { + message: 'Not found.', + code: 'not-found', + status: 404, + }, + }, + schema: { + $ref: '#/components/schemas/ApiError', + }, + }, + }, + }, + }, + } as ZodOpenApiOperationObject, +}); + +export const operations = [operation1, operation2] as const; diff --git a/src/app/api/me/api-keys/route.test.ts b/src/app/api/me/api-keys/route.test.ts new file mode 100644 index 000000000..5371073a7 --- /dev/null +++ b/src/app/api/me/api-keys/route.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { GET, POST } from './route'; + +const mocks = vi.hoisted(() => ({ + createApiKey: vi.fn(), + getUserApiKeys: vi.fn(), +})); + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/queries/prisma/apiKey', () => ({ + createApiKey: mocks.createApiKey, + getUserApiKeys: mocks.getUserApiKeys, +})); + +const parseRequestMock = vi.mocked(parseRequest); + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('CLOUD_MODE', ''); + parseRequestMock.mockReset(); + mocks.createApiKey.mockReset(); + mocks.getUserApiKeys.mockReset(); + + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + body: { name: 'CI pipeline' }, + error: undefined, + }); +}); + +test('GET returns the current user api keys', async () => { + const keys = [{ id: 'key-1', name: 'CI pipeline', keyPrefix: 'umami_abcdefgh' }]; + mocks.getUserApiKeys.mockResolvedValue(keys as any); + + const response = await GET(new Request('http://localhost/api/me/api-keys')); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(keys); + expect(mocks.getUserApiKeys).toHaveBeenCalledWith('user-1'); +}); + +test('GET returns 404 in cloud mode', async () => { + vi.stubEnv('CLOUD_MODE', '1'); + + const response = await GET(new Request('http://localhost/api/me/api-keys')); + + expect(response.status).toBe(404); + expect(mocks.getUserApiKeys).not.toHaveBeenCalled(); +}); + +test('GET returns the auth error when unauthenticated', async () => { + parseRequestMock.mockResolvedValue({ + auth: null, + error: () => new Response(null, { status: 401 }), + }); + + const response = await GET(new Request('http://localhost/api/me/api-keys')); + + expect(response.status).toBe(401); +}); + +test('POST creates a key, stores only the hash, and returns the plaintext once', async () => { + mocks.createApiKey.mockImplementation(async data => ({ + id: data.id, + name: data.name, + keyPrefix: data.keyPrefix, + createdAt: new Date('2026-01-01T00:00:00Z'), + })); + + const response = await POST(new Request('http://localhost/api/me/api-keys', { method: 'POST' })); + + expect(response.status).toBe(200); + + const body = await response.json(); + + expect(body.key).toMatch(/^umami_[0-9a-zA-Z]{32}$/); + expect(body.name).toBe('CI pipeline'); + expect(body.keyPrefix).toBe(body.key.slice(0, 14)); + expect(body).not.toHaveProperty('keyHash'); + + const stored = mocks.createApiKey.mock.calls[0][0]; + + expect(stored.userId).toBe('user-1'); + expect(stored.keyHash).not.toBe(body.key); + expect(stored.keyHash).toHaveLength(128); + expect(stored).not.toHaveProperty('key'); +}); + +test('POST returns 404 in cloud mode', async () => { + vi.stubEnv('CLOUD_MODE', '1'); + + const response = await POST(new Request('http://localhost/api/me/api-keys', { method: 'POST' })); + + expect(response.status).toBe(404); + expect(mocks.createApiKey).not.toHaveBeenCalled(); +}); diff --git a/src/app/api/me/api-keys/route.ts b/src/app/api/me/api-keys/route.ts new file mode 100644 index 000000000..b08afc5e6 --- /dev/null +++ b/src/app/api/me/api-keys/route.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { generateApiKey, getApiKeyPrefix, hashApiKey, isApiKeyEnabled } from '@/lib/api-key'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, notFound } from '@/lib/response'; +import { createApiKey, getUserApiKeys } from '@/queries/prisma/apiKey'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (!isApiKeyEnabled()) { + return notFound(); + } + + return json(await getUserApiKeys(auth.user.id)); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().trim().min(1).max(255), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!isApiKeyEnabled()) { + return notFound(); + } + + const key = generateApiKey(); + + const apiKey = await createApiKey({ + id: uuid(), + userId: auth.user.id, + name: body.name, + keyHash: hashApiKey(key), + keyPrefix: getApiKeyPrefix(key), + }); + + // The plaintext key is only returned once, at creation time. + return json({ ...apiKey, key }); +} diff --git a/src/app/api/me/contract.generated.ts b/src/app/api/me/contract.generated.ts index 48af82b18..18a395cd4 100644 --- a/src/app/api/me/contract.generated.ts +++ b/src/app/api/me/contract.generated.ts @@ -38,6 +38,18 @@ const operation1 = defineOperation({ }, required: ['id', 'username', 'role', 'isAdmin'], }, + apiKey: { + type: 'object', + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + }, + }, + required: ['id', 'name'], + }, shareToken: { type: 'object', properties: { diff --git a/src/app/api/users/[userId]/contract.generated.ts b/src/app/api/users/[userId]/contract.generated.ts index 8a8eb56e8..fdf0b916e 100644 --- a/src/app/api/users/[userId]/contract.generated.ts +++ b/src/app/api/users/[userId]/contract.generated.ts @@ -770,6 +770,46 @@ const operation2 = defineOperation({ required: ['id', 'userId', 'otp', 'expiresAt'], }, }, + apiKeys: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + }, + userId: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'date-time', + }, + name: { + type: 'string', + }, + keyHash: { + type: 'string', + }, + keyPrefix: { + type: 'string', + }, + lastUsedAt: { + type: 'string', + format: 'date-time', + }, + }, + required: [ + 'id', + 'userId', + 'createdAt', + 'name', + 'keyHash', + 'keyPrefix', + 'lastUsedAt', + ], + }, + }, _count: { type: 'object', properties: { @@ -806,6 +846,9 @@ const operation2 = defineOperation({ twoFactorRateLimit: { type: 'number', }, + apiKeys: { + type: 'number', + }, }, required: [ 'websites', @@ -819,6 +862,7 @@ const operation2 = defineOperation({ 'twoFactorBackupCodes', 'twoFactorOtpUseds', 'twoFactorRateLimit', + 'apiKeys', ], }, }, @@ -840,6 +884,7 @@ const operation2 = defineOperation({ 'boards', 'twoFactorBackupCodes', 'twoFactorOtpUseds', + 'apiKeys', '_count', ], }, diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index b4a4db3c5..cc6e70571 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -11,6 +11,7 @@ export * from './context/useWebsite'; // Query hooks export * from './queries/useActiveUsersQuery'; +export * from './queries/useApiKeysQuery'; export * from './queries/useBoardQuery'; export * from './queries/useBoardSharesQuery'; export * from './queries/useBoardsQuery'; diff --git a/src/components/hooks/queries/useApiKeysQuery.ts b/src/components/hooks/queries/useApiKeysQuery.ts new file mode 100644 index 000000000..f921b46e5 --- /dev/null +++ b/src/components/hooks/queries/useApiKeysQuery.ts @@ -0,0 +1,20 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export interface ApiKeyData { + id: string; + name: string; + keyPrefix: string; + lastUsedAt: string | null; + createdAt: string; +} + +export function useApiKeysQuery() { + const { get, useQuery } = useApi(); + const { modified } = useModified('api-keys'); + + return useQuery({ + queryKey: ['api-keys', { modified }], + queryFn: () => get('/me/api-keys'), + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index 35e2f7bcb..e14f3aeed 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -430,6 +430,12 @@ export const labels: Record = { twoFactorGlobalActiveTooltip: 'label.two-factor-global-active-tooltip', twoFactorVerify: 'label.two-factor-verify', twoFactorLogin: 'label.two-factor-login', + apiKeys: 'label.api-keys', + apiKey: 'label.api-key', + createApiKey: 'label.create-api-key', + lastUsed: 'label.last-used', + never: 'label.never', + copy: 'label.copy', }; export const messages: Record = { @@ -511,4 +517,6 @@ export const messages: Record = { twoFactorErrorNoPendingSetup: 'message.two-factor-error-no-pending-setup', twoFactorErrorNotConfigured: 'message.two-factor-error-not-configured', replayMinDurationDescription: 'message.replay-min-duration-description', + apiKeyCreated: 'message.api-key-created', + noApiKeys: 'message.no-api-keys', }; diff --git a/src/components/modals/TwoFactorSetupModal.tsx b/src/components/modals/TwoFactorSetupModal.tsx index 4277b18f2..718c7d6f8 100644 --- a/src/components/modals/TwoFactorSetupModal.tsx +++ b/src/components/modals/TwoFactorSetupModal.tsx @@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { Alert, - AlertDescription, AlertTitle, Box, Button, @@ -193,8 +192,7 @@ export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalPr {error && ( - {t(messages.error)} - {error} + {error} )} diff --git a/src/lib/api-key.test.ts b/src/lib/api-key.test.ts new file mode 100644 index 000000000..3033076db --- /dev/null +++ b/src/lib/api-key.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + API_KEY_PREFIX, + generateApiKey, + getApiKeyPrefix, + hashApiKey, + isApiKey, + isApiKeyBlockedPath, + isApiKeyEnabled, +} from './api-key'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('generateApiKey', () => { + test('produces umami_ followed by 32 base62 characters', () => { + const key = generateApiKey(); + + expect(key).toMatch(/^umami_[0-9a-zA-Z]{32}$/); + }); + + test('produces unique keys', () => { + expect(generateApiKey()).not.toBe(generateApiKey()); + }); +}); + +describe('hashApiKey', () => { + test('is deterministic and does not equal the key', () => { + const key = generateApiKey(); + + expect(hashApiKey(key)).toBe(hashApiKey(key)); + expect(hashApiKey(key)).not.toBe(key); + expect(hashApiKey(key)).toHaveLength(128); + }); +}); + +describe('getApiKeyPrefix', () => { + test('returns the prefix plus the first 8 characters', () => { + const key = `${API_KEY_PREFIX}abcdefghijklmnopqrstuvwxyz012345`; + + expect(getApiKeyPrefix(key)).toBe('umami_abcdefgh'); + }); +}); + +describe('isApiKey', () => { + test('detects api keys by prefix', () => { + expect(isApiKey('umami_abc')).toBe(true); + expect(isApiKey('eyJhbGciOi')).toBe(false); + expect(isApiKey(undefined)).toBe(false); + expect(isApiKey(null)).toBe(false); + }); +}); + +describe('isApiKeyBlockedPath', () => { + test('blocks credential and admin routes', () => { + expect(isApiKeyBlockedPath('/api/me/password')).toBe(true); + expect(isApiKeyBlockedPath('/api/me/api-keys')).toBe(true); + expect(isApiKeyBlockedPath('/api/me/api-keys/abc')).toBe(true); + expect(isApiKeyBlockedPath('/api/2fa/status')).toBe(true); + expect(isApiKeyBlockedPath('/api/auth/logout')).toBe(true); + expect(isApiKeyBlockedPath('/api/users')).toBe(true); + expect(isApiKeyBlockedPath('/api/users/abc/websites')).toBe(true); + expect(isApiKeyBlockedPath('/api/admin/users')).toBe(true); + }); + + test('allows data routes', () => { + expect(isApiKeyBlockedPath('/api/me')).toBe(false); + expect(isApiKeyBlockedPath('/api/me/websites')).toBe(false); + expect(isApiKeyBlockedPath('/api/me/teams')).toBe(false); + expect(isApiKeyBlockedPath('/api/websites')).toBe(false); + expect(isApiKeyBlockedPath('/api/websites/abc/stats')).toBe(false); + expect(isApiKeyBlockedPath('/api/authors')).toBe(false); + }); +}); + +describe('isApiKeyEnabled', () => { + test('is disabled in cloud mode', () => { + vi.stubEnv('CLOUD_MODE', '1'); + + expect(isApiKeyEnabled()).toBe(false); + }); + + test('is enabled when CLOUD_MODE is unset', () => { + vi.stubEnv('CLOUD_MODE', ''); + + expect(isApiKeyEnabled()).toBe(true); + }); +}); diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts new file mode 100644 index 000000000..0840a6b69 --- /dev/null +++ b/src/lib/api-key.ts @@ -0,0 +1,42 @@ +import { hash } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; + +export const API_KEY_PREFIX = 'umami_'; +export const API_KEY_LENGTH = 32; +export const API_KEY_DISPLAY_LENGTH = API_KEY_PREFIX.length + 8; +export const API_KEY_LAST_USED_INTERVAL = 5 * 60 * 1000; + +// Routes that manage account credentials or admin state must not be +// reachable with an API key. Matched as path prefixes. +export const API_KEY_BLOCKED_PATHS = [ + '/api/me/password', + '/api/me/api-keys', + '/api/2fa', + '/api/auth', + '/api/users', + '/api/admin', +]; + +export function generateApiKey() { + return `${API_KEY_PREFIX}${getRandomChars(API_KEY_LENGTH)}`; +} + +export function hashApiKey(key: string) { + return hash(key); +} + +export function getApiKeyPrefix(key: string) { + return key.slice(0, API_KEY_DISPLAY_LENGTH); +} + +export function isApiKey(token?: string | null): token is string { + return typeof token === 'string' && token.startsWith(API_KEY_PREFIX); +} + +export function isApiKeyBlockedPath(pathname: string) { + return API_KEY_BLOCKED_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`)); +} + +export function isApiKeyEnabled() { + return !process.env.CLOUD_MODE; +} diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts index fed2dad29..9db0b8567 100644 --- a/src/lib/auth.test.ts +++ b/src/lib/auth.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { hash } from '@/lib/crypto'; import { parseSecureToken } from '@/lib/jwt'; import redis from '@/lib/redis'; +import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey'; import { getUser } from '@/queries/prisma/user'; +import { hashApiKey } from './api-key'; import { checkAuth } from './auth'; vi.mock('@/lib/jwt', () => ({ @@ -14,6 +16,11 @@ vi.mock('@/queries/prisma/user', () => ({ getUser: vi.fn(), })); +vi.mock('@/queries/prisma/apiKey', () => ({ + getApiKeyByHash: vi.fn(), + updateApiKeyLastUsed: vi.fn(() => Promise.resolve()), +})); + vi.mock('@/lib/redis', () => ({ default: { enabled: false, @@ -25,6 +32,8 @@ vi.mock('@/lib/redis', () => ({ const parseSecureTokenMock = vi.mocked(parseSecureToken); const getUserMock = vi.mocked(getUser); +const getApiKeyByHashMock = vi.mocked(getApiKeyByHash); +const updateApiKeyLastUsedMock = vi.mocked(updateApiKeyLastUsed); const redisMock = redis as unknown as { enabled: boolean; client: { @@ -50,12 +59,129 @@ function mockUser() { } beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('CLOUD_MODE', ''); parseSecureTokenMock.mockReset(); getUserMock.mockReset(); + getApiKeyByHashMock.mockReset(); + updateApiKeyLastUsedMock.mockClear(); redisMock.enabled = false; redisMock.client.get.mockReset(); }); +describe('checkAuth api keys', () => { + const API_KEY = 'umami_abcdefghijklmnopqrstuvwxyz012345'; + + function apiKeyRequest(path = '/api/websites') { + return new Request(`http://localhost${path}`, { + headers: { authorization: `Bearer ${API_KEY}` }, + }); + } + + function mockApiKey(lastUsedAt: Date | null = null) { + getApiKeyByHashMock.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + name: 'CI', + keyHash: hashApiKey(API_KEY), + keyPrefix: 'umami_abcdefgh', + lastUsedAt, + createdAt: new Date(), + } as any); + } + + test('authorizes a valid api key and looks it up by hash', async () => { + mockApiKey(); + mockUser(); + + const result: any = await checkAuth(apiKeyRequest()); + + expect(getApiKeyByHashMock).toHaveBeenCalledWith(hashApiKey(API_KEY)); + expect(result?.user?.id).toBe('user-1'); + expect(result?.user).not.toHaveProperty('password'); + expect(result?.apiKey).toEqual({ id: 'key-1', name: 'CI' }); + expect(parseSecureTokenMock).not.toHaveBeenCalled(); + }); + + test('rejects an unknown api key', async () => { + getApiKeyByHashMock.mockResolvedValue(null); + + const result = await checkAuth(apiKeyRequest()); + + expect(result).toBeNull(); + expect(getUserMock).not.toHaveBeenCalled(); + }); + + test('rejects an api key whose user no longer exists', async () => { + mockApiKey(); + getUserMock.mockResolvedValue(null as any); + + const result = await checkAuth(apiKeyRequest()); + + expect(result).toBeNull(); + }); + + test('rejects api keys on sensitive routes', async () => { + mockApiKey(); + mockUser(); + + for (const path of [ + '/api/me/password', + '/api/me/api-keys', + '/api/me/api-keys/key-1', + '/api/2fa/status', + '/api/auth/logout', + '/api/users', + '/api/admin/users', + ]) { + expect(await checkAuth(apiKeyRequest(path))).toBeNull(); + } + + expect(getApiKeyByHashMock).not.toHaveBeenCalled(); + }); + + test('ignores api keys in cloud mode', async () => { + vi.stubEnv('CLOUD_MODE', '1'); + mockApiKey(); + mockUser(); + parseSecureTokenMock.mockReturnValue(null); + + const result = await checkAuth(apiKeyRequest()); + + expect(result).toBeNull(); + expect(getApiKeyByHashMock).not.toHaveBeenCalled(); + expect(parseSecureTokenMock).toHaveBeenCalled(); + }); + + test('updates lastUsedAt when stale', async () => { + mockApiKey(new Date(Date.now() - 60 * 60 * 1000)); + mockUser(); + + await checkAuth(apiKeyRequest()); + + expect(updateApiKeyLastUsedMock).toHaveBeenCalledWith('key-1'); + }); + + test('does not update lastUsedAt when recently used', async () => { + mockApiKey(new Date()); + mockUser(); + + await checkAuth(apiKeyRequest()); + + expect(updateApiKeyLastUsedMock).not.toHaveBeenCalled(); + }); + + test('does not treat a jwt as an api key', async () => { + parseSecureTokenMock.mockReturnValue({ userId: 'user-1' } as any); + mockUser(); + + const result = await checkAuth(authedRequest()); + + expect(result?.user?.id).toBe('user-1'); + expect(getApiKeyByHashMock).not.toHaveBeenCalled(); + }); +}); + describe('checkAuth password fingerprint', () => { test('authorizes a stateless token whose fingerprint matches the current password', async () => { parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 559c7f90e..1c3bba6c9 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,11 @@ import debug from 'debug'; +import { + API_KEY_LAST_USED_INTERVAL, + hashApiKey, + isApiKey, + isApiKeyBlockedPath, + isApiKeyEnabled, +} from '@/lib/api-key'; import { ROLE_PERMISSIONS, ROLES, @@ -10,6 +17,7 @@ import { createAuthKey, hash, secret } from '@/lib/crypto'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; import redis from '@/lib/redis'; import { ensureArray } from '@/lib/utils'; +import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey'; import { getUser } from '@/queries/prisma/user'; const log = debug('umami:auth'); @@ -20,8 +28,51 @@ export function getBearerToken(request: Request) { return auth?.split(' ')[1]; } +export async function checkApiKeyAuth(request: Request, token: string) { + const { pathname } = new URL(request.url); + + if (isApiKeyBlockedPath(pathname)) { + log('API key not allowed for path', pathname); + return null; + } + + const apiKey = await getApiKeyByHash(hashApiKey(token)); + + if (!apiKey) { + log('API key not found'); + return null; + } + + const user: any = await getUser(apiKey.userId); + + if (!user?.id) { + log('API key user not found'); + return null; + } + + const lastUsedAt = apiKey.lastUsedAt?.getTime() ?? 0; + + if (Date.now() - lastUsedAt > API_KEY_LAST_USED_INTERVAL) { + updateApiKeyLastUsed(apiKey.id).catch(e => log(e)); + } + + delete user.password; + user.isAdmin = user.role === ROLES.admin; + + return { + token, + user, + apiKey: { id: apiKey.id, name: apiKey.name }, + }; +} + export async function checkAuth(request: Request) { const token = getBearerToken(request); + + if (isApiKeyEnabled() && isApiKey(token)) { + return checkApiKeyAuth(request, token); + } + const payload = parseSecureToken(token, secret()); const shareToken = await parseShareToken(request); diff --git a/src/lib/types.ts b/src/lib/types.ts index 98f47af6e..d6146ea11 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,10 @@ export interface Auth { role: string; isAdmin: boolean; }; + apiKey?: { + id: string; + name: string; + }; shareToken?: { shareType?: number; websiteId?: string; diff --git a/src/openapi/security.ts b/src/openapi/security.ts index 4195fa44f..1f00e35e4 100644 --- a/src/openapi/security.ts +++ b/src/openapi/security.ts @@ -5,7 +5,8 @@ export const securitySchemes = { type: 'http' as const, scheme: 'bearer', bearerFormat: 'JWT', - description: 'Token returned by POST /api/auth/login.', + description: + 'Token returned by POST /api/auth/login, or an API key (`umami_…`) created under Settings → API keys (self-hosted only).', }, shareToken: { type: 'apiKey' as const, diff --git a/src/queries/prisma/apiKey.ts b/src/queries/prisma/apiKey.ts new file mode 100644 index 000000000..f67411721 --- /dev/null +++ b/src/queries/prisma/apiKey.ts @@ -0,0 +1,54 @@ +import prisma from '@/lib/prisma'; + +export async function createApiKey(data: { + id: string; + userId: string; + name: string; + keyHash: string; + keyPrefix: string; +}) { + return prisma.client.apiKey.create({ + data, + select: { + id: true, + name: true, + keyPrefix: true, + createdAt: true, + }, + }); +} + +export async function getApiKeyByHash(keyHash: string) { + return prisma.client.apiKey.findUnique({ + where: { keyHash }, + }); +} + +export async function getUserApiKeys(userId: string) { + return prisma.client.apiKey.findMany({ + where: { userId }, + select: { + id: true, + name: true, + keyPrefix: true, + lastUsedAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); +} + +export async function deleteApiKey(id: string, userId: string) { + const { count } = await prisma.client.apiKey.deleteMany({ + where: { id, userId }, + }); + + return count; +} + +export async function updateApiKeyLastUsed(id: string) { + return prisma.client.apiKey.update({ + where: { id }, + data: { lastUsedAt: new Date() }, + }); +} diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index 1db320166..2ffc48ac8 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -1,4 +1,5 @@ export * from './annotation'; +export * from './apiKey'; export * from './board'; export * from './link'; export * from './pixel';