Par coderoe · 8 min de lecture

Proposer un 2FA basé sur TOTP (type Google Authenticator) réduit drastiquement le risque de compromission de compte en ajoutant une seconde preuve d'identité, même si le mot de passe est volé.
Dans ce tutoriel, on va ajouter un 2FA basé sur des codes TOTP (Time-based One-Time Password) compatibles avec Google Authenticator, 1Password, etc., par-dessus votre authentification existante NextAuth. L'objectif est simple : après le login classique, l'utilisateur devra saisir un code à 6 chiffres généré par son application d'authentification pour finaliser la connexion.
Concrètement, on va :
twoFactorVerified dans le JWT NextAuth pour contrôler l'accès.On commence par installer les bibliothèques nécessaires pour générer les secrets et les QR codes.
pnpm install otplib qrcode
pnpm i --save-dev @types/qrcode
otplib fournit les primitives pour générer et vérifier des codes TOTP compatibles Google Authenticator, tandis que qrcode permet de transformer l'URI otpauth en QR code affichable dans le navigateur.
Source : Otplib Source
Dans votre schéma Prisma, ajoutez deux champs au modèle User :
model User {
// ... vos autres champs
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
}
twoFactorSecret stocke le secret TOTP lié à l'utilisateur.twoFactorEnabled indique si le 2FA est activé pour ce compte.Ensuite, appliquez les changements :
npx prisma migrate dev
# ou
npx prisma db push
npx prisma generate
L'idée est de stocker dans le JWT deux informations :
twoFactorEnabled : le 2FA est-il activé pour l'utilisateur ?twoFactorVerified : ce login a-t-il déjà passé l'étape 2FA pour cette session ?Ajoutez ceci dans la configuration de NextAuth (callbacks) :
callbacks: {
async jwt({ token, user, account, trigger, session }) {
if (account && user) {
token.twoFactorVerified = !user.twoFactorEnabled;
}
if (trigger === "update") {
const updatedUser = session?.user ?? session;
if (typeof updatedUser?.twoFactorVerified === "boolean") {
token.twoFactorVerified = updatedUser.twoFactorVerified;
}
if (typeof updatedUser?.twoFactorEnabled === "boolean") {
token.twoFactorEnabled = updatedUser.twoFactorEnabled;
}
}
if (token.id) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { twoFactorEnabled: true }
});
if (dbUser) {
token.twoFactorEnabled = dbUser.twoFactorEnabled;
if (token.twoFactorVerified === undefined) {
token.twoFactorVerified = !dbUser.twoFactorEnabled;
}
}
}
return token;
},
async session({ session, token }) {
if (!token?.id) return session;
if (session.user) {
session.user.twoFactorVerified = token.twoFactorVerified as boolean;
session.user.twoFactorEnabled = token.twoFactorEnabled as boolean;
}
return session;
},
},
On doit ensuite déclarer ces propriétés dans les types NextAuth pour profiter d'un typage correct.
Créez ou complétez types/next-auth.d.ts :
// types/next-auth.d.ts
import "next-auth";
declare module "next-auth" {
interface Session {
twoFactorVerified?: boolean;
twoFactorEnabled?: boolean;
user: {
twoFactorVerified: boolean;
twoFactorEnabled: boolean;
};
}
interface User {
id: string;
twoFactorEnabled?: boolean;
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
twoFactorVerified?: boolean;
twoFactorEnabled?: boolean;
}
}
declare module "next-auth/adapters" {
interface AdapterUser {
twoFactorEnabled?: boolean;
}
}
On veut intercepter les requêtes serveur et :
/sign-in./sign-in/verify-2fa./sign-in.Créez ou modifiez middleware.ts :
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
if (token && !token.twoFactorVerified) {
if (pathname.startsWith("/sign-in/verify-2fa")) {
return NextResponse.next();
}
return NextResponse.redirect(new URL("/sign-in/verify-2fa", request.url));
}
const isAuthPage = pathname === "/sign-in";
if (!token) {
if (!isAuthPage) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
return NextResponse.next();
}
if (isAuthPage) {
return NextResponse.redirect(new URL("/", request.url));
}
const allowedRoutes = [
"/settings",
];
const isRouteAllowed =
pathname === "/" ||
allowedRoutes.some((route) => pathname.startsWith(route));
if (!isRouteAllowed) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
On a besoin de deux routes API :
/api/2fa/setup : génère un secret + QR code./api/2fa/enable : vérifie le premier code et active twoFactorEnabled./api/2fa/setup// app/api/2fa/setup/route.ts
import { generateSecret, generateURI } from "otplib";
import QRCode from "qrcode";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function POST() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const secret = generateSecret();
await prisma.user.update({
where: { email: session.user.email },
data: { twoFactorSecret: secret },
});
const otpauth = generateURI({
issuer: "QRfeedback",
label: session.user.email,
secret,
});
const qr = await QRCode.toDataURL(otpauth);
return Response.json({ qr });
}
/api/2fa/enable// app/api/2fa/enable/route.ts
import { verify } from "otplib";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(req: Request) {
const { token } = await req.json();
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
const valid = verify({
token,
secret: user!.twoFactorSecret!,
});
if (!valid) {
return Response.json({ error: "Invalid code" }, { status: 400 });
}
await prisma.user.update({
where: { email: session.user.email },
data: { twoFactorEnabled: true },
});
return Response.json({ success: true });
}
// app/(private)/settings/security/page.tsx
"use client";
import { Button } from "@/components/ui/button";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
export default function TwoFactorSetupPage() {
const [qr, setQr] = useState<string | null>(null);
const [code, setCode] = useState("");
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: session } = useSession();
const { update } = useSession();
useEffect(() => {
if (typeof session?.user?.twoFactorEnabled === "boolean") {
setEnabled(session.user.twoFactorEnabled);
}
}, [session?.user?.twoFactorEnabled]);
async function generateQr() {
setError(null);
setLoading(true);
const res = await fetch("/api/2fa/setup", { method: "POST" });
const data = await res.json();
setQr(data.qr);
setLoading(false);
}
async function enable2FA() {
setError(null);
setLoading(true);
const res = await fetch("/api/2fa/enable", {
method: "POST",
body: JSON.stringify({ token: code }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Code invalide.");
setLoading(false);
return;
}
setEnabled(true);
setQr(null);
setCode("");
await update?.({ twoFactorEnabled: true, twoFactorVerified: true });
setLoading(false);
}
const showSetup = !enabled;
return (
<div className="flex flex-col p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-medium tracking-tight">
Gestion de la sécurité
</h1>
</div>
<div className="mt-6 max-w-2xl space-y-6">
<div className="border bg-background p-4">
<div className="flex items-center justify-between gap-6">
<div>
<h2 className="text-lg font-semibold">
Authentification à deux facteurs
</h2>
<p className="text-sm text-muted-foreground">
{enabled
? "2FA est activé sur votre compte."
: "Utilisez l'authentification à deux facteurs pour sécuriser votre compte."}
</p>
</div>
<button
type="button"
onClick={() => {
if (!enabled && !qr) generateQr();
}}
aria-pressed={enabled}
className={`relative inline-flex h-4 w-6 items-center cursor-pointer rounded-full transition-colors ${
enabled ? "bg-black" : "bg-neutral-200"
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-2.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
</div>
{showSetup && (
<div className="border bg-background p-4">
<h3 className="text-lg font-semibold">
Configurez l'authentification à deux facteurs.
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Pour pouvoir vous connecter, scannez ce code QR avec votre
application d'authentification Google et saisissez le code de
vérification ci-dessous.
</p>
{!qr && (
<div className="mt-6">
<Button
onClick={generateQr}
disabled={loading}
className="h-11 cursor-pointer rounded-none shadow-none bg-neutral-900 text-white hover:bg-neutral-900/90"
>
Activer l'authentification à deux facteurs
</Button>
</div>
)}
{qr && (
<div className="mt-6">
<div className="border bg-muted/30 p-4 flex justify-center">
<img src={qr} alt="QR Code" className="h-48 w-48" />
</div>
<div className="mt-6">
<p className="text-sm font-medium text-foreground">
Saisissez le code de vérification
</p>
<input
placeholder="Saisissez le code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="mt-2 h-11 w-full rounded-none border px-3 text-sm shadow-none"
/>
{error && (
<p className="mt-2 text-sm text-destructive">{error}</p>
)}
</div>
<Button
onClick={enable2FA}
disabled={loading}
className="mt-2 h-11 w-full rounded-none cursor-pointer shadow-none"
>
Confirmer
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}
/api/2fa/verify// app/api/2fa/verify/route.ts
import { verify } from "otplib";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(req: Request) {
const { token } = await req.json();
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user?.twoFactorSecret) {
return Response.json({ error: "Not setup" }, { status: 400 });
}
const isValid = verify({
token,
secret: user.twoFactorSecret,
});
if (!isValid) {
return Response.json({ error: "Invalid code" }, { status: 400 });
}
await prisma.user.update({
where: { email: session.user.email },
data: { twoFactorEnabled: true },
});
return Response.json({ success: true });
}
/sign-in/verify-2fa// app/(public)/sign-in/verify-2fa/page.tsx
"use client";
import { useMemo, useRef, useState } from "react";
import type { ClipboardEvent, KeyboardEvent } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
const OTP_LENGTH = 6;
export default function Verify2FA() {
const { update } = useSession();
const [digits, setDigits] = useState<string[]>(
Array.from({ length: OTP_LENGTH }, () => "")
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
const code = useMemo(() => digits.join(""), [digits]);
function focusIndex(index: number) {
const node = inputRefs.current[index];
if (node) node.focus();
}
function handleChange(index: number, value: string) {
const next = value.replace(/[^0-9]/g, "");
if (!next) {
const updated = [...digits];
updated[index] = "";
setDigits(updated);
return;
}
const updated = [...digits];
updated[index] = next[0];
setDigits(updated);
if (index < OTP_LENGTH - 1) {
focusIndex(index + 1);
}
}
function handleKeyDown(index: number, event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Backspace") return;
if (digits[index]) {
const updated = [...digits];
updated[index] = "";
setDigits(updated);
return;
}
if (index > 0) {
focusIndex(index - 1);
}
}
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const pasted = event.clipboardData.getData("text").replace(/[^0-9]/g, "");
if (!pasted) return;
const updated = Array.from(
{ length: OTP_LENGTH },
(_, idx) => pasted[idx] ?? ""
);
setDigits(updated);
const nextIndex = Math.min(pasted.length, OTP_LENGTH - 1);
focusIndex(nextIndex);
}
async function submit() {
setError(null);
if (code.length !== OTP_LENGTH || digits.some((digit) => digit === "")) {
setError("Veuillez saisir le code à 6 chiffres.");
return;
}
setSubmitting(true);
const res = await fetch("/api/2fa/verify", {
method: "POST",
body: JSON.stringify({ token: code }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Code invalide. Réessayez.");
setSubmitting(false);
return;
}
await update?.({ twoFactorVerified: true });
window.location.href = "/";
}
return (
<div className="min-h-screen bg-muted/40 flex items-center justify-center px-4 py-10">
<div className="w-full max-w-md border bg-background">
<div className="flex items-start justify-between border-b px-6 py-5">
<div>
<h1 className="text-lg font-semibold">
Two-Factor Authentication
</h1>
<p className="text-sm text-muted-foreground mt-1">
Enter the verification code generated by your authenticator app
</p>
</div>
</div>
<div className="px-6 py-6">
<div className="flex items-center justify-center gap-2">
{digits.map((digit, index) => (
<input
key={index}
ref={(node) => {
inputRefs.current[index] = node;
}}
value={digit}
onChange={(event) => handleChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
inputMode="numeric"
maxLength={1}
className="h-12 w-12 border text-center text-lg font-medium focus:outline-none focus:ring-2 focus:ring-black/20"
aria-label={`Digit ${index + 1}`}
/>
))}
</div>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
</div>
<div className="border-t px-6 py-4">
<Button
onClick={submit}
disabled={submitting}
className="w-full h-11 rounded-none bg-black text-white hover:bg-black/90 shadow-none cursor-pointer"
>
Confirm
</Button>
</div>
</div>
</div>
);
}
Pour vérifier que tout fonctionne :
/settings/security./sign-in/verify-2fa après le login./api/2fa/verify doit renvoyer une erreur et laisser l'accès bloqué./.En production, pensez à :
NEXTAUTH_SECRET).
Par coderoe · 5 min de lecture

Par coderoe · 15 min de lecture

Par coderoe · 5 min de lecture