From 7abe060fcf3dea922d9850acfed97385fe5a0f14 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Mon, 17 Feb 2025 00:07:36 -0600 Subject: [PATCH] feat: enhance two-factor authentication and auth client implementation --- .../server/update-server-config.test.ts | 1 + .../dashboard/projects/handle-project.tsx | 2 +- .../components/dashboard/search-command.tsx | 2 +- .../git/github/add-github-provider.tsx | 2 +- .../settings/profile/disable-2fa.tsx | 127 ++++-- .../dashboard/settings/profile/enable-2fa.tsx | 365 +++++++++++++----- .../settings/profile/profile-form.tsx | 7 +- .../settings/users/add-invitation.tsx | 166 ++++++++ .../dashboard/settings/users/add-user.tsx | 2 +- .../settings/users/show-invitations.tsx | 191 +++++++++ .../dashboard/settings/users/show-users.tsx | 2 +- apps/dokploy/components/layouts/side.tsx | 2 +- apps/dokploy/components/layouts/user-nav.tsx | 2 +- apps/dokploy/drizzle/0067_migrate-data.sql | 12 +- apps/dokploy/lib/{auth.ts => auth-client.ts} | 3 +- .../accept-invitation/[accept-invitation].tsx | 2 +- .../pages/dashboard/settings/users.tsx | 2 + apps/dokploy/pages/index.tsx | 307 +++++++++------ apps/dokploy/pages/invitation.tsx | 2 +- apps/dokploy/pages/register.tsx | 2 +- .../server/api/routers/organization.ts | 8 +- packages/server/package.json | 1 + packages/server/src/lib/auth.ts | 1 + packages/server/src/services/auth.ts | 159 +++++++- pnpm-lock.yaml | 3 + 25 files changed, 1103 insertions(+), 270 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/users/add-invitation.tsx create mode 100644 apps/dokploy/components/dashboard/settings/users/show-invitations.tsx rename apps/dokploy/lib/{auth.ts => auth-client.ts} (67%) diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 458266dc..49d71bc4 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -75,6 +75,7 @@ const baseAdmin: User = { image: "", token: "", updatedAt: new Date(), + twoFactorEnabled: false, }; beforeEach(() => { diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index fb2cbf67..492c03c9 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -21,7 +21,7 @@ import { import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { PlusIcon, SquarePen } from "lucide-react"; diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 8158a9ca..7ea53d72 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -18,7 +18,7 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { type Services, extractServices, diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 5f2cb934..819d8e70 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { format } from "date-fns"; import { useEffect, useState } from "react"; diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx index 2850332e..79306bf1 100644 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx @@ -1,52 +1,131 @@ import { AlertDialog, - AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, - AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import { z } from "zod"; + +const PasswordSchema = z.object({ + password: z.string().min(8, { + message: "Password is required", + }), +}); + +type PasswordForm = z.infer; export const Disable2FA = () => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = api.auth.disable2FA.useMutation(); + const [isLoading, setIsLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + password: "", + }, + }); + + const handleSubmit = async (formData: PasswordForm) => { + setIsLoading(true); + try { + const result = await authClient.twoFactor.disable({ + password: formData.password, + }); + + if (result.error) { + form.setError("password", { + message: result.error.message, + }); + toast.error(result.error.message); + return; + } + + toast.success("2FA disabled successfully"); + utils.auth.get.invalidate(); + } catch (error) { + form.setError("password", { + message: "Connection error. Please try again.", + }); + toast.error("Connection error. Please try again."); + } finally { + setIsLoading(false); + } + }; + return ( - + Are you absolutely sure? - This action cannot be undone. This will permanently delete the 2FA + This action cannot be undone. This will permanently disable + Two-Factor Authentication for your account. - - Cancel - { - await mutateAsync() - .then(() => { - utils.auth.get.invalidate(); - toast.success("2FA Disabled"); - }) - .catch(() => { - toast.error("Error disabling 2FA"); - }); - }} + +
+ - Confirm - - + ( + + Password + + + + + Enter your password to disable 2FA + + + + )} + /> +
+ + +
+ +
); diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index cf5910b8..b1da3ec1 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -17,144 +17,315 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AlertTriangle, Fingerprint } from "lucide-react"; -import { useEffect } from "react"; +import { Fingerprint, QrCode } from "lucide-react"; +import QRCode from "qrcode"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; -const Enable2FASchema = z.object({ +const PasswordSchema = z.object({ + password: z.string().min(8, { + message: "Password is required", + }), +}); + +const PinSchema = z.object({ pin: z.string().min(6, { message: "Pin is required", }), }); -type Enable2FA = z.infer; +type PasswordForm = z.infer; +type PinForm = z.infer; + +type TwoFactorEnableResponse = { + totpURI: string; + backupCodes: string[]; +}; + +type TwoFactorSetupData = { + qrCodeUrl: string; + secret: string; + totpURI: string; +}; export const Enable2FA = () => { const utils = api.useUtils(); + const { data: session } = authClient.useSession(); + const [data, setData] = useState(null); + const [backupCodes, setBackupCodes] = useState([]); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [step, setStep] = useState<"password" | "verify">("password"); + const [isPasswordLoading, setIsPasswordLoading] = useState(false); - const { data } = api.auth.generate2FASecret.useQuery(undefined, { - refetchOnWindowFocus: false, + const handlePasswordSubmit = async (formData: PasswordForm) => { + setIsPasswordLoading(true); + try { + const { data: enableData } = await authClient.twoFactor.enable({ + password: formData.password, + }); + + if (!enableData) { + throw new Error("No data received from server"); + } + + if (enableData.backupCodes) { + setBackupCodes(enableData.backupCodes); + } + + if (enableData.totpURI) { + const qrCodeUrl = await QRCode.toDataURL(enableData.totpURI); + + setData({ + qrCodeUrl, + secret: enableData.totpURI.split("secret=")[1]?.split("&")[0] || "", + totpURI: enableData.totpURI, + }); + + setStep("verify"); + toast.success("Scan the QR code with your authenticator app"); + } else { + throw new Error("No TOTP URI received from server"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error setting up 2FA", + ); + passwordForm.setError("password", { + message: "Error verifying password", + }); + } finally { + setIsPasswordLoading(false); + } + }; + + const handleVerifySubmit = async (formData: PinForm) => { + try { + const result = await authClient.twoFactor.verifyTotp({ + code: formData.pin, + }); + + if (result.error) { + if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") { + pinForm.setError("pin", { + message: "Invalid code. Please try again.", + }); + toast.error("Invalid verification code"); + return; + } + + throw result.error; + } + + if (!result.data) { + throw new Error("No response received from server"); + } + + toast.success("2FA configured successfully"); + utils.auth.get.invalidate(); + setIsDialogOpen(false); + } catch (error) { + if (error instanceof Error) { + const errorMessage = + error.message === "Failed to fetch" + ? "Connection error. Please check your internet connection." + : error.message; + + pinForm.setError("pin", { + message: errorMessage, + }); + toast.error(errorMessage); + } else { + pinForm.setError("pin", { + message: "Error verifying code", + }); + toast.error("Error verifying 2FA code"); + } + } + }; + + const passwordForm = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + password: "", + }, }); - const { mutateAsync, isLoading, error, isError } = - api.auth.verify2FASetup.useMutation(); - - const form = useForm({ + const pinForm = useForm({ + resolver: zodResolver(PinSchema), defaultValues: { pin: "", }, - resolver: zodResolver(Enable2FASchema), }); useEffect(() => { - form.reset({ - pin: "", - }); - }, [form, form.reset, form.formState.isSubmitSuccessful]); + if (!isDialogOpen) { + setStep("password"); + setData(null); + setBackupCodes([]); + passwordForm.reset(); + pinForm.reset(); + } + }, [isDialogOpen, passwordForm, pinForm]); - const onSubmit = async (formData: Enable2FA) => { - await mutateAsync({ - pin: formData.pin, - secret: data?.secret || "", - }) - .then(async () => { - toast.success("2FA Verified"); - utils.auth.get.invalidate(); - }) - .catch(() => { - toast.error("Error verifying the 2FA"); - }); - }; return ( - + - + 2FA Setup - Add a 2FA to your account + + {step === "password" + ? "Enter your password to begin 2FA setup" + : "Scan the QR code and verify with your authenticator app"} + - {isError && ( -
- - - {error?.message} - -
- )} -
- -
- - {data?.qrCodeUrl ? "Scan the QR code to add 2FA" : ""} - - qrCode -
- - {data?.secret ? `Secret: ${data?.secret}` : ""} - -
-
- ( - - Pin - - - - - - - - - - - - - - Please enter the 6 digits code provided by your - authenticator app. - - - - )} - /> - - - - - - + ( + + Password + + + + + Enter your password to enable 2FA + + + + )} + /> + + + + ) : ( +
+ +
+ {data?.qrCodeUrl ? ( + <> +
+ + + Scan this QR code with your authenticator app + + 2FA QR Code +
+ + Can't scan the QR code? + + + {data.secret} + +
+
+ + {backupCodes && backupCodes.length > 0 && ( +
+

Backup Codes

+
+ {backupCodes.map((code, index) => ( + + {code} + + ))} +
+

+ Save these backup codes in a secure place. You can use + them to access your account if you lose access to your + authenticator device. +

+
+ )} + + ) : ( +
+ +
+ )} +
+ + ( + + Verification Code + + + + + + + + + + + + + + Enter the 6-digit code from your authenticator app + + + + )} + /> + + + + + )}
); diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 4da97d18..944b2ff4 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -1,4 +1,5 @@ import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; import { Button } from "@/components/ui/button"; import { Card, @@ -17,6 +18,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { authClient } from "@/lib/auth-client"; import { generateSHA256Hash } from "@/lib/utils"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -54,6 +56,9 @@ const randomImages = [ ]; export const ProfileForm = () => { + const utils = api.useUtils(); + const { mutateAsync: disable2FA, isLoading: isDisabling } = + api.auth.disable2FA.useMutation(); const { data, refetch, isLoading } = api.auth.get.useQuery(); const { mutateAsync, @@ -130,7 +135,7 @@ export const ProfileForm = () => { {t("settings.profile.description")} - {!data?.is2FAEnabled ? : } + {!data?.user.twoFactorEnabled ? : } diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx new file mode 100644 index 00000000..d05409fb --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -0,0 +1,166 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const addInvitation = z.object({ + email: z + .string() + .min(1, "Email is required") + .email({ message: "Invalid email" }), + role: z.enum(["member", "admin"]), +}); + +type AddInvitation = z.infer; + +export const AddInvitation = () => { + const [open, setOpen] = useState(false); + const utils = api.useUtils(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { data: activeOrganization } = authClient.useActiveOrganization(); + + const form = useForm({ + defaultValues: { + email: "", + role: "member", + }, + resolver: zodResolver(addInvitation), + }); + useEffect(() => { + form.reset(); + }, [form, form.formState.isSubmitSuccessful, form.reset]); + + const onSubmit = async (data: AddInvitation) => { + setIsLoading(true); + const result = await authClient.organization.inviteMember({ + email: data.email.toLowerCase(), + role: data.role, + organizationId: activeOrganization?.id, + }); + + if (result.error) { + setError(result.error.message || ""); + } else { + toast.success("Invitation created"); + setError(null); + setOpen(false); + } + + utils.organization.allInvitations.invalidate(); + setIsLoading(false); + }; + return ( + + + + + + + Add Invitation + Invite a new user + + {error && {error}} + +
+ + { + return ( + + Email + + + + + This will be the email of the new user + + + + ); + }} + /> + + { + return ( + + Role + + + Select the role for the new user + + + + ); + }} + /> + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/add-user.tsx b/apps/dokploy/components/dashboard/settings/users/add-user.tsx index fa1c8bf9..78c8ebdb 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-user.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-user.tsx @@ -19,7 +19,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { PlusIcon } from "lucide-react"; diff --git a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx new file mode 100644 index 00000000..e6067e7b --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx @@ -0,0 +1,191 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; +import { format } from "date-fns"; +import { Mail, MoreHorizontal, Users } from "lucide-react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { AddInvitation } from "./add-invitation"; + +export const ShowInvitations = () => { + const { data, isLoading, refetch } = + api.organization.allInvitations.useQuery(); + const { mutateAsync, isLoading: isRemoving } = + api.admin.removeUser.useMutation(); + + return ( +
+ +
+ + + + Invitations + + + Create invitations to your organization. + + + + {isLoading ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + Invite users to your organization + + +
+ ) : ( +
+ + See all invitations + + + Email + Role + Status + + Expires At + + Actions + + + + {data?.map((invitation) => { + return ( + + + {invitation.email} + + + + {invitation.role} + + + + + {invitation.status} + + + + {format(new Date(invitation.expiresAt), "PPpp")} + + + + + + + + + + Actions + + + {/* { + copy( + `${origin}/invitation?token=${user.user.token}`, + ); + toast.success( + "Invitation Copied to clipboard", + ); + }} + > + Copy Invitation + */} + {invitation.status === "pending" && ( + { + const result = + await authClient.organization.cancelInvitation( + { + invitationId: invitation.id, + }, + ); + + if (result.error) { + toast.error(result.error.message); + } else { + toast.success("Invitation deleted"); + refetch(); + } + }} + > + Cancel Invitation + + )} + + + + + ); + })} + +
+ +
+ +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 7e3ed6f1..40fbea0d 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -153,7 +153,7 @@ export const ShowUsers = () => { )} - {user.user.isRegistered && ( + {user.role !== "owner" && ( diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index cc27dc38..bd10352f 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -495,7 +495,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { toast } from "sonner"; import { AddOrganization } from "../dashboard/organization/handle-organization"; import { DialogAction } from "../shared/dialog-action"; diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index d4de2019..49fc92e5 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -15,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { Languages } from "@/lib/languages"; import { api } from "@/utils/api"; import useLocale from "@/utils/hooks/use-locale"; diff --git a/apps/dokploy/drizzle/0067_migrate-data.sql b/apps/dokploy/drizzle/0067_migrate-data.sql index 4b860a32..279acbfb 100644 --- a/apps/dokploy/drizzle/0067_migrate-data.sql +++ b/apps/dokploy/drizzle/0067_migrate-data.sql @@ -26,7 +26,8 @@ WITH inserted_users AS ( "serversQuantity", "expirationDate", "createdAt", - "two_factor_enabled" + "two_factor_enabled", + "isRegistered" ) SELECT a."adminId", @@ -52,7 +53,8 @@ WITH inserted_users AS ( a."serversQuantity", NOW() + INTERVAL '1 year', NOW(), - COALESCE(auth."is2FAEnabled", false) + COALESCE(auth."is2FAEnabled", false), + true FROM admin a JOIN auth ON auth.id = a."authId" RETURNING * @@ -141,7 +143,8 @@ inserted_members AS ( "accesedProjects", "accesedServices", "expirationDate", - "two_factor_enabled" + "two_factor_enabled", + "isRegistered" ) SELECT u."userId", @@ -163,7 +166,8 @@ inserted_members AS ( COALESCE(u."accesedProjects", '{}'), COALESCE(u."accesedServices", '{}'), NOW() + INTERVAL '1 year', - COALESCE(auth."is2FAEnabled", false) + COALESCE(auth."is2FAEnabled", false), + COALESCE(u."isRegistered", false) FROM "user" u JOIN admin a ON u."adminId" = a."adminId" JOIN auth ON auth.id = u."authId" diff --git a/apps/dokploy/lib/auth.ts b/apps/dokploy/lib/auth-client.ts similarity index 67% rename from apps/dokploy/lib/auth.ts rename to apps/dokploy/lib/auth-client.ts index 12c3cc3e..9a184959 100644 --- a/apps/dokploy/lib/auth.ts +++ b/apps/dokploy/lib/auth-client.ts @@ -1,7 +1,8 @@ import { organizationClient } from "better-auth/client/plugins"; +import { twoFactorClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ // baseURL: "http://localhost:3000", // the base url of your auth server - plugins: [organizationClient()], + plugins: [organizationClient(), twoFactorClient()], }); diff --git a/apps/dokploy/pages/accept-invitation/[accept-invitation].tsx b/apps/dokploy/pages/accept-invitation/[accept-invitation].tsx index 6936a802..bc60d970 100644 --- a/apps/dokploy/pages/accept-invitation/[accept-invitation].tsx +++ b/apps/dokploy/pages/accept-invitation/[accept-invitation].tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { useRouter } from "next/router"; export const AcceptInvitation = () => { diff --git a/apps/dokploy/pages/dashboard/settings/users.tsx b/apps/dokploy/pages/dashboard/settings/users.tsx index 1c53c82b..7945bf86 100644 --- a/apps/dokploy/pages/dashboard/settings/users.tsx +++ b/apps/dokploy/pages/dashboard/settings/users.tsx @@ -1,3 +1,4 @@ +import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations"; import { ShowUsers } from "@/components/dashboard/settings/users/show-users"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; @@ -12,6 +13,7 @@ const Page = () => { return (
+
); }; diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index b85b1c7e..8013c631 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -3,17 +3,24 @@ import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { AlertBlock } from "@/components/shared/alert-block"; import { Logo } from "@/components/shared/logo"; import { Button, buttonVariants } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; +import { CardContent, CardDescription } from "@/components/ui/card"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server"; @@ -21,110 +28,118 @@ import { validateRequest } from "@dokploy/server/lib/auth"; import { zodResolver } from "@hookform/resolvers/zod"; import { Session, getSessionCookie } from "better-auth"; import { betterFetch } from "better-auth/react"; +import base32 from "hi-base32"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; +import { TOTP } from "otpauth"; import { type ReactElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; -const loginSchema = z.object({ - email: z - .string() - .min(1, { - message: "Email is required", - }) - .email({ - message: "Email must be a valid email", - }), - - password: z - .string() - .min(1, { - message: "Password is required", - }) - .min(8, { - message: "Password must be at least 8 characters", - }), +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), }); -type Login = z.infer; +const TwoFactorSchema = z.object({ + code: z.string().min(6), +}); -type AuthResponse = { - is2FAEnabled: boolean; - authId: string; -}; +type LoginForm = z.infer; +type TwoFactorForm = z.infer; interface Props { IS_CLOUD: boolean; } export default function Home({ IS_CLOUD }: Props) { - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - const [error, setError] = useState(null); - const [temp, setTemp] = useState({ - is2FAEnabled: false, - authId: "", - }); const router = useRouter(); - const form = useForm({ + const [isLoginLoading, setIsLoginLoading] = useState(false); + const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false); + const [isTwoFactor, setIsTwoFactor] = useState(false); + const [error, setError] = useState(null); + const [twoFactorCode, setTwoFactorCode] = useState(""); + + const loginForm = useForm({ + resolver: zodResolver(LoginSchema), defaultValues: { email: "siumauricio@hotmail.com", password: "Password123", }, - resolver: zodResolver(loginSchema), }); - useEffect(() => { - form.reset(); - }, [form, form.reset, form.formState.isSubmitSuccessful]); - - const onSubmit = async (values: Login) => { - setIsLoading(true); - const { data, error } = await authClient.signIn.email({ - email: values.email, - password: values.password, - }); - - if (!error) { - // if (data) { - // setTemp(data); - // } else { - toast.success("Successfully signed in", { - duration: 2000, + const onSubmit = async (values: LoginForm) => { + setIsLoginLoading(true); + try { + const { data, error } = await authClient.signIn.email({ + email: values.email, + password: values.password, }); + + if (error) { + toast.error(error.message); + setError(error.message || "An error occurred while logging in"); + return; + } + + if (data?.twoFactorRedirect as boolean) { + setTwoFactorCode(""); + setIsTwoFactor(true); + toast.info("Please enter your 2FA code"); + return; + } + + toast.success("Logged in successfully"); router.push("/dashboard/projects"); - // } - } else { - setIsError(true); - setError(error.message ?? "Error to signup"); - toast.error("Error to sign up", { - description: error.message, - }); + } catch (error) { + toast.error("An error occurred while logging in"); + } finally { + setIsLoginLoading(false); + } + }; + + const onTwoFactorSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (twoFactorCode.length !== 6) { + toast.error("Please enter a valid 6-digit code"); + return; } - setIsLoading(false); - // await mutateAsync({ - // email: values.email.toLowerCase(), - // password: values.password, - // }) - // .then((data) => { - // if (data.is2FAEnabled) { - // setTemp(data); - // } else { - // toast.success("Successfully signed in", { - // duration: 2000, - // }); - // router.push("/dashboard/projects"); - // } - // }) - // .catch(() => { - // toast.error("Signin failed", { - // duration: 2000, - // }); - // }); + setIsTwoFactorLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: twoFactorCode.replace(/\s/g, ""), + }); + + if (error) { + toast.error(error.message); + setError(error.message || "An error occurred while verifying 2FA code"); + return; + } + + toast.success("Logged in successfully"); + router.push("/dashboard/projects"); + } catch (error) { + toast.error("An error occurred while verifying 2FA code"); + } finally { + setIsTwoFactorLoading(false); + } }; + + const convertBase32ToHex = (base32Secret: string) => { + try { + // Usar asBytes() para obtener los bytes directamente + const bytes = base32.decode.asBytes(base32Secret.toUpperCase()); + // Convertir bytes a hex + return Buffer.from(bytes).toString("hex"); + } catch (error) { + console.error("Error converting base32 to hex:", error); + return base32Secret; // Fallback al valor original si hay error + } + }; + return ( <>
@@ -138,55 +153,109 @@ export default function Home({ IS_CLOUD }: Props) { Enter your email and password to sign in

- {isError && ( + {error && ( {error} )} - {!temp.is2FAEnabled ? ( -
- -
- ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - - -
+ {!isTwoFactor ? ( + + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ) : ( - +
+
+ + + + + + + + + + + + + Enter the 6-digit code from your authenticator app + +
+ +
+ + +
+
)}
diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index e8bfc3fc..0dd8dbe4 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -26,8 +26,8 @@ import { useRouter } from "next/router"; import { type ReactElement, useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod"; import superjson from "superjson"; +import { z } from "zod"; const registerSchema = z .object({ diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx index 73dce5e7..e8fd15cf 100644 --- a/apps/dokploy/pages/register.tsx +++ b/apps/dokploy/pages/register.tsx @@ -17,7 +17,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server"; import { zodResolver } from "@hookform/resolvers/zod"; diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index db0c6438..444a80ba 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -1,5 +1,5 @@ import { db } from "@/server/db"; -import { member, organization } from "@/server/db/schema"; +import { invitation, member, organization } from "@/server/db/schema"; import { TRPCError } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -83,4 +83,10 @@ export const organizationRouter = createTRPCRouter({ .where(eq(organization.id, input.organizationId)); return result; }), + allInvitations: adminProcedure.query(async ({ ctx }) => { + return await db.query.invitation.findMany({ + where: eq(invitation.organizationId, ctx.session.activeOrganizationId), + orderBy: [desc(invitation.status)], + }); + }), }); diff --git a/packages/server/package.json b/packages/server/package.json index d8f72a86..dbc24375 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@better-auth/utils":"0.2.3", "@oslojs/encoding":"1.1.0", "@oslojs/crypto":"1.0.1", "drizzle-dbml-generator":"0.10.0", diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index cc144345..a4b9a4f1 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -16,6 +16,7 @@ export const auth = betterAuth({ provider: "pg", schema: schema, }), + appName: "Dokploy", socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID as string, diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts index 8f3564be..0a9f4e94 100644 --- a/packages/server/src/services/auth.ts +++ b/packages/server/src/services/auth.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { createOTP } from "@better-auth/utils/otp"; import { db } from "@dokploy/server/db"; import { users_temp } from "@dokploy/server/db/schema"; import { getPublicIpWithFallback } from "@dokploy/server/wss/utils"; @@ -29,26 +30,41 @@ export const findAuthById = async (authId: string) => { return result; }; -export const generate2FASecret = async (userId: string) => { - const user = await findUserById(userId); +const generateBase32Secret = () => { + // Generamos 32 bytes (256 bits) para asegurar que tengamos suficiente longitud + const buffer = randomBytes(32); + // Convertimos directamente a hex para Better Auth + const hex = buffer.toString("hex"); + // También necesitamos la versión base32 para el QR code + const base32 = encode.encode(buffer).replace(/=/g, "").substring(0, 32); + return { + hex, + base32, + }; +}; - const base32_secret = generateBase32Secret(); +export const generate2FASecret = () => { + const secret = "46JMUCG4NJ3CIU6LQAIVFWUW"; const totp = new TOTP({ issuer: "Dokploy", - label: `${user?.email}`, + label: "siumauricio@hotmail.com", algorithm: "SHA1", digits: 6, - secret: base32_secret, + secret: secret, }); - const otpauth_url = totp.toString(); + // Convertir los bytes del secreto a hex + const secretBytes = totp.secret.bytes; + const hexSecret = Buffer.from(secretBytes).toString("hex"); - const qrUrl = await QRCode.toDataURL(otpauth_url); + console.log("Secret bytes:", secretBytes); + console.log("Hex secret:", hexSecret); return { - qrCodeUrl: qrUrl, - secret: base32_secret, + secret, + hexSecret, + totp, }; }; @@ -59,6 +75,7 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => { algorithm: "SHA1", digits: 6, secret: secret, + period: 30, }); const delta = totp.validate({ token: pin }); @@ -72,8 +89,124 @@ export const verify2FA = async (auth: User, secret: string, pin: string) => { return auth; }; -const generateBase32Secret = () => { - const buffer = randomBytes(15); - const base32 = encode.encode(buffer).replace(/=/g, "").substring(0, 24); - return base32; +const convertBase32ToHex = (base32Secret: string) => { + try { + // Asegurarnos de que la longitud sea múltiplo de 8 agregando padding + let paddedSecret = base32Secret; + while (paddedSecret.length % 8 !== 0) { + paddedSecret += "="; + } + + const bytes = encode.decode.asBytes(paddedSecret.toUpperCase()); + let hex = Buffer.from(bytes).toString("hex"); + + // Asegurarnos de que el hex tenga al menos 32 caracteres (16 bytes) + while (hex.length < 32) { + hex += "0"; + } + + return hex; + } catch (error) { + console.error("Error converting base32 to hex:", error); + return base32Secret; + } }; + +// Para probar +// const testSecret = "46JMUCG4NJ3CIU6LQAIVFWUW"; +// console.log("Original:", testSecret); +// console.log("Converted:", convertBase32ToHex(testSecret)); +// console.log( +// "Length in bytes:", +// Buffer.from(convertBase32ToHex(testSecret), "hex").length, +// ); +// console.log(generate2FASecret().secret.secret); + +// // Para probar +// const testResult = generate2FASecret(); +// console.log("\nResultados:"); +// console.log("Original base32:", testResult.secret); +// console.log("Hex convertido:", testResult.hexSecret); +// console.log( +// "Longitud en bytes:", +// Buffer.from(testResult.hexSecret, "hex").length, +// ); +export const symmetricDecrypt = async ({ key, data }) => { + const keyAsBytes = await createHash("SHA-256").digest(key); + const dataAsBytes = hexToBytes(data); + const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes)); + return new TextDecoder().decode(chacha.decrypt(dataAsBytes)); +}; +export const migrateExistingSecret = async ( + existingBase32Secret: string, + encryptionKey: string, +) => { + try { + // 1. Primero asegurarnos que el secreto base32 tenga el padding correcto + let paddedSecret = existingBase32Secret; + while (paddedSecret.length % 8 !== 0) { + paddedSecret += "="; + } + + // 2. Decodificar el base32 a bytes usando hi-base32 + const bytes = encode.decode.asBytes(paddedSecret.toUpperCase()); + + // 3. Convertir los bytes a hex + const hexSecret = Buffer.from(bytes).toString("hex"); + + // 4. Encriptar el secreto hex usando Better Auth + const encryptedSecret = await symmetricEncrypt({ + key: encryptionKey, + data: hexSecret, + }); + + // 5. Crear TOTP con el secreto original para validación + const originalTotp = new TOTP({ + issuer: "Dokploy", + label: "migration-test", + algorithm: "SHA1", + digits: 6, + secret: existingBase32Secret, + }); + + // 6. Generar un código de prueba con el secreto original + const testCode = originalTotp.generate(); + + // 7. Validar que el código funcione con el secreto original + const isValid = originalTotp.validate({ token: testCode }) !== null; + + return { + originalSecret: existingBase32Secret, + hexSecret, + encryptedSecret, // Este es el valor que debes guardar en la base de datos + isValid, + testCode, + secretLength: hexSecret.length, + }; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error("Error durante la migración:", errorMessage); + throw new Error(`Error al migrar el secreto: ${errorMessage}`); + } +}; + +// // Ejemplo de uso con el secreto de prueba +// const testMigration = await migrateExistingSecret( +// "46JMUCG4NJ3CIU6LQAIVFWUW", +// process.env.BETTER_AUTH_SECRET || "your-encryption-key", +// ); +// console.log("\nPrueba de migración:"); +// console.log("Secreto original (base32):", testMigration.originalSecret); +// console.log("Secreto convertido (hex):", testMigration.hexSecret); +// console.log("Secreto encriptado:", testMigration.encryptedSecret); +// console.log("Longitud del secreto hex:", testMigration.secretLength); +// console.log("¿Conversión válida?:", testMigration.isValid); +// console.log("Código de prueba:", testMigration.testCode); +const secret = "46JMUCG4NJ3CIU6LQAIVFWUW"; +const isValid = createOTP(secret, { + digits: 6, + period: 30, +}).verify("123456"); + +console.log(isValid.then((isValid) => console.log(isValid))); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09f7885b..ee68b399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,6 +549,9 @@ importers: packages/server: dependencies: + '@better-auth/utils': + specifier: 0.2.3 + version: 0.2.3 '@faker-js/faker': specifier: ^8.4.1 version: 8.4.1