Guia Completo: NextAuth + 2FA no Next.js

Este guia aborda a implementação completa de autenticação com Next.js usando NextAuth.js, incluindo login social com Google, login com credenciais e autenticação de dois fatores (2FA).

Sumário

  1. Introdução
  2. Instalação e Configuração
  3. Callbacks e Sessões
  4. Fluxo de Autenticação
  5. Implementação do 2FA
  6. Componentes de UI
  7. Middleware para Proteção de Rotas
  8. Boas Práticas e Segurança

Introdução

A autenticação de dois fatores (2FA) adiciona uma camada extra de segurança ao processo de login. Com NextAuth.js, podemos integrar facilmente tanto login social quanto com credenciais, além de adicionar 2FA ao fluxo de autenticação.

Instalação e Configuração

Dependências Necessárias

npm install next-auth @auth/core @next-auth/prisma-adapter prisma
npm install react-totp-input qrcode otplib

Estrutura de Arquivos

src/
├── app/
│   ├── api/
│   │   └── auth/
│   │       └── [...nextauth]/
│   │           └── route.ts
│   ├── login/
│   │   └── page.tsx
│   ├── auth/
│   │   ├── verify-2fa/
│   │   │   └── page.tsx
│   │   └── error/
│   │       └── page.tsx
│   └── profile/
│       └── two-factor/
│           └── page.tsx
├── components/
│   ├── LoginForm.tsx
│   ├── TwoFactorForm.tsx
│   └── TwoFactorSetup.tsx
├── lib/
│   └── totp.ts
└── middleware.ts

Configuração do NextAuth

Arquivo: src/app/api/auth/[...nextauth]/route.ts

import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "@/lib/prisma";

