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.

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

3. Ajouter les champs 2FA en base

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

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

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;
  }
}

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 :

// 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)$).*)",
  ],
};

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

// 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 });
}

6.2 Route /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 });
}

7. Interface de gestion du 2FA

// 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&apos;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&apos;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&apos;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>
  );
}

8. Vérifier le 2FA à la connexion

8.1 Route /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 });
}

8.2 Page /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>
  );
}

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