Files
Arnaud acc93de708 feat: two-factor authentication (TOTP + backup codes)
Uses better-auth's built-in twoFactor plugin rather than hand-rolling
TOTP — adds the two_factors table and users.twoFactorEnabled, wires
the server/client plugins (allowPasswordless: true so OAuth-only
accounts aren't locked out of managing 2FA), and adds a custom rate
limit for the verify endpoints (5/min — far stricter than the default
100/10s, since a 6-digit code has a much smaller keyspace than a
password).

New Settings → Security section walks through enable (password
confirm -> QR + backup codes -> confirm a live code before it's
actually turned on, per the plugin's default flow) and disable. New
/verify-2fa page handles the post-password mid-login step, with a
backup-code fallback. Verified live end-to-end: enable, confirm,
forced re-auth on next sign-in, TOTP accepted, backup code accepted
(single-use), disable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:08:15 +02:00

252 lines
9.1 KiB
TypeScript

"use client";
import { useState } from "react";
import QRCode from "qrcode";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
type Step = "closed" | "password" | "setup" | "disable-password";
export function TwoFactorSettings({ initialEnabled, hasPassword }: { initialEnabled: boolean; hasPassword: boolean }) {
const t = useTranslations("settingsForm");
const [enabled, setEnabled] = useState(initialEnabled);
const [step, setStep] = useState<Step>("closed");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [confirmCode, setConfirmCode] = useState("");
const [copied, setCopied] = useState(false);
function closeAndReset() {
setStep("closed");
setPassword("");
setError(null);
setQrDataUrl(null);
setBackupCodes([]);
setConfirmCode("");
setCopied(false);
}
async function startEnable() {
setError(null);
setBusy(true);
try {
const { data, error } = await authClient.twoFactor.enable(
hasPassword ? { password } : {}
);
if (error || !data) {
setError(error?.message ?? t("twoFactorEnableFailed"));
return;
}
const dataUrl = await QRCode.toDataURL(data.totpURI, { margin: 1, width: 200 });
setQrDataUrl(dataUrl);
setBackupCodes(data.backupCodes);
setStep("setup");
} finally {
setBusy(false);
}
}
async function confirmEnable() {
setError(null);
setBusy(true);
try {
const { error } = await authClient.twoFactor.verifyTotp({ code: confirmCode.trim() });
if (error) {
setError(error.message ?? t("twoFactorInvalidCode"));
return;
}
setEnabled(true);
toast.success(t("twoFactorEnabled"));
closeAndReset();
} finally {
setBusy(false);
}
}
async function handleDisable() {
setError(null);
setBusy(true);
try {
const { error } = await authClient.twoFactor.disable(hasPassword ? { password } : {});
if (error) {
setError(error.message ?? t("twoFactorDisableFailed"));
return;
}
setEnabled(false);
toast.success(t("twoFactorDisabled"));
closeAndReset();
} finally {
setBusy(false);
}
}
async function copyBackupCodes() {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(t("twoFactorCopyFailed"));
}
}
return (
<section className="rounded-xl border p-6 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg">{t("twoFactorTitle")}</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-prose">{t("twoFactorDescription")}</p>
</div>
{enabled ? (
<Button
variant="outline"
size="sm"
onClick={() => setStep("disable-password")}
className="gap-1.5 shrink-0"
>
<ShieldOff className="h-4 w-4" />
{t("twoFactorDisableButton")}
</Button>
) : (
<Button
size="sm"
onClick={() => (hasPassword ? setStep("password") : void startEnable())}
className="gap-1.5 shrink-0"
>
<ShieldCheck className="h-4 w-4" />
{t("twoFactorEnableButton")}
</Button>
)}
</div>
{/* Password confirmation before enabling */}
<Dialog open={step === "password"} onOpenChange={(open) => !open && closeAndReset()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("twoFactorConfirmPasswordTitle")}</DialogTitle>
<DialogDescription>{t("twoFactorConfirmPasswordDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="two-factor-password">{t("currentPassword")}</Label>
<Input
id="two-factor-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
<Button onClick={() => void startEnable()} disabled={busy || !password}>
{busy ? t("saving") : t("twoFactorContinue")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* QR + backup codes + confirmation code */}
<Dialog open={step === "setup"} onOpenChange={(open) => !open && closeAndReset()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("twoFactorSetupTitle")}</DialogTitle>
<DialogDescription>{t("twoFactorSetupDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{qrDataUrl && (
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL generated client-side, not an optimizable remote image */}
<img src={qrDataUrl} alt={t("twoFactorQrAlt")} className="h-40 w-40" />
</div>
)}
<div className="space-y-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("twoFactorBackupCodesLabel")}</p>
<div className="rounded-lg border bg-muted/40 p-3 grid grid-cols-2 gap-1 font-mono text-xs">
{backupCodes.map((c) => <span key={c}>{c}</span>)}
</div>
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyBackupCodes()}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("twoFactorCodesCopied") : t("twoFactorCopyCodes")}
</Button>
<p className="text-xs text-muted-foreground">{t("twoFactorBackupCodesHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="two-factor-confirm-code">{t("twoFactorCodeLabel")}</Label>
<Input
id="two-factor-confirm-code"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value)}
inputMode="numeric"
placeholder="000000"
autoComplete="one-time-code"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
<Button onClick={() => void confirmEnable()} disabled={busy || !confirmCode.trim()}>
{busy ? t("saving") : t("twoFactorVerifyAndEnable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Password confirmation before disabling */}
<Dialog open={step === "disable-password"} onOpenChange={(open) => !open && closeAndReset()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("twoFactorDisableTitle")}</DialogTitle>
<DialogDescription>{t("twoFactorDisableDescription")}</DialogDescription>
</DialogHeader>
{hasPassword && (
<div className="space-y-2">
<Label htmlFor="two-factor-disable-password">{t("currentPassword")}</Label>
<Input
id="two-factor-disable-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoFocus
/>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
<Button
variant="destructive"
onClick={() => void handleDisable()}
disabled={busy || (hasPassword && !password)}
>
{busy ? t("saving") : t("twoFactorDisableButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}