export const authOptions = {
  adapter: PrismaAdapter(prisma),
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify-request",
    newUser: "/auth/new-user"
  },
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60,  // 30 dias
  },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          prompt: "consent",
          access_type: "offline",
          response_type: "code"
        }
      }
    }),
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Senha", type: "password" },
        totpCode: { label: "Código 2FA", type: "text" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        // Chamada à API para validar credenciais
        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            email: credentials.email,
            password: credentials.password,
          }),
        });

        const user = await response.json();

        if (!response.ok || !user) {
          throw new Error("Credenciais inválidas");
        }

        // Verificar se o usuário tem 2FA habilitado
        if (user.twoFactorEnabled) {
          // Se não enviou código TOTP, retornar usuário com flag
          if (!credentials.totpCode) {
            return {
              ...user,
              requiresTwoFactor: true
            };
          }

          // Verificar código TOTP
          const totpResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/verify-totp`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              userId: user.id,
              code: credentials.totpCode
            }),
          });

          if (!totpResponse.ok) {
            throw new Error("Código 2FA inválido");
          }
        }

        return user;
      }
    }),
  ],
  callbacks: {
    // Callbacks definidos na próxima seção
  },
  events: {
    async signIn(message) {
      // Registrar logins bem-sucedidos
    },
    async signOut(message) {
      // Limpar tokens/sessões
    },
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Callbacks e Sessões

Os callbacks são funções que são chamadas durante o fluxo de autenticação e permitem personalizar o comportamento do NextAuth.js. Vamos detalhar os principais callbacks:

JWT Callback

Este callback é executado sempre que um token JWT é criado (ao fazer login) ou verificado (em cada requisição).

async jwt({ token, user, account }) {
  // Quando o usuário faz login, transferir propriedades para o token
  if (user) {
    token.userId = user.id;
    token.role = user.role;
    token.requiresTwoFactor = user.requiresTwoFactor;
  }

  // Se o token indicar que o usuário precisa completar 2FA
  if (token.requiresTwoFactor) {
    return { ...token, requiresTwoFactor: true };
  }

  return token;
}

Session Callback

Este callback é executado sempre que uma sessão é verificada (em cada requisição que usa useSession() ou getServerSession()).

async session({ session, token }) {
  // Se ainda precisar de verificação 2FA, não gerar sessão válida
  if (token.requiresTwoFactor) {
    return null;
  }

  // Adicionar dados do token à sessão
  if (token) {
    session.user.id = token.userId;
    session.user.role = token.role;
  }

  return session;
}

Redirect Callback

Este callback controla os redirecionamentos durante o fluxo de autenticação.

async redirect({ url, baseUrl }) {
  // Personalizar redirecionamentos após login
  if (url.startsWith("/")) return `${baseUrl}${url}`;
  else if (new URL(url).origin === baseUrl) return url;
  return baseUrl;
}

Configuração de Sessão

A configuração de sessão determina como as sessões de usuário são gerenciadas:

session: {
  strategy: "jwt",            // Usar tokens JWT em vez de sessão no banco de dados
  maxAge: 30 * 24 * 60 * 60,  // 30 dias de duração
}

Tipos de Sessão

Com NextAuth.js, você pode escolher entre duas estratégias de sessão:

  1. Database Sessions (default):

    • Armazena sessões no banco de dados
    • Mais seguro para informações sensíveis
    • Permite invalidar sessões específicas
  2. JWT Sessions:

    • Armazena tudo no token JWT
    • Mais rápido (não precisa consultar o banco de dados)
    • Ideal para aplicações serverless
    • Recomendado para uso com 2FA

Para 2FA, geralmente usamos JWT para armazenar o estado de autenticação temporário.

Fluxo de Autenticação

Login Normal

  1. Usuário acessa /login
  2. Insere credenciais ou clica no botão de login social
  3. NextAuth processa a autenticação
  4. Se bem-sucedido, redireciona para a página de destino

Login com 2FA

  1. Usuário acessa /login
  2. Insere credenciais
  3. Sistema verifica se 2FA está habilitado
  4. Se 2FA estiver habilitado:
    • O token JWT é criado com requiresTwoFactor: true
    • O usuário é redirecionado para /auth/verify-2fa
    • O usuário insere o código TOTP
    • O sistema valida o código
    • Se válido, o token é atualizado e o usuário é redirecionado

Implementação do 2FA

Biblioteca TOTP

Arquivo: src/lib/totp.ts

import { authenticator } from "otplib";

// Configuração para o TOTP
authenticator.options = {
  digits: 6,
  step: 30, // 30 segundos
  window: 1, // tolerância de 1 passo
};

export function generateTOTP(secret: string) {
  return authenticator.generate(secret);
}

export function verifyTOTP(token: string, secret: string) {
  return authenticator.verify({ token, secret });
}

export function generateTOTPSecret() {
  return authenticator.generateSecret();
}

export function generateTOTPUri(secret: string, email: string, issuer = "SeuApp") {
  return authenticator.keyuri(email, issuer, secret);
}

Modificações no Banco de Dados

Para suportar 2FA, seu modelo de usuário deve incluir:

model User {
  id                  String    @id @default(cuid())
  // ... outros campos
  twoFactorEnabled    Boolean   @default(false)
  twoFactorSecret     String?   // Armazenado criptografado
  backupCodes         String[]  // Códigos de recuperação
}

Componentes de UI

Formulário de Login

// src/components/LoginForm.tsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import TwoFactorForm from "./TwoFactorForm";

export default function LoginForm() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const [showTwoFactor, setShowTwoFactor] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError("");

    try {
      const result = await signIn("credentials", {
        redirect: false,
        email,
        password,
      });

      if (result?.error) {
        // Verificar se é um erro de 2FA
        const errorData = JSON.parse(result.error);
        if (errorData.requiresTwoFactor) {
          setShowTwoFactor(true);
          return;
        }
        setError("Email ou senha inválidos");
        return;
      }

      if (result?.url) {
        router.push("/dashboard");
      }
    } catch (error) {
      setError("Ocorreu um erro ao fazer login");
    } finally {
      setLoading(false);
    }
  };

  const handleGoogleLogin = () => {
    signIn("google", { callbackUrl: "/dashboard" });
  };

  if (showTwoFactor) {
    return <TwoFactorForm email={email} password={password} />;
  }

  // Renderização do formulário...
}

Formulário de 2FA

// src/components/TwoFactorForm.tsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import ReactTOTPInput from "react-totp-input";

export default function TwoFactorForm({ email, password }) {
  const router = useRouter();
  const [totpCode, setTotpCode] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setError("");

    try {
      const result = await signIn("credentials", {
        redirect: false,
        email,
        password,
        totpCode,
      });

      if (result?.error) {
        setError("Código 2FA inválido");
        return;
      }

      if (result?.url) {
        router.push("/dashboard");
      }
    } catch (error) {
      setError("Ocorreu um erro ao verificar o código");
    } finally {
      setLoading(false);
    }
  };

  // Renderização do formulário...
}

Middleware para Proteção de Rotas

O middleware verifica se o usuário está autenticado e se já completou o 2FA.

// src/middleware.ts
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(req) {
  const token = await getToken({ req });

  // Verificar se o usuário está autenticado
  if (!token) {
    const url = new URL('/login', req.url);
    url.searchParams.set('callbackUrl', req.url);
    return NextResponse.redirect(url);
  }

  // Verificar se o usuário precisa completar 2FA
  if (token.requiresTwoFactor) {
    return NextResponse.redirect(new URL('/auth/verify-2fa', req.url));
  }

  return NextResponse.next();
}

// Configurar quais rotas o middleware protege
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/api/protected/:path*',
  ],
};

Boas Práticas e Segurança

Armazenamento Seguro

  • Nunca armazene segredos TOTP em texto plano
  • Use criptografia para armazenar o segredo TOTP no banco de dados
  • Considere usar HMAC para validar a integridade dos tokens

Códigos de Recuperação

Sempre ofereça códigos de recuperação como backup:

function generateRecoveryCodes() {
  const codes = [];
  for (let i = 0; i < 10; i++) {
    // Gerar código aleatório (exemplo: XXXX-XXXX-XXXX)
    const code = [...Array(12)]
      .map(() => Math.floor(Math.random() * 16).toString(16))
      .join('')
      .toUpperCase()
      .match(/.{1,4}/g)
      .join('-');

    codes.push(code);
  }
  return codes;
}

Limitação de Tentativas

Implemente limitação de tentativas para evitar ataques de força bruta:

// Exemplo simples de rate limiting
const attempts = {};

function checkRateLimit(userId) {
  const now = Date.now();

  if (!attempts[userId]) {
    attempts[userId] = { count: 1, timestamp: now };
    return true;
  }

  // Resetar tentativas após 15 minutos
  if (now - attempts[userId].timestamp > 15 * 60 * 1000) {
    attempts[userId] = { count: 1, timestamp: now };
    return true;
  }

  // Limitar a 5 tentativas
  if (attempts[userId].count >= 5) {
    return false;
  }

  attempts[userId].count += 1;
  return true;
}

Considerações sobre UX

  • Ofereça opções claras para usuários que perderam acesso ao dispositivo
  • Considere a opção de lembrar dispositivos confiáveis
  • Torne o 2FA opcional inicialmente, mas incentive seu uso

Conclusão

A implementação de NextAuth com 2FA adiciona uma camada significativa de segurança à sua aplicação. Com esta estrutura, você tem um sistema robusto que suporta múltiplos provedores de autenticação e protege as contas dos usuários com autenticação de dois fatores.

Lembre-se de adaptar este guia à sua API e às necessidades específicas do seu projeto.