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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function Verify2faPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auth");
|
||||
const [code, setCode] = useState("");
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const { error } = useBackupCode
|
||||
? await authClient.twoFactor.verifyBackupCode({ code: code.trim() })
|
||||
: await authClient.twoFactor.verifyTotp({ code: code.trim() });
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError(error.message ?? t("twoFactorInvalidCode"));
|
||||
} else {
|
||||
router.push("/recipes");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight">{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{useBackupCode ? t("twoFactorBackupDescription") : t("twoFactorAppDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{useBackupCode ? t("twoFactorBackupCodeLabel") : t("twoFactorCodeLabel")}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
inputMode={useBackupCode ? "text" : "numeric"}
|
||||
placeholder={useBackupCode ? undefined : "000000"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button className="w-full" type="submit" disabled={loading || !code.trim()}>
|
||||
{loading ? t("twoFactorVerifying") : t("twoFactorVerify")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</form>
|
||||
<CardFooter className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseBackupCode(!useBackupCode); setCode(""); setError(null); }}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{useBackupCode ? t("twoFactorUseApp") : t("twoFactorUseBackupCode")}
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user