From 6aacc0c7a86e36c52a3c2c1d26fe58cefcd3bec4 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Mon, 12 Feb 2024 14:52:00 +0000 Subject: feature: Add support for managing API keys --- .../20240211184744_add_api_key/migration.sql | 16 +++ packages/db/prisma/schema.prisma | 13 ++ packages/web/app/dashboard/components/Sidebar.tsx | 15 ++- .../dashboard/settings/components/AddApiKey.tsx | 148 +++++++++++++++++++++ .../settings/components/ApiKeySettings.tsx | 49 +++++++ .../dashboard/settings/components/DeleteApiKey.tsx | 65 +++++++++ packages/web/app/dashboard/settings/page.tsx | 10 ++ packages/web/components/ui/dialog.tsx | 122 +++++++++++++++++ packages/web/components/ui/table.tsx | 117 ++++++++++++++++ packages/web/package.json | 3 + packages/web/server/api/routers/_app.ts | 2 + packages/web/server/api/routers/apiKeys.ts | 67 ++++++++++ packages/web/server/auth.ts | 75 +++++++++++ packages/workers/openai.ts | 8 +- 14 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql create mode 100644 packages/web/app/dashboard/settings/components/AddApiKey.tsx create mode 100644 packages/web/app/dashboard/settings/components/ApiKeySettings.tsx create mode 100644 packages/web/app/dashboard/settings/components/DeleteApiKey.tsx create mode 100644 packages/web/app/dashboard/settings/page.tsx create mode 100644 packages/web/components/ui/dialog.tsx create mode 100644 packages/web/components/ui/table.tsx create mode 100644 packages/web/server/api/routers/apiKeys.ts (limited to 'packages') diff --git a/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql new file mode 100644 index 00000000..c39bf511 --- /dev/null +++ b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "keyId" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyId_key" ON "ApiKey"("keyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 623a9f13..5c575c97 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { sessions Session[] tags BookmarkTags[] bookmarks Bookmark[] + apiKeys ApiKey[] } model VerificationToken { @@ -57,6 +58,18 @@ model VerificationToken { @@unique([identifier, token]) } +model ApiKey { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + keyId String @unique + keyHash String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([name, userId]) +} + model Bookmark { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index 44892e81..d2ec14a6 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -1,5 +1,13 @@ import { Button } from "@/components/ui/button"; -import { Archive, MoreHorizontal, Star, Tag, Home, Brain } from "lucide-react"; +import { + Archive, + MoreHorizontal, + Star, + Tag, + Home, + Brain, + Settings, +} from "lucide-react"; import { redirect } from "next/navigation"; import SidebarItem from "./SidebarItem"; import { getServerAuthSession } from "@/server/auth"; @@ -35,6 +43,11 @@ export default async function Sidebar() { path="/dashboard/bookmarks/archive" /> } name="Tags" path="#" /> + } + name="Settings" + path="/dashboard/settings" + />
diff --git a/packages/web/app/dashboard/settings/components/AddApiKey.tsx b/packages/web/app/dashboard/settings/components/AddApiKey.tsx new file mode 100644 index 00000000..f4f2894c --- /dev/null +++ b/packages/web/app/dashboard/settings/components/AddApiKey.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { z } from "zod"; +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { useState } from "react"; +import { Copy } from "lucide-react"; + +function ApiKeySuccess({ apiKey }: { apiKey: string }) { + return ( +
+
+ Note: please copy the key and store it somewhere safe. Once you close + the dialog, you won't be able to access it again. +
+
+ + +
+
+ ); +} + +function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const formSchema = z.object({ + name: z.string(), + }); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer) { + try { + const resp = await api.apiKeys.create.mutate({ name: value.name }); + onSuccess(resp.key); + } catch (e) { + toast({ description: "Something went wrong", variant: "destructive" }); + return; + } + router.refresh(); + } + + const onError: SubmitErrorHandler> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); + }; + + return ( +
+ + { + return ( + + Name + + + + + Give your API key a unique name + + + + ); + }} + /> + + + + ); +} + +export default function AddApiKey() { + const [key, setKey] = useState(undefined); + const [dialogOpen, setDialogOpen] = useState(false); + return ( + + + + + + + + {key ? "Key was successfully created" : "Create API key"} + + + {key ? ( + + ) : ( + + )} + + + + + + + + + + ); +} diff --git a/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx new file mode 100644 index 00000000..1598f25f --- /dev/null +++ b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx @@ -0,0 +1,49 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/server/api/client"; +import DeleteApiKey from "./DeleteApiKey"; +import AddApiKey from "./AddApiKey"; + +export default async function ApiKeys() { + const keys = await api.apiKeys.list(); + return ( +
+ API Keys +
+
+
+ +
+ + + + Name + Key + Created At + Action + + + + {keys.keys.map((k) => ( + + {k.name} + **_{k.keyId}_** + {k.createdAt.toLocaleString()} + + + + + ))} + + +
+
+
+ ); +} diff --git a/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx new file mode 100644 index 00000000..715b7a2c --- /dev/null +++ b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Trash } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/lib/trpc"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; + +export default function DeleteApiKey({ + name, + id, +}: { + name: string; + id: string; +}) { + const router = useRouter(); + const deleteKey = async () => { + await api.apiKeys.revoke.mutate({ id }); + toast({ + description: "Key was successfully deleted", + }); + router.refresh(); + }; + return ( + + + + + + + Delete API Key + + Are you sure you want to delete the API key "{name}"? Any + service using this API key will lose access. + + + + + + + + + + + + + ); +} diff --git a/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx new file mode 100644 index 00000000..e8799583 --- /dev/null +++ b/packages/web/app/dashboard/settings/page.tsx @@ -0,0 +1,10 @@ +import { Button } from "@/components/ui/button"; +import ApiKeySettings from "./components/ApiKeySettings"; +export default async function Settings() { + return ( +
+

