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.
1pnpm install otplib qrcode2pnpm i --save-dev @types/qrcodeotplib 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 :
1model User {2 // ... vos autres champs3 4 twoFactorSecret String?5 twoFactorEnabled Boolean @default(false)6}twoFactorSecret stocke le secret TOTP lié à l'utilisateur.twoFactorEnabled indique si le 2FA est activé pour ce compte.Ensuite, appliquez les changements :
1npx prisma migrate dev2# ou3npx prisma db push4npx prisma generateL'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) :
1callbacks: {2 async jwt({ token, user, account, trigger, session }) {3 if (account && user) {4 token.twoFactorVerified = !user.twoFactorEnabled;5 }6 7 if (trigger === "update") {8 const updatedUser = session?.user ?? session;9 if (typeof updatedUser?.twoFactorVerified === "boolean") {10 token.twoFactorVerified = updatedUser.twoFactorVerified;11 }12 if (typeof updatedUser?.twoFactorEnabled === "boolean") {13 token.twoFactorEnabled = updatedUser.twoFactorEnabled;14 }15 }16 17 if (token.id) {18 const dbUser = await prisma.user.findUnique({19 where: { id: token.id as string },20 select: { twoFactorEnabled: true }21 });22 if (dbUser) {23 token.twoFactorEnabled = dbUser.twoFactorEnabled;24 if (token.twoFactorVerified === undefined) {25 token.twoFactorVerified = !dbUser.twoFactorEnabled;26 }27 }28 }29 30 return token;31 },32 async session({ session, token }) {33 if (!token?.id) return session;34 35 if (session.user) {36 session.user.twoFactorVerified = token.twoFactorVerified as boolean;37 session.user.twoFactorEnabled = token.twoFactorEnabled as boolean;38 }39 40 return session;41 },42},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 :
1// types/next-auth.d.ts2import "next-auth";3 4declare module "next-auth" {5 interface Session {6 twoFactorVerified?: boolean;7 twoFactorEnabled?: boolean;8 user: {9 twoFactorVerified: boolean;10 twoFactorEnabled: boolean;11 };12 }13 14 interface User {15 id: string;16 twoFactorEnabled?: boolean;17 }18}19 20declare module "next-auth/jwt" {21 interface JWT {22 id?: string;23 twoFactorVerified?: boolean;24 twoFactorEnabled?: boolean;25 }26}27 28declare module "next-auth/adapters" {29 interface AdapterUser {30 twoFactorEnabled?: boolean;31 }32}On veut intercepter les requêtes serveur et :
/sign-in./sign-in/verify-2fa./sign-in.Créez ou modifiez middleware.ts :
1// middleware.ts2import { NextResponse } from "next/server";3import type { NextRequest } from "next/server";4import { getToken } from "next-auth/jwt";5 6export async function middleware(request: NextRequest) {7 const { pathname } = request.nextUrl;8 9 const token = await getToken({ 10 req: request,11 secret: process.env.NEXTAUTH_SECRET 12 });13 14 if (token && !token.twoFactorVerified) {15 if (pathname.startsWith("/sign-in/verify-2fa")) {16 return NextResponse.next();17 }18 return NextResponse.redirect(new URL("/sign-in/verify-2fa", request.url));19 }20 21 const isAuthPage = pathname === "/sign-in";22 23 if (!token) {24 if (!isAuthPage) {25 return NextResponse.redirect(new URL("/sign-in", request.url));26 }27 return NextResponse.next();28 }29 30 if (isAuthPage) {31 return NextResponse.redirect(new URL("/", request.url));32 }33 34 const allowedRoutes = [35 "/settings",36 ];37 38 const isRouteAllowed =39 pathname === "/" ||40 allowedRoutes.some((route) => pathname.startsWith(route));41 42 if (!isRouteAllowed) {43 return NextResponse.redirect(new URL("/", request.url));44 }45 46 return NextResponse.next();47}48 49export const config = {50 matcher: [51 "/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",52 ],53};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/setup1// app/api/2fa/setup/route.ts2import { generateSecret, generateURI } from "otplib";3import QRCode from "qrcode";4import { getServerSession } from "next-auth";5import { authOptions } from "@/lib/auth";6import { prisma } from "@/lib/prisma";7 8export async function POST() {9 const session = await getServerSession(authOptions);10 if (!session?.user?.email) {11 return Response.json({ error: "Unauthorized" }, { status: 401 });12 }13 14 const secret = generateSecret();15 16 await prisma.user.update({17 where: { email: session.user.email },18 data: { twoFactorSecret: secret },19 });20 21 const otpauth = generateURI({22 issuer: "QRfeedback",23 label: session.user.email,24 secret,25 });26 27 const qr = await QRCode.toDataURL(otpauth);28 29 return Response.json({ qr });30}/api/2fa/enable1// app/api/2fa/enable/route.ts2import { verify } from "otplib";3import { prisma } from "@/lib/prisma";4import { getServerSession } from "next-auth";5import { authOptions } from "@/lib/auth";6 7export async function POST(req: Request) {8 const { token } = await req.json();9 10 const session = await getServerSession(authOptions);11 if (!session?.user?.email) {12 return Response.json({ error: "Unauthorized" }, { status: 401 });13 }14 15 const user = await prisma.user.findUnique({16 where: { email: session.user.email },17 });18 19 const valid = verify({20 token,21 secret: user!.twoFactorSecret!,22 });23 24 if (!valid) {25 return Response.json({ error: "Invalid code" }, { status: 400 });26 }27 28 await prisma.user.update({29 where: { email: session.user.email },30 data: { twoFactorEnabled: true },31 });32 33 return Response.json({ success: true });34}1// app/(private)/settings/security/page.tsx2"use client";3 4import { Button } from "@/components/ui/button";5import { useSession } from "next-auth/react";6import { useEffect, useState } from "react";7 8export default function TwoFactorSetupPage() {9 const [qr, setQr] = useState<string | null>(null);10 const [code, setCode] = useState("");11 const [enabled, setEnabled] = useState(false);12 const [loading, setLoading] = useState(false);13 const [error, setError] = useState<string | null>(null);14 const { data: session } = useSession();15 const { update } = useSession();16 17 useEffect(() => {18 if (typeof session?.user?.twoFactorEnabled === "boolean") {19 setEnabled(session.user.twoFactorEnabled);20 }21 }, [session?.user?.twoFactorEnabled]);22 23 async function generateQr() {24 setError(null);25 setLoading(true);26 const res = await fetch("/api/2fa/setup", { method: "POST" });27 const data = await res.json();28 setQr(data.qr);29 setLoading(false);30 }31 32 async function enable2FA() {33 setError(null);34 setLoading(true);35 const res = await fetch("/api/2fa/enable", {36 method: "POST",37 body: JSON.stringify({ token: code }),38 });39 40 if (!res.ok) {41 const data = await res.json().catch(() => null);42 setError(data?.error ?? "Code invalide.");43 setLoading(false);44 return;45 }46 47 setEnabled(true);48 setQr(null);49 setCode("");50 await update?.({ twoFactorEnabled: true, twoFactorVerified: true });51 setLoading(false);52 }53 54 const showSetup = !enabled;55 56 return (57 <div className="flex flex-col p-6">58 <div className="flex items-center justify-between">59 <h1 className="text-2xl font-medium tracking-tight">60 Gestion de la sécurité61 </h1>62 </div>63 64 <div className="mt-6 max-w-2xl space-y-6">65 <div className="border bg-background p-4">66 <div className="flex items-center justify-between gap-6">67 <div>68 <h2 className="text-lg font-semibold">69 Authentification à deux facteurs70 </h2>71 <p className="text-sm text-muted-foreground">72 {enabled73 ? "2FA est activé sur votre compte."74 : "Utilisez l'authentification à deux facteurs pour sécuriser votre compte."}75 </p>76 </div>77 <button78 type="button"79 onClick={() => {80 if (!enabled && !qr) generateQr();81 }}82 aria-pressed={enabled}83 className={`relative inline-flex h-4 w-6 items-center cursor-pointer rounded-full transition-colors ${84 enabled ? "bg-black" : "bg-neutral-200"85 }`}86 >87 <span88 className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${89 enabled ? "translate-x-2.5" : "translate-x-0.5"90 }`}91 />92 </button>93 </div>94 </div>95 96 {showSetup && (97 <div className="border bg-background p-4">98 <h3 className="text-lg font-semibold">99 Configurez l'authentification à deux facteurs.100 </h3>101 <p className="mt-1 text-sm text-muted-foreground">102 Pour pouvoir vous connecter, scannez ce code QR avec votre103 application d'authentification Google et saisissez le code de104 vérification ci-dessous.105 </p>106 107 {!qr && (108 <div className="mt-6">109 <Button110 onClick={generateQr}111 disabled={loading}112 className="h-11 cursor-pointer rounded-none shadow-none bg-neutral-900 text-white hover:bg-neutral-900/90"113 >114 Activer l'authentification à deux facteurs115 </Button>116 </div>117 )}118 119 {qr && (120 <div className="mt-6">121 <div className="border bg-muted/30 p-4 flex justify-center">122 <img src={qr} alt="QR Code" className="h-48 w-48" />123 </div>124 125 <div className="mt-6">126 <p className="text-sm font-medium text-foreground">127 Saisissez le code de vérification128 </p>129 <input130 placeholder="Saisissez le code"131 value={code}132 onChange={(e) => setCode(e.target.value)}133 className="mt-2 h-11 w-full rounded-none border px-3 text-sm shadow-none"134 />135 {error && (136 <p className="mt-2 text-sm text-destructive">{error}</p>137 )}138 </div>139 140 <Button141 onClick={enable2FA}142 disabled={loading}143 className="mt-2 h-11 w-full rounded-none cursor-pointer shadow-none"144 >145 Confirmer146 </Button>147 </div>148 )}149 </div>150 )}151 </div>152 </div>153 );154}/api/2fa/verify1// app/api/2fa/verify/route.ts2import { verify } from "otplib";3import { prisma } from "@/lib/prisma";4import { getServerSession } from "next-auth";5import { authOptions } from "@/lib/auth";6 7export async function POST(req: Request) {8 const { token } = await req.json();9 10 const session = await getServerSession(authOptions);11 if (!session?.user?.email) {12 return Response.json({ error: "Unauthorized" }, { status: 401 });13 }14 15 const user = await prisma.user.findUnique({16 where: { email: session.user.email },17 });18 19 if (!user?.twoFactorSecret) {20 return Response.json({ error: "Not setup" }, { status: 400 });21 }22 23 const isValid = verify({24 token,25 secret: user.twoFactorSecret,26 });27 28 if (!isValid) {29 return Response.json({ error: "Invalid code" }, { status: 400 });30 }31 32 await prisma.user.update({33 where: { email: session.user.email },34 data: { twoFactorEnabled: true },35 });36 37 return Response.json({ success: true });38}/sign-in/verify-2fa1// app/(public)/sign-in/verify-2fa/page.tsx2"use client";3 4import { useMemo, useRef, useState } from "react";5import type { ClipboardEvent, KeyboardEvent } from "react";6import { useSession } from "next-auth/react";7import { Button } from "@/components/ui/button";8 9const OTP_LENGTH = 6;10 11export default function Verify2FA() {12 const { update } = useSession();13 const [digits, setDigits] = useState<string[]>(14 Array.from({ length: OTP_LENGTH }, () => "")15 );16 const [submitting, setSubmitting] = useState(false);17 const [error, setError] = useState<string | null>(null);18 const inputRefs = useRef<Array<HTMLInputElement | null>>([]);19 20 const code = useMemo(() => digits.join(""), [digits]);21 22 function focusIndex(index: number) {23 const node = inputRefs.current[index];24 if (node) node.focus();25 }26 27 function handleChange(index: number, value: string) {28 const next = value.replace(/[^0-9]/g, "");29 if (!next) {30 const updated = [...digits];31 updated[index] = "";32 setDigits(updated);33 return;34 }35 36 const updated = [...digits];37 updated[index] = next[0];38 setDigits(updated);39 40 if (index < OTP_LENGTH - 1) {41 focusIndex(index + 1);42 }43 }44 45 function handleKeyDown(index: number, event: KeyboardEvent<HTMLInputElement>) {46 if (event.key !== "Backspace") return;47 48 if (digits[index]) {49 const updated = [...digits];50 updated[index] = "";51 setDigits(updated);52 return;53 }54 55 if (index > 0) {56 focusIndex(index - 1);57 }58 }59 60 function handlePaste(event: ClipboardEvent<HTMLInputElement>) {61 event.preventDefault();62 const pasted = event.clipboardData.getData("text").replace(/[^0-9]/g, "");63 if (!pasted) return;64 65 const updated = Array.from(66 { length: OTP_LENGTH },67 (_, idx) => pasted[idx] ?? ""68 );69 setDigits(updated);70 const nextIndex = Math.min(pasted.length, OTP_LENGTH - 1);71 focusIndex(nextIndex);72 }73 74 async function submit() {75 setError(null);76 77 if (code.length !== OTP_LENGTH || digits.some((digit) => digit === "")) {78 setError("Veuillez saisir le code à 6 chiffres.");79 return;80 }81 82 setSubmitting(true);83 const res = await fetch("/api/2fa/verify", {84 method: "POST",85 body: JSON.stringify({ token: code }),86 });87 88 if (!res.ok) {89 const data = await res.json().catch(() => null);90 setError(data?.error ?? "Code invalide. Réessayez.");91 setSubmitting(false);92 return;93 }94 95 await update?.({ twoFactorVerified: true });96 window.location.href = "/";97 }98 99 return (100 <div className="min-h-screen bg-muted/40 flex items-center justify-center px-4 py-10">101 <div className="w-full max-w-md border bg-background">102 <div className="flex items-start justify-between border-b px-6 py-5">103 <div>104 <h1 className="text-lg font-semibold">105 Two-Factor Authentication106 </h1>107 <p className="text-sm text-muted-foreground mt-1">108 Enter the verification code generated by your authenticator app109 </p>110 </div>111 </div>112 113 <div className="px-6 py-6">114 <div className="flex items-center justify-center gap-2">115 {digits.map((digit, index) => (116 <input117 key={index}118 ref={(node) => {119 inputRefs.current[index] = node;120 }}121 value={digit}122 onChange={(event) => handleChange(index, event.target.value)}123 onKeyDown={(event) => handleKeyDown(index, event)}124 onPaste={handlePaste}125 inputMode="numeric"126 maxLength={1}127 className="h-12 w-12 border text-center text-lg font-medium focus:outline-none focus:ring-2 focus:ring-black/20"128 aria-label={`Digit ${index + 1}`}129 />130 ))}131 </div>132 133 {error && <p className="mt-3 text-sm text-destructive">{error}</p>}134 </div>135 136 <div className="border-t px-6 py-4">137 <Button138 onClick={submit}139 disabled={submitting}140 className="w-full h-11 rounded-none bg-black text-white hover:bg-black/90 shadow-none cursor-pointer"141 >142 Confirm143 </Button>144 </div>145 </div>146 </div>147 );148}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).