Skip to content

Commit

Permalink
Use scrypt for api keys (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
KMKoushik committed Jun 26, 2024
1 parent 1beced8 commit 57fcfbc
Show file tree
Hide file tree
Showing 10 changed files with 219 additions and 113 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');

-- CreateEnum
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'OPENED', 'CLICKED', 'BOUNCED', 'COMPLAINED', 'DELIVERED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERY_DELAYED', 'FAILED');
CREATE TYPE "EmailStatus" AS ENUM ('QUEUED', 'SENT', 'DELIVERY_DELAYED', 'BOUNCED', 'REJECTED', 'RENDERING_FAILURE', 'DELIVERED', 'OPENED', 'CLICKED', 'COMPLAINED', 'FAILED');

-- CreateTable
CREATE TABLE "AppSetting" (
Expand Down Expand Up @@ -132,6 +132,7 @@ CREATE TABLE "Domain" (
-- CreateTable
CREATE TABLE "ApiKey" (
"id" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"partialToken" TEXT NOT NULL,
"name" TEXT NOT NULL,
Expand Down Expand Up @@ -180,6 +181,9 @@ CREATE TABLE "EmailEvent" (
-- CreateIndex
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");

-- CreateIndex
CREATE UNIQUE INDEX "SesSetting_idPrefix_key" ON "SesSetting"("idPrefix");

-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");

Expand All @@ -202,7 +206,7 @@ CREATE UNIQUE INDEX "TeamUser_teamId_userId_key" ON "TeamUser"("teamId", "userId
CREATE UNIQUE INDEX "Domain_name_key" ON "Domain"("name");

-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash");
CREATE UNIQUE INDEX "ApiKey_clientId_key" ON "ApiKey"("clientId");

-- CreateIndex
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");
Expand Down
5 changes: 3 additions & 2 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ model AppSetting {
model SesSetting {
id String @id @default(cuid())
region String @unique
idPrefix String
idPrefix String @unique
topic String
topicArn String?
callbackUrl String
Expand Down Expand Up @@ -149,7 +149,8 @@ enum ApiPermission {

model ApiKey {
id Int @id @default(autoincrement())
tokenHash String @unique
clientId String @unique
tokenHash String
partialToken String
name String
permission ApiPermission @default(SENDING)
Expand Down
98 changes: 69 additions & 29 deletions apps/web/src/app/(dashboard)/api-keys/add-api-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,52 @@ import { api } from "~/trpc/react";
import { useState } from "react";
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
import { toast } from "@unsend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";

const apiKeySchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
}),
});

export default function AddApiKey() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
const addDomainMutation = api.apiKey.createToken.useMutation();
const createApiKeyMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);

const utils = api.useUtils();

function handleSave() {
addDomainMutation.mutate(
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
resolver: zodResolver(apiKeySchema),
defaultValues: {
name: "",
},
});

function handleSave(values: z.infer<typeof apiKeySchema>) {
createApiKeyMutation.mutate(
{
name,
name: values.name,
permission: "FULL",
},
{
onSuccess: (data) => {
utils.apiKey.invalidate();
setApiKey(data);
apiKeyForm.reset();
},
}
);
Expand All @@ -53,8 +78,8 @@ export default function AddApiKey() {
function copyAndClose() {
handleCopy();
setApiKey("");
setName("");
setOpen(false);
setShowApiKey(false);
toast.success("API key copied to clipboard");
}

Expand All @@ -70,7 +95,7 @@ export default function AddApiKey() {
</Button>
</DialogTrigger>
{apiKey ? (
<DialogContent>
<DialogContent key={apiKey}>
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
Expand All @@ -80,7 +105,7 @@ export default function AddApiKey() {
<p className="text-sm">{apiKey}</p>
) : (
<div className="flex gap-1">
{Array.from({ length: 30 }).map((_, index) => (
{Array.from({ length: 40 }).map((_, index) => (
<div
key={index}
className="w-1 h-1 bg-muted-foreground rounded-lg"
Expand Down Expand Up @@ -120,7 +145,7 @@ export default function AddApiKey() {
<Button
type="submit"
onClick={copyAndClose}
disabled={addDomainMutation.isPending}
disabled={createApiKeyMutation.isPending}
>
Close
</Button>
Expand All @@ -132,27 +157,42 @@ export default function AddApiKey() {
<DialogTitle>Create a new API key</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label htmlFor="name" className="text-right">
API key name
</Label>
<Input
id="name"
placeholder="prod key"
defaultValue=""
className="col-span-3 mt-1"
onChange={(e) => setName(e.target.value)}
value={name}
/>
<Form {...apiKeyForm}>
<form
onSubmit={apiKeyForm.handleSubmit(handleSave)}
className="space-y-8"
>
<FormField
control={apiKeyForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>API key name</FormLabel>
<FormControl>
<Input placeholder="prod key" {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription>
Use a name to easily identify this API key.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
</Button>
</div>
</form>
</Form>
</div>
<DialogFooter>
<Button
type="submit"
onClick={handleSave}
disabled={addDomainMutation.isPending}
>
Save changes
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
Expand Down
95 changes: 68 additions & 27 deletions apps/web/src/app/(dashboard)/api-keys/delete-api-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,56 @@

import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import { Label } from "@unsend/ui/src/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { ApiKey, Domain } from "@prisma/client";
import { ApiKey } from "@prisma/client";
import { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";

const apiKeySchema = z.object({
name: z.string(),
});

export const DeleteApiKey: React.FC<{
apiKey: Partial<ApiKey> & { id: number };
}> = ({ apiKey }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation();

const utils = api.useUtils();

function handleSave() {
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
resolver: zodResolver(apiKeySchema),
});

async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
if (values.name !== apiKey.name) {
apiKeyForm.setError("name", {
message: "Name does not match",
});
return;
}

deleteApiKeyMutation.mutate(
{
id: apiKey.id,
Expand All @@ -42,6 +66,8 @@ export const DeleteApiKey: React.FC<{
);
}

const name = apiKeyForm.watch("name");

return (
<Dialog
open={open}
Expand All @@ -62,29 +88,44 @@ export const DeleteApiKey: React.FC<{
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label htmlFor="name" className="text-right">
Type <span className="text-primary">{apiKey.name}</span> to confirm
</Label>
<Input
id="name"
defaultValue=""
className="mt-2"
onChange={(e) => setDomainName(e.target.value)}
value={domainName}
/>
<Form {...apiKeyForm}>
<form
onSubmit={apiKeyForm.handleSubmit(onDomainDelete)}
className="space-y-4"
>
<FormField
control={apiKeyForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteApiKeyMutation.isPending || apiKey.name !== name
}
>
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
<DialogFooter>
<Button
type="submit"
variant="destructive"
onClick={handleSave}
disabled={
deleteApiKeyMutation.isPending || apiKey.name !== domainName
}
>
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
Expand Down
17 changes: 2 additions & 15 deletions apps/web/src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import {
AuthOptions,
getServerSession,
type DefaultSession,
type NextAuthOptions,
Expand All @@ -9,7 +8,9 @@ import { type Adapter } from "next-auth/adapters";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import { Provider } from "next-auth/providers/index";

import { sendSignUpEmail } from "~/server/mailer";
import { env } from "~/env";
import { db } from "~/server/db";

Expand Down Expand Up @@ -116,17 +117,3 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/configuration/nextjs
*/
export const getServerAuthSession = () => getServerSession(authOptions);

import { createHash } from "crypto";
import { sendSignUpEmail } from "./mailer";
import { Provider } from "next-auth/providers/index";

/**
* Hashes a token using SHA-256.
*
* @param {string} token - The token to be hashed.
* @returns {string} The hashed token.
*/
export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
Loading

0 comments on commit 57fcfbc

Please sign in to comment.