Use scrypt for api keys (#33)
This commit is contained in:
@@ -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");
|
@@ -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)
|
||||||
|
@@ -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>
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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");
|
|
||||||
}
|
|
||||||
|
19
apps/web/src/server/crypto.ts
Normal file
19
apps/web/src/server/crypto.ts
Normal 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");
|
||||||
|
};
|
6
apps/web/src/server/nanoid.ts
Normal file
6
apps/web/src/server/nanoid.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
|
||||||
|
export const smallNanoid = customAlphabet(
|
||||||
|
"1234567890abcdefghijklmnopqrstuvwxyz",
|
||||||
|
10
|
||||||
|
);
|
@@ -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(),
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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: {
|
||||||
|
Reference in New Issue
Block a user