Ajouter un 2FA (TOTP) avec NextAuth et Next.js

Par coderoe · 8 min de lecture

Next.jsSécuritéNextAuth
Ajouter un 2FA (TOTP) avec NextAuth et Next.js

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é.

1. Pourquoi ajouter un 2FA ?

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 :

  • Stocker un secret TOTP par utilisateur (dans Prisma).
  • Générer un QR code à scanner dans l'app d'authentification.
  • Ajouter un flag twoFactorVerified dans le JWT NextAuth pour contrôler l'accès.
  • Protéger les routes via le middleware jusqu'à ce que le 2FA soit validé.

2. Installer les dépendances

On commence par installer les bibliothèques nécessaires pour générer les secrets et les QR codes.

bash
1pnpm install otplib qrcode
2pnpm 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

3. Ajouter les champs 2FA en base

Dans votre schéma Prisma, ajoutez deux champs au modèle User :

prisma
1model User {
2 // ... vos autres champs
3 
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 :

bash
1npx prisma migrate dev
2# ou
3npx prisma db push
4npx prisma generate

4. Propager l'état 2FA dans NextAuth

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) :

ts
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 :

ts
1// types/next-auth.d.ts
2import "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}

5. Protéger les routes avec le middleware

On veut intercepter les requêtes serveur et :

  • Rediriger les utilisateurs non connectés vers /sign-in.
  • Rediriger les utilisateurs connectés mais non vérifiés 2FA vers /sign-in/verify-2fa.
  • Empêcher un utilisateur connecté d'accéder à /sign-in.

Créez ou modifiez middleware.ts :

ts
1// middleware.ts
2import { 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};

6. Activer le 2FA côté backend

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.

6.1 Route /api/2fa/setup

ts
1// app/api/2fa/setup/route.ts
2import { 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}

6.2 Route /api/2fa/enable

ts
1// app/api/2fa/enable/route.ts
2import { 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}

7. Interface de gestion du 2FA

tsx
1// app/(private)/settings/security/page.tsx
2"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 facteurs
70 </h2>
71 <p className="text-sm text-muted-foreground">
72 {enabled
73 ? "2FA est activé sur votre compte."
74 : "Utilisez l'authentification à deux facteurs pour sécuriser votre compte."}
75 </p>
76 </div>
77 <button
78 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 <span
88 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&apos;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 votre
103 application d&apos;authentification Google et saisissez le code de
104 vérification ci-dessous.
105 </p>
106 
107 {!qr && (
108 <div className="mt-6">
109 <Button
110 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&apos;authentification à deux facteurs
115 </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érification
128 </p>
129 <input
130 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 <Button
141 onClick={enable2FA}
142 disabled={loading}
143 className="mt-2 h-11 w-full rounded-none cursor-pointer shadow-none"
144 >
145 Confirmer
146 </Button>
147 </div>
148 )}
149 </div>
150 )}
151 </div>
152 </div>
153 );
154}

8. Vérifier le 2FA à la connexion

8.1 Route /api/2fa/verify

ts
1// app/api/2fa/verify/route.ts
2import { 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}

8.2 Page /sign-in/verify-2fa

tsx
1// app/(public)/sign-in/verify-2fa/page.tsx
2"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 Authentication
106 </h1>
107 <p className="text-sm text-muted-foreground mt-1">
108 Enter the verification code generated by your authenticator app
109 </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 <input
117 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 <Button
138 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 Confirm
143 </Button>
144 </div>
145 </div>
146 </div>
147 );
148}

9. Tests rapides et bonnes pratiques

Pour vérifier que tout fonctionne :

  • Créez un compte de test, connectez-vous, puis activez le 2FA dans /settings/security.
  • Déconnectez-vous puis reconnectez-vous : vous devez être redirigé vers /sign-in/verify-2fa après le login.
  • Saisissez un code invalide : la route /api/2fa/verify doit renvoyer une erreur et laisser l'accès bloqué.
  • Saisissez un code valide : la session doit être marquée comme vérifiée et vous rediriger vers /.

En production, pensez à :

  • Protéger correctement les variables d'environnement (notamment NEXTAUTH_SECRET).
  • Sauvegarder le secret TOTP dans une base sécurisée et chiffrée si nécessaire.
  • Prévoir un mécanisme de récupération (désactivation du 2FA via support, codes de secours, etc.).

Articles recommandés