Use scrypt for api keys (#33)

This commit is contained in:
KM Koushik
2024-06-27 07:42:13 +10:00
committed by GitHub
parent 1beced823e
commit 57fcfbc9b6
10 changed files with 219 additions and 113 deletions

View File

@@ -8,7 +8,7 @@ CREATE TYPE "DomainStatus" AS ENUM ('NOT_STARTED', 'PENDING', 'SUCCESS', 'FAILED
CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING'); CREATE TYPE "ApiPermission" AS ENUM ('FULL', 'SENDING');
-- CreateEnum -- 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 -- CreateTable
CREATE TABLE "AppSetting" ( CREATE TABLE "AppSetting" (
@@ -132,6 +132,7 @@ CREATE TABLE "Domain" (
-- CreateTable -- CreateTable
CREATE TABLE "ApiKey" ( CREATE TABLE "ApiKey" (
"id" SERIAL NOT NULL, "id" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL, "tokenHash" TEXT NOT NULL,
"partialToken" TEXT NOT NULL, "partialToken" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
@@ -180,6 +181,9 @@ CREATE TABLE "EmailEvent" (
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region"); CREATE UNIQUE INDEX "SesSetting_region_key" ON "SesSetting"("region");
-- CreateIndex
CREATE UNIQUE INDEX "SesSetting_idPrefix_key" ON "SesSetting"("idPrefix");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
@@ -202,7 +206,7 @@ CREATE UNIQUE INDEX "TeamUser_teamId_userId_key" ON "TeamUser"("teamId", "userId
CREATE UNIQUE INDEX "Domain_name_key" ON "Domain"("name"); CREATE UNIQUE INDEX "Domain_name_key" ON "Domain"("name");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "ApiKey_tokenHash_key" ON "ApiKey"("tokenHash"); CREATE UNIQUE INDEX "ApiKey_clientId_key" ON "ApiKey"("clientId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId"); CREATE UNIQUE INDEX "Email_sesEmailId_key" ON "Email"("sesEmailId");

View File

@@ -22,7 +22,7 @@ model AppSetting {
model SesSetting { model SesSetting {
id String @id @default(cuid()) id String @id @default(cuid())
region String @unique region String @unique
idPrefix String idPrefix String @unique
topic String topic String
topicArn String? topicArn String?
callbackUrl String callbackUrl String
@@ -149,7 +149,8 @@ enum ApiPermission {
model ApiKey { model ApiKey {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
tokenHash String @unique clientId String @unique
tokenHash String
partialToken String partialToken String
name String name String
permission ApiPermission @default(SENDING) permission ApiPermission @default(SENDING)

View File

@@ -16,27 +16,52 @@ import { api } from "~/trpc/react";
import { useState } from "react"; import { useState } from "react";
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react"; import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
import { toast } from "@unsend/ui/src/toaster"; 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() { export default function AddApiKey() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const addDomainMutation = api.apiKey.createToken.useMutation(); const createApiKeyMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
function handleSave() { const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
addDomainMutation.mutate( resolver: zodResolver(apiKeySchema),
defaultValues: {
name: "",
},
});
function handleSave(values: z.infer<typeof apiKeySchema>) {
createApiKeyMutation.mutate(
{ {
name, name: values.name,
permission: "FULL", permission: "FULL",
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
utils.apiKey.invalidate(); utils.apiKey.invalidate();
setApiKey(data); setApiKey(data);
apiKeyForm.reset();
}, },
} }
); );
@@ -53,8 +78,8 @@ export default function AddApiKey() {
function copyAndClose() { function copyAndClose() {
handleCopy(); handleCopy();
setApiKey(""); setApiKey("");
setName("");
setOpen(false); setOpen(false);
setShowApiKey(false);
toast.success("API key copied to clipboard"); toast.success("API key copied to clipboard");
} }
@@ -70,7 +95,7 @@ export default function AddApiKey() {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
{apiKey ? ( {apiKey ? (
<DialogContent> <DialogContent key={apiKey}>
<DialogHeader> <DialogHeader>
<DialogTitle>Copy API key</DialogTitle> <DialogTitle>Copy API key</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -80,7 +105,7 @@ export default function AddApiKey() {
<p className="text-sm">{apiKey}</p> <p className="text-sm">{apiKey}</p>
) : ( ) : (
<div className="flex gap-1"> <div className="flex gap-1">
{Array.from({ length: 30 }).map((_, index) => ( {Array.from({ length: 40 }).map((_, index) => (
<div <div
key={index} key={index}
className="w-1 h-1 bg-muted-foreground rounded-lg" className="w-1 h-1 bg-muted-foreground rounded-lg"
@@ -120,7 +145,7 @@ export default function AddApiKey() {
<Button <Button
type="submit" type="submit"
onClick={copyAndClose} onClick={copyAndClose}
disabled={addDomainMutation.isPending} disabled={createApiKeyMutation.isPending}
> >
Close Close
</Button> </Button>
@@ -132,27 +157,42 @@ export default function AddApiKey() {
<DialogTitle>Create a new API key</DialogTitle> <DialogTitle>Create a new API key</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div className="py-2">
<Label htmlFor="name" className="text-right"> <Form {...apiKeyForm}>
API key name <form
</Label> onSubmit={apiKeyForm.handleSubmit(handleSave)}
<Input className="space-y-8"
id="name"
placeholder="prod key"
defaultValue=""
className="col-span-3 mt-1"
onChange={(e) => setName(e.target.value)}
value={name}
/>
</div>
<DialogFooter>
<Button
type="submit"
onClick={handleSave}
disabled={addDomainMutation.isPending}
> >
Save changes <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> </Button>
</DialogFooter> </div>
</form>
</Form>
</div>
</DialogContent> </DialogContent>
)} )}
</Dialog> </Dialog>

View File

@@ -2,32 +2,56 @@
import { Button } from "@unsend/ui/src/button"; import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input"; import { Input } from "@unsend/ui/src/input";
import { Label } from "@unsend/ui/src/label";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@unsend/ui/src/dialog"; } from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "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 { toast } from "@unsend/ui/src/toaster";
import { Trash2 } from "lucide-react"; 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<{ export const DeleteApiKey: React.FC<{
apiKey: Partial<ApiKey> & { id: number }; apiKey: Partial<ApiKey> & { id: number };
}> = ({ apiKey }) => { }> = ({ apiKey }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation(); const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation();
const utils = api.useUtils(); 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( deleteApiKeyMutation.mutate(
{ {
id: apiKey.id, id: apiKey.id,
@@ -42,6 +66,8 @@ export const DeleteApiKey: React.FC<{
); );
} }
const name = apiKeyForm.watch("name");
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -62,29 +88,44 @@ export const DeleteApiKey: React.FC<{
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div className="py-2">
<Label htmlFor="name" className="text-right"> <Form {...apiKeyForm}>
Type <span className="text-primary">{apiKey.name}</span> to confirm <form
</Label> onSubmit={apiKeyForm.handleSubmit(onDomainDelete)}
<Input className="space-y-4"
id="name" >
defaultValue="" <FormField
className="mt-2" control={apiKeyForm.control}
onChange={(e) => setDomainName(e.target.value)} name="name"
value={domainName} render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/> />
</div> <div className="flex justify-end">
<DialogFooter>
<Button <Button
type="submit" type="submit"
variant="destructive" variant="destructive"
onClick={handleSave}
disabled={ disabled={
deleteApiKeyMutation.isPending || apiKey.name !== domainName deleteApiKeyMutation.isPending || apiKey.name !== name
} }
> >
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"} {deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
</Button> </Button>
</DialogFooter> </div>
</form>
</Form>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,5 @@
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import { import {
AuthOptions,
getServerSession, getServerSession,
type DefaultSession, type DefaultSession,
type NextAuthOptions, type NextAuthOptions,
@@ -9,7 +8,9 @@ import { type Adapter } from "next-auth/adapters";
import GitHubProvider from "next-auth/providers/github"; import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email"; import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import { Provider } from "next-auth/providers/index";
import { sendSignUpEmail } from "~/server/mailer";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
@@ -116,17 +117,3 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/configuration/nextjs * @see https://next-auth.js.org/configuration/nextjs
*/ */
export const getServerAuthSession = () => getServerSession(authOptions); 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");
}

View File

@@ -0,0 +1,19 @@
import { randomBytes, scryptSync } from "crypto";
export const createSecureHash = async (key: string) => {
const data = new TextEncoder().encode(key);
const salt = randomBytes(16).toString("hex");
const derivedKey = scryptSync(data, salt, 64);
return `${salt}:${derivedKey.toString("hex")}`;
};
export const verifySecureHash = async (key: string, hash: string) => {
const data = new TextEncoder().encode(key);
const [salt, storedHash] = hash.split(":");
const derivedKey = scryptSync(data, String(salt), 64);
return storedHash === derivedKey.toString("hex");
};

View File

@@ -0,0 +1,6 @@
import { customAlphabet } from "nanoid";
export const smallNanoid = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyz",
10
);

View File

@@ -1,9 +1,9 @@
import TTLCache from "@isaacs/ttlcache"; import TTLCache from "@isaacs/ttlcache";
import { Context } from "hono"; import { Context } from "hono";
import { hashToken } from "../auth";
import { db } from "../db"; import { db } from "../db";
import { UnsendApiError } from "./api-error"; import { UnsendApiError } from "./api-error";
import { env } from "~/env"; import { env } from "~/env";
import { getTeamAndApiKey } from "../service/api-service";
const rateLimitCache = new TTLCache({ const rateLimitCache = new TTLCache({
ttl: 1000, // 1 second ttl: 1000, // 1 second
@@ -34,17 +34,16 @@ export const getTeamFromToken = async (c: Context) => {
checkRateLimit(token); checkRateLimit(token);
const hashedToken = hashToken(token); const teamAndApiKey = await getTeamAndApiKey(token);
const team = await db.team.findFirst({ if (!teamAndApiKey) {
where: { throw new UnsendApiError({
apiKeys: { code: "FORBIDDEN",
some: { message: "Invalid API token",
tokenHash: hashedToken,
},
},
},
}); });
}
const { team, apiKey } = teamAndApiKey;
if (!team) { if (!team) {
throw new UnsendApiError({ throw new UnsendApiError({
@@ -57,7 +56,7 @@ export const getTeamFromToken = async (c: Context) => {
db.apiKey db.apiKey
.update({ .update({
where: { where: {
tokenHash: hashedToken, id: apiKey.id,
}, },
data: { data: {
lastUsed: new Date(), lastUsed: new Date(),

View File

@@ -1,7 +1,8 @@
import { ApiPermission } from "@prisma/client"; import { ApiPermission } from "@prisma/client";
import { db } from "../db"; import { db } from "../db";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { hashToken } from "../auth"; import { smallNanoid } from "../nanoid";
import { createSecureHash, verifySecureHash } from "../crypto";
export async function addApiKey({ export async function addApiKey({
name, name,
@@ -13,8 +14,11 @@ export async function addApiKey({
teamId: number; teamId: number;
}) { }) {
try { try {
const token = `us_${randomBytes(20).toString("hex")}`; const clientId = smallNanoid(10);
const hashedToken = hashToken(token); const token = randomBytes(16).toString("hex");
const hashedToken = await createSecureHash(token);
const apiKey = `us_${clientId}_${token}`;
await db.apiKey.create({ await db.apiKey.create({
data: { data: {
@@ -22,39 +26,46 @@ export async function addApiKey({
permission: permission, permission: permission,
teamId, teamId,
tokenHash: hashedToken, tokenHash: hashedToken,
partialToken: `${token.slice(0, 8)}...${token.slice(-5)}`, partialToken: `${apiKey.slice(0, 6)}...${apiKey.slice(-3)}`,
clientId,
}, },
}); });
return token; return apiKey;
} catch (error) { } catch (error) {
console.error("Error adding API key:", error); console.error("Error adding API key:", error);
throw error; throw error;
} }
} }
export async function retrieveApiKey(token: string) { export async function getTeamAndApiKey(apiKey: string) {
const hashedToken = hashToken(token); const [, clientId, token] = apiKey.split("_") as [string, string, string];
try { const apiKeyRow = await db.apiKey.findUnique({
const apiKey = await db.apiKey.findUnique({
where: { where: {
tokenHash: hashedToken, clientId,
},
select: {
id: true,
name: true,
permission: true,
teamId: true,
partialToken: true,
}, },
}); });
if (!apiKey) {
throw new Error("API Key not found"); if (!apiKeyRow) {
return null;
} }
return apiKey;
try {
const isValid = await verifySecureHash(token, apiKeyRow.tokenHash);
if (!isValid) {
return null;
}
const team = await db.team.findUnique({
where: {
id: apiKeyRow.teamId,
},
});
return { team, apiKey: apiKeyRow };
} catch (error) { } catch (error) {
console.error("Error retrieving API key:", error); console.error("Error verifying API key:", error);
throw error; return null;
} }
} }

View File

@@ -1,13 +1,11 @@
import { SesSetting } from "@prisma/client"; import { SesSetting } from "@prisma/client";
import { db } from "../db"; import { db } from "../db";
import { env } from "~/env"; import { env } from "~/env";
import { customAlphabet } from "nanoid";
import * as sns from "~/server/aws/sns"; import * as sns from "~/server/aws/sns";
import * as ses from "~/server/aws/ses"; import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2"; import { EventType } from "@aws-sdk/client-sesv2";
import { EmailQueueService } from "./email-queue-service"; import { EmailQueueService } from "./email-queue-service";
import { smallNanoid } from "../nanoid";
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10);
const GENERAL_EVENTS: EventType[] = [ const GENERAL_EVENTS: EventType[] = [
"BOUNCE", "BOUNCE",
@@ -75,7 +73,7 @@ export class SesSettingsService {
); );
} }
const idPrefix = nanoid(10); const idPrefix = smallNanoid(10);
const setting = await db.sesSetting.create({ const setting = await db.sesSetting.create({
data: { data: {