feat: add backup code authentication for 2FA login

This commit is contained in:
Mauricio Siu
2025-02-20 01:50:01 -06:00
parent a9e12c2b18
commit 5a1145996d
2 changed files with 156 additions and 60 deletions

View File

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

View File

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