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:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.23.0 — 2026-07-13 23:06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.
|
||||||
|
|
||||||
## 0.22.0 — 2026-07-13 22:49
|
## 0.22.0 — 2026-07-13 22:49
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
|
import { db, accounts, users, eq, and } from "@epicure/db";
|
||||||
import { SecurityForm } from "@/components/settings/security-form";
|
import { SecurityForm } from "@/components/settings/security-form";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -9,5 +10,16 @@ export default async function SecurityPage() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
return <SecurityForm currentEmail={session.user.email} />;
|
const [dbUser, credentialAccount] = await Promise.all([
|
||||||
|
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { twoFactorEnabled: true } }),
|
||||||
|
db.query.accounts.findFirst({ where: and(eq(accounts.userId, session.user.id), eq(accounts.providerId, "credential")) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecurityForm
|
||||||
|
currentEmail={session.user.email}
|
||||||
|
twoFactorEnabled={dbUser?.twoFactorEnabled ?? false}
|
||||||
|
hasPassword={!!credentialAccount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,17 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
import { TwoFactorSettings } from "@/components/settings/two-factor-settings";
|
||||||
|
|
||||||
export function SecurityForm({ currentEmail }: { currentEmail: string }) {
|
export function SecurityForm({
|
||||||
|
currentEmail,
|
||||||
|
twoFactorEnabled,
|
||||||
|
hasPassword,
|
||||||
|
}: {
|
||||||
|
currentEmail: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
hasPassword: boolean;
|
||||||
|
}) {
|
||||||
const t = useTranslations("settingsForm");
|
const t = useTranslations("settingsForm");
|
||||||
|
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
@@ -139,6 +148,8 @@ export function SecurityForm({ currentEmail }: { currentEmail: string }) {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TwoFactorSettings initialEnabled={twoFactorEnabled} hasPassword={hasPassword} />
|
||||||
|
|
||||||
<section className="rounded-xl border p-6 space-y-4">
|
<section className="rounded-xl border p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-lg">{t("downloadData")}</h2>
|
<h2 className="font-semibold text-lg">{t("downloadData")}</h2>
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
import { genericOAuthClient, twoFactorClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
plugins: [genericOAuthClient()],
|
plugins: [
|
||||||
|
genericOAuthClient(),
|
||||||
|
twoFactorClient({ twoFactorPage: "/verify-2fa" }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth, twoFactor } from "better-auth/plugins";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
|
import { db, users, sessions, accounts, verifications, twoFactors, eq, count } from "@epicure/db";
|
||||||
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||||
import { isSignupsDisabled } from "@/lib/site-settings";
|
import { isSignupsDisabled } from "@/lib/site-settings";
|
||||||
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
||||||
@@ -17,11 +17,19 @@ export const auth = betterAuth({
|
|||||||
// rules) — everything else on /api/auth falls back to 100/10s.
|
// rules) — everything else on /api/auth falls back to 100/10s.
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
// A 6-digit TOTP/backup code has far fewer combinations than a password —
|
||||||
|
// the generic 100-req/10s default is too loose to meaningfully slow down
|
||||||
|
// guessing it.
|
||||||
|
customRules: {
|
||||||
|
"/two-factor/verify-totp": { window: 60, max: 5 },
|
||||||
|
"/two-factor/verify-otp": { window: 60, max: 5 },
|
||||||
|
"/two-factor/verify-backup-code": { window: 60, max: 5 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: { user: users, session: sessions, account: accounts, verification: verifications },
|
schema: { user: users, session: sessions, account: accounts, verification: verifications, twoFactor: twoFactors },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
@@ -68,6 +76,10 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// allowPasswordless: OAuth-only accounts (no credential/password account)
|
||||||
|
// would otherwise never be able to enable or manage 2FA, since the
|
||||||
|
// endpoint requires a password to confirm identity by default.
|
||||||
|
twoFactor({ issuer: "Epicure", allowPasswordless: true }),
|
||||||
...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [
|
...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.22.0";
|
export const APP_VERSION = "0.23.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.23.0",
|
||||||
|
date: "2026-07-13 23:06",
|
||||||
|
added: [
|
||||||
|
"**Two-factor authentication**: turn it on in Settings → Security to require a code from an authenticator app when signing in, with backup codes in case you lose access to it.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.22.0",
|
version: "0.22.0",
|
||||||
date: "2026-07-13 22:49",
|
date: "2026-07-13 22:49",
|
||||||
|
|||||||
@@ -592,7 +592,17 @@
|
|||||||
"resendingSending": "Sending…",
|
"resendingSending": "Sending…",
|
||||||
"resendFailed": "Failed to resend",
|
"resendFailed": "Failed to resend",
|
||||||
"resendSuccess": "Verification email sent — check your inbox",
|
"resendSuccess": "Verification email sent — check your inbox",
|
||||||
"or": "or"
|
"or": "or",
|
||||||
|
"twoFactorTitle": "Two-factor authentication",
|
||||||
|
"twoFactorAppDescription": "Enter the 6-digit code from your authenticator app.",
|
||||||
|
"twoFactorBackupDescription": "Enter one of your backup codes.",
|
||||||
|
"twoFactorCodeLabel": "Code",
|
||||||
|
"twoFactorBackupCodeLabel": "Backup code",
|
||||||
|
"twoFactorVerify": "Verify",
|
||||||
|
"twoFactorVerifying": "Verifying…",
|
||||||
|
"twoFactorInvalidCode": "Invalid code",
|
||||||
|
"twoFactorUseBackupCode": "Use a backup code instead",
|
||||||
|
"twoFactorUseApp": "Use your authenticator app instead"
|
||||||
},
|
},
|
||||||
"batchCooking": {
|
"batchCooking": {
|
||||||
"title": "Batch cooking",
|
"title": "Batch cooking",
|
||||||
@@ -1174,6 +1184,31 @@
|
|||||||
"downloadData": "Download my data",
|
"downloadData": "Download my data",
|
||||||
"downloadDataDescription": "Get a JSON export of all the personal data Epicure holds about you — your profile, recipes, activity, and settings.",
|
"downloadDataDescription": "Get a JSON export of all the personal data Epicure holds about you — your profile, recipes, activity, and settings.",
|
||||||
"downloadDataButton": "Download my data",
|
"downloadDataButton": "Download my data",
|
||||||
|
"twoFactorTitle": "Two-factor authentication",
|
||||||
|
"twoFactorDescription": "Require a code from an authenticator app in addition to your password when signing in.",
|
||||||
|
"twoFactorEnableButton": "Enable",
|
||||||
|
"twoFactorDisableButton": "Disable",
|
||||||
|
"twoFactorEnabled": "Two-factor authentication enabled",
|
||||||
|
"twoFactorDisabled": "Two-factor authentication disabled",
|
||||||
|
"twoFactorEnableFailed": "Failed to start setup",
|
||||||
|
"twoFactorDisableFailed": "Failed to disable",
|
||||||
|
"twoFactorConfirmPasswordTitle": "Confirm your password",
|
||||||
|
"twoFactorConfirmPasswordDescription": "Enter your password to set up two-factor authentication.",
|
||||||
|
"twoFactorContinue": "Continue",
|
||||||
|
"twoFactorCancel": "Cancel",
|
||||||
|
"twoFactorSetupTitle": "Set up two-factor authentication",
|
||||||
|
"twoFactorSetupDescription": "Scan this code with your authenticator app (like Google Authenticator or 1Password), then enter the 6-digit code it shows.",
|
||||||
|
"twoFactorQrAlt": "QR code for two-factor authentication setup",
|
||||||
|
"twoFactorBackupCodesLabel": "Backup codes",
|
||||||
|
"twoFactorBackupCodesHint": "Save these somewhere safe — each one can be used once to sign in if you lose access to your authenticator app.",
|
||||||
|
"twoFactorCopyCodes": "Copy codes",
|
||||||
|
"twoFactorCodesCopied": "Copied",
|
||||||
|
"twoFactorCopyFailed": "Failed to copy",
|
||||||
|
"twoFactorCodeLabel": "6-digit code",
|
||||||
|
"twoFactorVerifyAndEnable": "Verify & enable",
|
||||||
|
"twoFactorInvalidCode": "Invalid code",
|
||||||
|
"twoFactorDisableTitle": "Disable two-factor authentication",
|
||||||
|
"twoFactorDisableDescription": "Your account will only need your password to sign in.",
|
||||||
"webhookCreateFailed": "Failed to create webhook",
|
"webhookCreateFailed": "Failed to create webhook",
|
||||||
"webhookDeleteFailed": "Failed to delete webhook",
|
"webhookDeleteFailed": "Failed to delete webhook",
|
||||||
"webhookUpdateFailed": "Failed to update webhook",
|
"webhookUpdateFailed": "Failed to update webhook",
|
||||||
|
|||||||
@@ -583,7 +583,17 @@
|
|||||||
"checkInboxVerification": "Vérifiez votre boîte de réception pour l'e-mail de vérification.",
|
"checkInboxVerification": "Vérifiez votre boîte de réception pour l'e-mail de vérification.",
|
||||||
"resendVerification": "Renvoyer l'e-mail de vérification",
|
"resendVerification": "Renvoyer l'e-mail de vérification",
|
||||||
"resendingSending": "Envoi en cours…",
|
"resendingSending": "Envoi en cours…",
|
||||||
"or": "ou"
|
"or": "ou",
|
||||||
|
"twoFactorTitle": "Authentification à deux facteurs",
|
||||||
|
"twoFactorAppDescription": "Saisissez le code à 6 chiffres de votre application d'authentification.",
|
||||||
|
"twoFactorBackupDescription": "Saisissez un de vos codes de secours.",
|
||||||
|
"twoFactorCodeLabel": "Code",
|
||||||
|
"twoFactorBackupCodeLabel": "Code de secours",
|
||||||
|
"twoFactorVerify": "Vérifier",
|
||||||
|
"twoFactorVerifying": "Vérification…",
|
||||||
|
"twoFactorInvalidCode": "Code invalide",
|
||||||
|
"twoFactorUseBackupCode": "Utiliser un code de secours",
|
||||||
|
"twoFactorUseApp": "Utiliser votre application d'authentification"
|
||||||
},
|
},
|
||||||
"batchCooking": {
|
"batchCooking": {
|
||||||
"title": "Batch cooking",
|
"title": "Batch cooking",
|
||||||
@@ -1162,6 +1172,31 @@
|
|||||||
"downloadData": "Télécharger mes données",
|
"downloadData": "Télécharger mes données",
|
||||||
"downloadDataDescription": "Obtenez un export JSON de toutes les données personnelles qu'Epicure détient sur vous — votre profil, vos recettes, votre activité et vos paramètres.",
|
"downloadDataDescription": "Obtenez un export JSON de toutes les données personnelles qu'Epicure détient sur vous — votre profil, vos recettes, votre activité et vos paramètres.",
|
||||||
"downloadDataButton": "Télécharger mes données",
|
"downloadDataButton": "Télécharger mes données",
|
||||||
|
"twoFactorTitle": "Authentification à deux facteurs",
|
||||||
|
"twoFactorDescription": "Exiger un code d'une application d'authentification en plus de votre mot de passe pour vous connecter.",
|
||||||
|
"twoFactorEnableButton": "Activer",
|
||||||
|
"twoFactorDisableButton": "Désactiver",
|
||||||
|
"twoFactorEnabled": "Authentification à deux facteurs activée",
|
||||||
|
"twoFactorDisabled": "Authentification à deux facteurs désactivée",
|
||||||
|
"twoFactorEnableFailed": "Échec du démarrage de la configuration",
|
||||||
|
"twoFactorDisableFailed": "Échec de la désactivation",
|
||||||
|
"twoFactorConfirmPasswordTitle": "Confirmez votre mot de passe",
|
||||||
|
"twoFactorConfirmPasswordDescription": "Saisissez votre mot de passe pour configurer l'authentification à deux facteurs.",
|
||||||
|
"twoFactorContinue": "Continuer",
|
||||||
|
"twoFactorCancel": "Annuler",
|
||||||
|
"twoFactorSetupTitle": "Configurer l'authentification à deux facteurs",
|
||||||
|
"twoFactorSetupDescription": "Scannez ce code avec votre application d'authentification (comme Google Authenticator ou 1Password), puis saisissez le code à 6 chiffres qu'elle affiche.",
|
||||||
|
"twoFactorQrAlt": "Code QR pour la configuration de l'authentification à deux facteurs",
|
||||||
|
"twoFactorBackupCodesLabel": "Codes de secours",
|
||||||
|
"twoFactorBackupCodesHint": "Conservez-les en lieu sûr — chacun ne peut être utilisé qu'une fois pour vous connecter si vous perdez l'accès à votre application d'authentification.",
|
||||||
|
"twoFactorCopyCodes": "Copier les codes",
|
||||||
|
"twoFactorCodesCopied": "Copié",
|
||||||
|
"twoFactorCopyFailed": "Échec de la copie",
|
||||||
|
"twoFactorCodeLabel": "Code à 6 chiffres",
|
||||||
|
"twoFactorVerifyAndEnable": "Vérifier et activer",
|
||||||
|
"twoFactorInvalidCode": "Code invalide",
|
||||||
|
"twoFactorDisableTitle": "Désactiver l'authentification à deux facteurs",
|
||||||
|
"twoFactorDisableDescription": "Votre compte n'aura besoin que de votre mot de passe pour vous connecter.",
|
||||||
"webhookCreateFailed": "Échec de la création du webhook",
|
"webhookCreateFailed": "Échec de la création du webhook",
|
||||||
"webhookDeleteFailed": "Échec de la suppression du webhook",
|
"webhookDeleteFailed": "Échec de la suppression du webhook",
|
||||||
"webhookUpdateFailed": "Échec de la mise à jour du webhook",
|
"webhookUpdateFailed": "Échec de la mise à jour du webhook",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.22.0",
|
"version": "0.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSessionCookie } from "better-auth/cookies";
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
import { applyRateLimit } from "@/lib/rate-limit";
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
|
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
|
||||||
const ADMIN_PATHS = ["/admin"];
|
const ADMIN_PATHS = ["/admin"];
|
||||||
|
|
||||||
// A public-editable shopping list's own items endpoint — no session cookie to
|
// A public-editable shopping list's own items endpoint — no session cookie to
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.22.0",
|
"version": "0.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE "two_factors" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"secret" text NOT NULL,
|
||||||
|
"backup_codes" text NOT NULL,
|
||||||
|
"verified" boolean DEFAULT true NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "two_factor_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factors" ADD CONSTRAINT "two_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,13 @@
|
|||||||
"when": 1783939302554,
|
"when": 1783939302554,
|
||||||
"tag": "0036_mixed_wither",
|
"tag": "0036_mixed_wither",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783976055649,
|
||||||
|
"tag": "0037_big_nehzno",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ export const users = pgTable("users", {
|
|||||||
stripeCustomerId: text("stripe_customer_id").unique(),
|
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||||
locale: text("locale").notNull().default("en"),
|
locale: text("locale").notNull().default("en"),
|
||||||
|
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -71,6 +72,16 @@ export const verifications = pgTable("verifications", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Better Auth's twoFactor plugin table — secret/backupCodes are encrypted by
|
||||||
|
// the plugin before storage, not plaintext.
|
||||||
|
export const twoFactors = pgTable("two_factors", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
secret: text("secret").notNull(),
|
||||||
|
backupCodes: text("backup_codes").notNull(),
|
||||||
|
verified: boolean("verified").notNull().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
export const userFollows = pgTable("user_follows", {
|
export const userFollows = pgTable("user_follows", {
|
||||||
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user