Settings

+ +
+ ); +} diff --git a/packages/web/components/ui/dialog.tsx b/packages/web/components/ui/dialog.tsx new file mode 100644 index 00000000..8fe3fe35 --- /dev/null +++ b/packages/web/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/packages/web/components/ui/table.tsx b/packages/web/components/ui/table.tsx new file mode 100644 index 00000000..0fa9288e --- /dev/null +++ b/packages/web/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/packages/web/package.json b/packages/web/package.json index 0601e4f4..d66a3fe7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@next-auth/prisma-adapter": "^1.0.7", "@next/eslint-plugin-next": "^14.1.0", "@prisma/client": "^5.9.1", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", @@ -22,6 +23,7 @@ "@trpc/client": "11.0.0-next-beta.274", "@trpc/next": "11.0.0-next-beta.274", "@trpc/server": "11.0.0-next-beta.274", + "bcrypt": "^5.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "install": "^0.13.0", @@ -38,6 +40,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index a4f9c629..2097b47d 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,7 +1,9 @@ import { router } from "../trpc"; +import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; export const appRouter = router({ bookmarks: bookmarksAppRouter, + apiKeys: apiKeysAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts new file mode 100644 index 00000000..b681d43f --- /dev/null +++ b/packages/web/server/api/routers/apiKeys.ts @@ -0,0 +1,67 @@ +import { generateApiKey } from "@/server/auth"; +import { authedProcedure, router } from "../trpc"; +import { prisma } from "@remember/db"; +import { z } from "zod"; + +export const apiKeysAppRouter = router({ + create: authedProcedure + .input( + z.object({ + name: z.string(), + }), + ) + .output( + z.object({ + id: z.string(), + name: z.string(), + key: z.string(), + createdAt: z.date(), + }), + ) + .mutation(async ({ input, ctx }) => { + return await generateApiKey(input.name, ctx.user.id); + }), + revoke: authedProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .output(z.object({})) + .mutation(async ({ input, ctx }) => { + const resp = await prisma.apiKey.delete({ + where: { + id: input.id, + userId: ctx.user.id, + }, + }); + return resp; + }), + list: authedProcedure + .output( + z.object({ + keys: z.array( + z.object({ + id: z.string(), + name: z.string(), + createdAt: z.date(), + keyId: z.string(), + }), + ), + }), + ) + .query(async ({ ctx }) => { + const resp = await prisma.apiKey.findMany({ + where: { + userId: ctx.user.id, + }, + select: { + id: true, + name: true, + createdAt: true, + keyId: true, + }, + }); + return { keys: resp }; + }), +}); diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index 05d3d296..f78fa8cf 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -4,6 +4,9 @@ import AuthentikProvider from "next-auth/providers/authentik"; import serverConfig from "@/server/config"; import { prisma } from "@remember/db"; import { DefaultSession } from "next-auth"; +import * as bcrypt from "bcrypt"; + +import { randomBytes } from "crypto"; declare module "next-auth" { /** @@ -37,3 +40,75 @@ export const authOptions: NextAuthOptions = { export const authHandler = NextAuth(authOptions); export const getServerAuthSession = () => getServerSession(authOptions); + +// API Keys + +const BCRYPT_SALT_ROUNDS = 10; +const API_KEY_PREFIX = "ak1"; + +export async function generateApiKey(name: string, userId: string) { + const id = randomBytes(10).toString("hex"); + const secret = randomBytes(10).toString("hex"); + const secretHash = await bcrypt.hash(secret, BCRYPT_SALT_ROUNDS); + + const plain = `${API_KEY_PREFIX}_${id}_${secret}`; + + const key = await prisma.apiKey.create({ + data: { + name: name, + userId: userId, + keyId: id, + keyHash: secretHash, + }, + }); + + return { + id: key.id, + name: key.name, + createdAt: key.createdAt, + key: plain, + }; +} + +function parseApiKey(plain: string) { + const parts = plain.split("_"); + if (parts.length != 3) { + throw new Error( + `Malformd API key. API keys should have 3 segments, found ${parts.length} instead.`, + ); + } + if (parts[0] !== API_KEY_PREFIX) { + throw new Error(`Malformd API key. Got unexpected key prefix.`); + } + return { + keyId: parts[1], + keySecret: parts[2], + }; +} + +export async function authenticateApiKey(key: string) { + const { keyId, keySecret } = parseApiKey(key); + const apiKey = await prisma.apiKey.findUnique({ + where: { + keyId, + }, + include: { + user: true, + }, + }); + + if (!apiKey) { + throw new Error("API key not found"); + } + + const hash = apiKey.keyHash; + + const validation = await bcrypt.compare(keySecret, hash); + if (!validation) { + throw new Error("Invalid API Key"); + } + + return { + user: apiKey.user, + }; +} diff --git a/packages/workers/openai.ts b/packages/workers/openai.ts index 999f2827..1adedeba 100644 --- a/packages/workers/openai.ts +++ b/packages/workers/openai.ts @@ -1,6 +1,11 @@ import { prisma, BookmarkedLink } from "@remember/db"; import logger from "@remember/shared/logger"; -import { OpenAIQueue, ZOpenAIRequest, queueConnectionDetails, zOpenAIRequestSchema } from "@remember/shared/queues"; +import { + OpenAIQueue, + ZOpenAIRequest, + queueConnectionDetails, + zOpenAIRequestSchema, +} from "@remember/shared/queues"; import { Job } from "bullmq"; import OpenAI from "openai"; import { z } from "zod"; @@ -9,7 +14,6 @@ import { Worker } from "bullmq"; const openAIResponseSchema = z.object({ tags: z.array(z.string()), }); - export class OpenAiWorker { static async build() { -- cgit v1.2.3-70-g09d2