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');
-- 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" (
@@ -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,
@@ -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");
@@ -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");

View File

@@ -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
@@ -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)

View File

@@ -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();
},
}
);
@@ -53,8 +78,8 @@ export default function AddApiKey() {
function copyAndClose() {
handleCopy();
setApiKey("");
setName("");
setOpen(false);
setShowApiKey(false);
toast.success("API key copied to clipboard");
}
@@ -70,7 +95,7 @@ export default function AddApiKey() {
</Button>
</DialogTrigger>
{apiKey ? (
<DialogContent>
<DialogContent key={apiKey}>
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
@@ -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"
@@ -120,7 +145,7 @@ export default function AddApiKey() {
<Button
type="submit"
onClick={copyAndClose}
disabled={addDomainMutation.isPending}
disabled={createApiKeyMutation.isPending}
>
Close
</Button>
@@ -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>

View File

@@ -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,
@@ -42,6 +66,8 @@ export const DeleteApiKey: React.FC<{
);
}
const name = apiKeyForm.watch("name");
return (
<Dialog
open={open}
@@ -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>
);

View File

@@ -1,6 +1,5 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import {
AuthOptions,
getServerSession,
type DefaultSession,
type NextAuthOptions,
@@ -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";
@@ -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");
}

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

View File

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

View File

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