From ba1fd8a3f77c4bce056efa2d054e759ae4e70e3d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 16 Nov 2024 02:48:28 +0800 Subject: [PATCH] feat: totp dynamic secret --- .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 10 +- .../services/dynamic-secret/providers/totp.ts | 64 ++++++ frontend/src/hooks/api/dynamicSecret/types.ts | 9 +- .../CreateDynamicSecretForm.tsx | 26 ++- .../CreateDynamicSecretForm/TotpInputForm.tsx | 197 ++++++++++++++++ .../CreateDynamicSecretLease.tsx | 39 +++- .../EditDynamicSecretForm.tsx | 18 ++ .../EditDynamicSecretTotpForm.tsx | 214 ++++++++++++++++++ 9 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 backend/src/ee/services/dynamic-secret/providers/totp.ts create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index f70985379..e51462be6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -13,6 +13,7 @@ import { RabbitMqProvider } from "./rabbit-mq"; import { RedisDatabaseProvider } from "./redis"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; +import { TotpProvider } from "./totp"; export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), @@ -27,5 +28,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), [DynamicSecretProviders.Ldap]: LdapProvider(), [DynamicSecretProviders.SapHana]: SapHanaProvider(), - [DynamicSecretProviders.Snowflake]: SnowflakeProvider() + [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), + [DynamicSecretProviders.Totp]: TotpProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index d98215fd4..4df217847 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -221,6 +221,10 @@ export const LdapSchema = z.union([ }) ]); +export const DynamicSecretTotpSchema = z.object({ + url: z.string().trim().min(1) +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -234,7 +238,8 @@ export enum DynamicSecretProviders { AzureEntraID = "azure-entra-id", Ldap = "ldap", SapHana = "sap-hana", - Snowflake = "snowflake" + Snowflake = "snowflake", + Totp = "totp" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -250,7 +255,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/totp.ts b/backend/src/ee/services/dynamic-secret/providers/totp.ts new file mode 100644 index 000000000..c7ecfa2b7 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/totp.ts @@ -0,0 +1,64 @@ +import { authenticator } from "otplib"; +import { HashAlgorithms } from "otplib/core"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretTotpSchema, TDynamicProviderFns } from "./models"; + +export const TotpProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretTotpSchema.parseAsync(inputs); + + const urlObj = new URL(providerInputs.url); + const secret = urlObj.searchParams.get("secret"); + if (!secret) { + throw new BadRequestError({ + message: "TOTP secret is missing from URL" + }); + } + + return providerInputs; + }; + + const validateConnection = async () => { + return true; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const entityId = alphaNumericNanoId(32); + const authenticatorInstance = authenticator.clone(); + + const urlObj = new URL(providerInputs.url); + const secret = urlObj.searchParams.get("secret") as string; + const periodFromUrl = urlObj.searchParams.get("period"); + const digitsFromUrl = urlObj.searchParams.get("digits"); + const algorithm = urlObj.searchParams.get("algorithm"); + + authenticatorInstance.options = { + digits: digitsFromUrl ? +digitsFromUrl : undefined, + algorithm: algorithm ? (algorithm.toLowerCase() as HashAlgorithms) : undefined, + step: periodFromUrl ? +periodFromUrl : undefined + }; + + return { entityId, data: { TOTP: authenticatorInstance.generate(secret) } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + return { entityId }; + }; + + const renew = async (inputs: unknown, entityId: string) => { + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 7ac8d4147..14c86c4d8 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -28,7 +28,8 @@ export enum DynamicSecretProviders { AzureEntraId = "azure-entra-id", Ldap = "ldap", SapHana = "sap-hana", - Snowflake = "snowflake" + Snowflake = "snowflake", + Totp = "totp" } export enum SqlProviders { @@ -230,6 +231,12 @@ export type TDynamicSecretProvider = revocationStatement: string; renewStatement?: string; }; + } + | { + type: DynamicSecretProviders.Totp; + inputs: { + url: string; + }; }; export type TCreateDynamicSecretDTO = { projectSlug: string; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 490ad6f48..44a435958 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -11,7 +11,7 @@ import { SiSnowflake } from "react-icons/si"; import { faAws } from "@fortawesome/free-brands-svg-icons"; -import { faDatabase } from "@fortawesome/free-solid-svg-icons"; +import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -31,6 +31,7 @@ import { RabbitMqInputForm } from "./RabbitMqInputForm"; import { RedisInputForm } from "./RedisInputForm"; import { SapHanaInputForm } from "./SapHanaInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; +import { TotpInputForm } from "./TotpInputForm"; type Props = { isOpen?: boolean; @@ -110,6 +111,11 @@ const DYNAMIC_SECRET_LIST = [ icon: , provider: DynamicSecretProviders.Snowflake, title: "Snowflake" + }, + { + icon: , + provider: DynamicSecretProviders.Totp, + title: "TOTP" } ]; @@ -405,6 +411,24 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Totp && ( + + + + )} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx new file mode 100644 index 000000000..684bc2b88 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/TotpInputForm.tsx @@ -0,0 +1,197 @@ +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + url: z.string().trim().min(1) + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z + .string() + .trim() + .min(1) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const TotpInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.Totp, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: err instanceof Error ? err.message : "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration + + +
+ + Docs + +
+
+ +
+
+ ( + + + + )} + /> +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index d4ba30158..4f024eb7c 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -10,7 +10,7 @@ import { z } from "zod"; import { TtlFormLabel } from "@app/components/features"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, IconButton, Input, SecretInput, Tooltip } from "@app/components/v2"; -import { useTimedReset } from "@app/hooks"; +import { useTimedReset, useToggle } from "@app/hooks"; import { useCreateDynamicSecretLease } from "@app/hooks/api"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; @@ -242,11 +242,26 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { ); } + if (provider === DynamicSecretProviders.Totp) { + const { TOTP } = data as { + TOTP: string; + }; + + return ( +
+ +
+ ); + } + return null; }; const formSchema = z.object({ - ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number") + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() }); type TForm = z.infer; @@ -259,6 +274,8 @@ type Props = { secretPath: string; }; +const PROVIDERS_WITH_AUTOGENERATE_SUPPORT = [DynamicSecretProviders.Totp]; + export const CreateDynamicSecretLease = ({ onClose, projectSlug, @@ -277,6 +294,9 @@ export const CreateDynamicSecretLease = ({ ttl: "1h" } }); + const [isPreloading, setIsPreloading] = useToggle( + PROVIDERS_WITH_AUTOGENERATE_SUPPORT.includes(provider) + ); const createDynamicSecretLease = useCreateDynamicSecretLease(); @@ -290,10 +310,13 @@ export const CreateDynamicSecretLease = ({ ttl, dynamicSecretName }); + createNotification({ type: "success", text: "Successfully leased dynamic secret" }); + + setIsPreloading.off(); } catch (error) { console.log(error); createNotification({ @@ -303,8 +326,18 @@ export const CreateDynamicSecretLease = ({ } }; + useEffect(() => { + if (provider === DynamicSecretProviders.Totp) { + handleDynamicSecretLeaseCreate({}); + } + }, [provider]); + const isOutputMode = Boolean(createDynamicSecretLease?.data); + if (isPreloading) { + return
; + } + return (
diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 7c37ed7b2..d260b9f40 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -17,6 +17,7 @@ import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProv import { EditDynamicSecretSapHanaForm } from "./EditDynamicSecretSapHanaForm"; import { EditDynamicSecretSnowflakeForm } from "./EditDynamicSecretSnowflakeForm"; import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm"; +import { EditDynamicSecretTotpForm } from "./EditDynamicSecretTotpForm"; type Props = { onClose: () => void; @@ -276,6 +277,23 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.Totp && ( + + + + )} ); }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx new file mode 100644 index 000000000..b27cf2e57 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretTotpForm.tsx @@ -0,0 +1,214 @@ +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + inputs: z + .object({ + url: z.string().trim().min(1) + }) + .partial(), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + newName: z + .string() + .trim() + .min(1) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const EditDynamicSecretTotpForm = ({ + onClose, + dynamicSecret, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL, + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { + // wait till previous request is finished + if (updateDynamicSecret.isLoading) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + maxTTL: maxTTL || undefined, + defaultTTL, + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch (err) { + createNotification({ + type: "error", + text: err instanceof Error ? err.message : "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration + + +
+ + Docs + +
+
+ +
+
+ ( + + + + )} + /> +
+
+
+
+ + +
+
+
+ ); +};