mirror of
https://github.com/LukeHagar/dokploy.git
synced 2025-12-10 04:19:48 +00:00
feat: add backup code authentication for 2FA login
This commit is contained in:
@@ -35,6 +35,7 @@ type PasswordForm = z.infer<typeof PasswordSchema>;
|
|||||||
|
|
||||||
export const Disable2FA = () => {
|
export const Disable2FA = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const form = useForm<PasswordForm>({
|
const form = useForm<PasswordForm>({
|
||||||
@@ -72,7 +73,7 @@ export const Disable2FA = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive">Disable 2FA</Button>
|
<Button variant="destructive">Disable 2FA</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
@@ -116,6 +117,7 @@ export const Disable2FA = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -20,16 +20,23 @@ import {
|
|||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server";
|
import { IS_CLOUD, auth, isAdminPresent } from "@dokploy/server";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Session, getSessionCookie } from "better-auth";
|
|
||||||
import { betterFetch } from "better-auth/react";
|
|
||||||
import base32 from "hi-base32";
|
import base32 from "hi-base32";
|
||||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -48,8 +55,14 @@ const TwoFactorSchema = z.object({
|
|||||||
code: z.string().min(6),
|
code: z.string().min(6),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const BackupCodeSchema = z.object({
|
||||||
|
code: z.string().min(8, {
|
||||||
|
message: "Backup code must be at least 8 characters",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
type LoginForm = z.infer<typeof LoginSchema>;
|
type LoginForm = z.infer<typeof LoginSchema>;
|
||||||
type TwoFactorForm = z.infer<typeof TwoFactorSchema>;
|
type BackupCodeForm = z.infer<typeof BackupCodeSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
IS_CLOUD: boolean;
|
IS_CLOUD: boolean;
|
||||||
@@ -58,9 +71,12 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||||
|
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||||
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||||
|
const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false);
|
||||||
|
const [backupCode, setBackupCode] = useState("");
|
||||||
|
|
||||||
const loginForm = useForm<LoginForm>({
|
const loginForm = useForm<LoginForm>({
|
||||||
resolver: zodResolver(LoginSchema),
|
resolver: zodResolver(LoginSchema),
|
||||||
@@ -128,15 +144,33 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const convertBase32ToHex = (base32Secret: string) => {
|
const onBackupCodeSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (backupCode.length < 8) {
|
||||||
|
toast.error("Please enter a valid backup code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBackupCodeLoading(true);
|
||||||
try {
|
try {
|
||||||
// Usar asBytes() para obtener los bytes directamente
|
const { data, error } = await authClient.twoFactor.verifyBackupCode({
|
||||||
const bytes = base32.decode.asBytes(base32Secret.toUpperCase());
|
code: backupCode.trim(),
|
||||||
// Convertir bytes a hex
|
});
|
||||||
return Buffer.from(bytes).toString("hex");
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
setError(
|
||||||
|
error.message || "An error occurred while verifying backup code",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Logged in successfully");
|
||||||
|
router.push("/dashboard/projects");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error converting base32 to hex:", error);
|
toast.error("An error occurred while verifying backup code");
|
||||||
return base32Secret; // Fallback al valor original si hay error
|
} finally {
|
||||||
|
setIsBackupCodeLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,56 +240,116 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<>
|
||||||
onSubmit={onTwoFactorSubmit}
|
<form
|
||||||
className="space-y-4"
|
onSubmit={onTwoFactorSubmit}
|
||||||
id="two-factor-form"
|
className="space-y-4"
|
||||||
autoComplete="off"
|
id="two-factor-form"
|
||||||
>
|
autoComplete="off"
|
||||||
<div className="flex flex-col gap-2">
|
>
|
||||||
<Label>2FA Code</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<InputOTP
|
<Label>2FA Code</Label>
|
||||||
value={twoFactorCode}
|
<InputOTP
|
||||||
onChange={setTwoFactorCode}
|
value={twoFactorCode}
|
||||||
maxLength={6}
|
onChange={setTwoFactorCode}
|
||||||
pattern={REGEXP_ONLY_DIGITS}
|
maxLength={6}
|
||||||
autoComplete="off"
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
>
|
autoComplete="off"
|
||||||
<InputOTPGroup>
|
>
|
||||||
<InputOTPSlot index={0} className="border-border" />
|
<InputOTPGroup>
|
||||||
<InputOTPSlot index={1} className="border-border" />
|
<InputOTPSlot index={0} className="border-border" />
|
||||||
<InputOTPSlot index={2} className="border-border" />
|
<InputOTPSlot index={1} className="border-border" />
|
||||||
<InputOTPSlot index={3} className="border-border" />
|
<InputOTPSlot index={2} className="border-border" />
|
||||||
<InputOTPSlot index={4} className="border-border" />
|
<InputOTPSlot index={3} className="border-border" />
|
||||||
<InputOTPSlot index={5} className="border-border" />
|
<InputOTPSlot index={4} className="border-border" />
|
||||||
</InputOTPGroup>
|
<InputOTPSlot index={5} className="border-border" />
|
||||||
</InputOTP>
|
</InputOTPGroup>
|
||||||
<CardDescription>
|
</InputOTP>
|
||||||
Enter the 6-digit code from your authenticator app
|
<CardDescription>
|
||||||
</CardDescription>
|
Enter the 6-digit code from your authenticator app
|
||||||
</div>
|
</CardDescription>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBackupCodeModalOpen(true)}
|
||||||
|
className="text-sm text-muted-foreground hover:underline self-start mt-2"
|
||||||
|
>
|
||||||
|
Lost access to your authenticator app?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsTwoFactor(false);
|
setIsTwoFactor(false);
|
||||||
setTwoFactorCode("");
|
setTwoFactorCode("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={isTwoFactorLoading}
|
isLoading={isTwoFactorLoading}
|
||||||
>
|
>
|
||||||
Verify
|
Verify
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isBackupCodeModalOpen}
|
||||||
|
onOpenChange={setIsBackupCodeModalOpen}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enter Backup Code</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter one of your backup codes to access your account
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={onBackupCodeSubmit} className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Backup Code</Label>
|
||||||
|
<Input
|
||||||
|
value={backupCode}
|
||||||
|
onChange={(e) => setBackupCode(e.target.value)}
|
||||||
|
placeholder="Enter your backup code"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<CardDescription>
|
||||||
|
Enter one of the backup codes you received when setting up
|
||||||
|
2FA
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsBackupCodeModalOpen(false);
|
||||||
|
setBackupCode("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isBackupCodeLoading}
|
||||||
|
>
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row justify-between flex-wrap">
|
<div className="flex flex-row justify-between flex-wrap">
|
||||||
|
|||||||
Reference in New Issue
Block a user