Capa do Ebook gratuito Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Novo curso

20 páginas

Mini-projeto guiado: autenticação completa, sessão persistente e navegação segura

Capítulo 19

Tempo estimado de leitura: 9 minutos

+ Exercício
Audio Icon

Ouça em áudio

0:00 / 0:00

Neste mini-projeto guiado, você vai integrar todas as peças já construídas (rotas, guards, contexto de autenticação, interceptadores, refresh token, páginas de erro) em um fluxo completo e coerente: autenticação funcional, sessão persistente e navegação segura do início ao fim. O foco aqui não é reexplicar os conceitos isolados, mas sim montar o “encadeamento” correto entre UI, roteamento e camada de API para que a experiência do usuário seja consistente e o app seja resiliente a recarregamentos, expiração de token e estados intermediários.

Objetivo do mini-projeto

Você vai implementar (ou finalizar) um conjunto de funcionalidades que, juntas, caracterizam uma autenticação completa em SPA:

  • Login com validação de formulário, estado de carregamento e tratamento de erro.
  • Persistência de sessão (recarregar a página não derruba o usuário).
  • Bootstrap da sessão ao abrir o app (restaurar tokens, buscar “me”, resolver expiração/refresh).
  • Navegação segura: páginas privadas só renderizam após a sessão estar resolvida.
  • Proteção de rotas e componentes com base em autenticação e permissões.
  • Logout consistente (limpa estado, cancela dados sensíveis, redireciona).
  • Tratamento de “token expirou no meio do uso”: refresh transparente e repetição da requisição.
  • Experiência de retorno: manter a rota pretendida quando o usuário é redirecionado ao login.

Visão geral do fluxo (do ponto de vista do usuário)

1) Abrir o app

Ao abrir a SPA, o app entra em um estado de “verificando sessão”. Nesse momento, você ainda não sabe se o usuário está autenticado. O app tenta restaurar a sessão persistida e validar/atualizar o acesso. Só depois disso as rotas privadas podem ser exibidas.

2) Usuário não autenticado tenta acessar rota privada

O guard redireciona para /login, preservando a rota original (por exemplo, /app/profile) como returnUrl. Após login, o usuário volta exatamente para onde queria ir.

3) Usuário autenticado navega e faz requisições

As requisições saem com o token anexado. Se o token expirar, o refresh acontece e a requisição é repetida sem o usuário perceber (desde que o refresh seja válido). Se o refresh falhar, o usuário é deslogado e volta ao login.

Continue em nosso aplicativo

Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.

ou continue lendo abaixo...
Download App

Baixar o aplicativo

Passo a passo prático: implementando o encadeamento completo

Passo 1: Definir contratos mínimos da API (mock ou backend real)

Mesmo que você já tenha um backend, vale explicitar os contratos esperados para alinhar a implementação do front-end. Um conjunto típico inclui endpoints de login, refresh, logout e me.

  • POST /auth/login → retorna accessToken, refreshToken (ou refresh via cookie), e opcionalmente dados básicos do usuário.
  • POST /auth/refresh → retorna novo accessToken (e às vezes novo refresh).
  • POST /auth/logout → invalida refresh no servidor (quando aplicável).
  • GET /me → retorna o usuário atual (id, nome, email, roles/permissões).

O mini-projeto fica mais robusto quando o front-end não “confia” apenas no token local e sempre confirma o usuário via /me durante o bootstrap.

Passo 2: Criar (ou revisar) um AuthService com operações atômicas

Centralize as operações de autenticação em um serviço. A regra aqui é: componentes e páginas não devem conhecer detalhes de armazenamento, refresh, nem de parsing de token. Eles chamam métodos simples.

// services/authService.ts
import { api } from "../services/apiClient";
import { tokenStore } from "../services/tokenStore";

export type LoginPayload = { email: string; password: string };

export const authService = {
  async login(payload: LoginPayload) {
    const { data } = await api.post("/auth/login", payload);
    tokenStore.set(data.accessToken, data.refreshToken);
    return data;
  },

  async logout() {
    try {
      await api.post("/auth/logout");
    } finally {
      tokenStore.clear();
    }
  },

  async refresh() {
    const refreshToken = tokenStore.getRefreshToken();
    if (!refreshToken) throw new Error("No refresh token");
    const { data } = await api.post("/auth/refresh", { refreshToken });
    tokenStore.setAccessToken(data.accessToken);
    if (data.refreshToken) tokenStore.setRefreshToken(data.refreshToken);
    return data;
  },

  async getMe() {
    const { data } = await api.get("/me");
    return data;
  }
};

Se o seu refresh for via cookie httpOnly, o método refresh() não precisa ler refreshToken do storage e pode apenas chamar /auth/refresh com withCredentials (no axios) ou credentials: "include" (no fetch). O importante é manter a assinatura do serviço estável.

Passo 3: Implementar um tokenStore simples e previsível

O tokenStore é um detalhe de infraestrutura. Ele deve oferecer operações pequenas: set/get/clear. Evite espalhar localStorage.getItem pelo app.

// services/tokenStore.ts
const ACCESS_KEY = "app.access";
const REFRESH_KEY = "app.refresh";

export const tokenStore = {
  set(accessToken: string, refreshToken?: string) {
    localStorage.setItem(ACCESS_KEY, accessToken);
    if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
  },
  setAccessToken(accessToken: string) {
    localStorage.setItem(ACCESS_KEY, accessToken);
  },
  setRefreshToken(refreshToken: string) {
    localStorage.setItem(REFRESH_KEY, refreshToken);
  },
  getAccessToken() {
    return localStorage.getItem(ACCESS_KEY);
  },
  getRefreshToken() {
    return localStorage.getItem(REFRESH_KEY);
  },
  clear() {
    localStorage.removeItem(ACCESS_KEY);
    localStorage.removeItem(REFRESH_KEY);
  }
};

Mesmo que você use outra estratégia de armazenamento, a ideia do mini-projeto é: o restante do app não muda.

Passo 4: Bootstrap da sessão (restauração + validação) no AuthProvider

O ponto mais comum de bugs em SPAs é renderizar rotas privadas antes de saber se o usuário está autenticado. Para evitar isso, o AuthProvider precisa ter um estado explícito de inicialização, por exemplo: status: "checking" | "authenticated" | "unauthenticated".

// auth/AuthProvider.tsx
import React, { createContext, useEffect, useMemo, useState } from "react";
import { authService } from "../services/authService";
import { tokenStore } from "../services/tokenStore";

type AuthStatus = "checking" | "authenticated" | "unauthenticated";

type AuthState = {
  status: AuthStatus;
  user: any | null;
};

type AuthContextValue = {
  status: AuthStatus;
  user: any | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  refreshSession: () => Promise<void>;
};

export const AuthContext = createContext<AuthContextValue>({} as any);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AuthState>({ status: "checking", user: null });

  async function bootstrap() {
    const access = tokenStore.getAccessToken();
    if (!access) {
      setState({ status: "unauthenticated", user: null });
      return;
    }

    try {
      // Confirma usuário no backend. Se access expirou, o interceptor/refresh deve resolver.
      const me = await authService.getMe();
      setState({ status: "authenticated", user: me });
    } catch (e) {
      // Se falhar, tente refresh uma vez e repita /me
      try {
        await authService.refresh();
        const me = await authService.getMe();
        setState({ status: "authenticated", user: me });
      } catch {
        tokenStore.clear();
        setState({ status: "unauthenticated", user: null });
      }
    }
  }

  useEffect(() => {
    bootstrap();
  }, []);

  async function login(email: string, password: string) {
    await authService.login({ email, password });
    const me = await authService.getMe();
    setState({ status: "authenticated", user: me });
  }

  async function logout() {
    await authService.logout();
    setState({ status: "unauthenticated", user: null });
  }

  async function refreshSession() {
    await authService.refresh();
    const me = await authService.getMe();
    setState({ status: "authenticated", user: me });
  }

  const value = useMemo(
    () => ({ status: state.status, user: state.user, login, logout, refreshSession }),
    [state.status, state.user]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Note que o bootstrap faz duas tentativas: primeiro /me, depois refresh + /me. Isso reduz “flash” de logout quando o access expira entre sessões.

Passo 5: Gate de inicialização para evitar renderização prematura

Em vez de deixar cada rota lidar com status === "checking", crie um componente de gate no topo da árvore (por exemplo, envolvendo o Router) ou no layout autenticado. A regra é: enquanto estiver “checking”, renderize um fallback neutro.

// auth/AuthGate.tsx
import React from "react";
import { useAuth } from "../auth/useAuth";

export function AuthGate({ children }: { children: React.ReactNode }) {
  const { status } = useAuth();

  if (status === "checking") {
    return <div style={{ padding: 24 }}>Carregando sessão...</div>;
  }

  return <>{children}</>;
}

Isso evita o problema clássico: a rota privada renderiza, dispara requests, e logo em seguida o app descobre que não havia sessão válida.

Passo 6: Página de Login com returnUrl e UX de erro

A página de login deve: (1) capturar returnUrl, (2) chamar login, (3) redirecionar para o destino, (4) exibir erro amigável e (5) bloquear múltiplos envios.

// pages/LoginPage.tsx
import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";

export function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();

  const params = new URLSearchParams(location.search);
  const returnUrl = params.get("returnUrl") || "/app";

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setLoading(true);
    try {
      await login(email, password);
      navigate(returnUrl, { replace: true });
    } catch (err: any) {
      setError("Credenciais inválidas ou erro de rede.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div style={{ padding: 24, maxWidth: 420 }}>
      <h2>Login</h2>
      <form onSubmit={onSubmit}>
        <div>
          <label>Email</label>
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
        </div>
        <div>
          <label>Senha</label>
          <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
        </div>
        {error && <p style={{ color: "crimson" }}>{error}</p>}
        <button disabled={loading}>{loading ? "Entrando..." : "Entrar"}</button>
      </form>
    </div>
  );
}

O replace: true evita que o usuário volte para a tela de login ao apertar “voltar” após autenticar.

Passo 7: Interceptor de API para anexar token e lidar com 401

O mini-projeto precisa garantir que qualquer request autenticado:

  • Envie o access token automaticamente.
  • Ao receber 401 por expiração, tente refresh e repita a requisição.
  • Se refresh falhar, limpe sessão e redirecione (ou sinalize) logout.

A implementação exata depende do seu cliente HTTP. Abaixo um exemplo com axios, focando no encadeamento do mini-projeto (sem reexplicar a teoria):

// services/apiClient.ts
import axios from "axios";
import { tokenStore } from "./tokenStore";
import { authService } from "./authService";

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL
});

api.interceptors.request.use((config) => {
  const token = tokenStore.getAccessToken();
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

let refreshing: Promise<any> | null = null;

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;

    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;

      try {
        if (!refreshing) {
          refreshing = authService.refresh().finally(() => {
            refreshing = null;
          });
        }
        await refreshing;

        const token = tokenStore.getAccessToken();
        original.headers.Authorization = `Bearer ${token}`;
        return api(original);
      } catch (e) {
        tokenStore.clear();
        return Promise.reject(e);
      }
    }

    return Promise.reject(error);
  }
);

O detalhe importante é usar uma variável de fila para evitar múltiplos refresh simultâneos quando várias requisições falham ao mesmo tempo.

Passo 8: Navegação segura com dados do usuário (Dashboard e Perfil)

Agora conecte páginas privadas reais ao estado de autenticação. O objetivo é: páginas privadas devem consumir user do contexto e, quando precisarem de dados adicionais, chamar a API já com token anexado.

// pages/ProfilePage.tsx
import React, { useEffect, useState } from "react";
import { useAuth } from "../auth/useAuth";
import { api } from "../services/apiClient";

export function ProfilePage() {
  const { user } = useAuth();
  const [details, setDetails] = useState<any | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    api.get("/profile")
      .then((res) => {
        if (alive) setDetails(res.data);
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, []);

  if (loading) return <div style={{ padding: 24 }}>Carregando perfil...</div>;

  return (
    <div style={{ padding: 24 }}>
      <h2>Perfil</h2>
      <p>Usuário autenticado: {user?.email}</p>
      <pre><code>{JSON.stringify(details, null, 2)}</code></pre>
    </div>
  );
}

Repare que o componente não precisa saber nada sobre refresh. Se o access expirou, o interceptor faz o trabalho e o componente continua simples.

Passo 9: Logout seguro e limpeza de dados sensíveis

Além de limpar tokens e estado do AuthProvider, o logout deve considerar dados em cache (por exemplo, queries, stores e estados locais). Mesmo sem uma biblioteca de cache, você pode pelo menos garantir que páginas privadas não permaneçam visíveis após logout.

// components/TopBar.tsx
import React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";

export function TopBar() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();

  async function onLogout() {
    await logout();
    navigate("/login", { replace: true });
  }

  return (
    <div style={{ display: "flex", gap: 12, padding: 12, borderBottom: "1px solid #eee" }}>
      <span>Logado como: {user?.email}</span>
      <button onClick={onLogout}>Sair</button>
    </div>
  );
}

Se você usa React Query, Redux ou outra camada de cache, este é o ponto para invalidar/limpar caches no logout (por exemplo, queryClient.clear()), evitando que dados do usuário anterior apareçam para o próximo login no mesmo dispositivo.

Passo 10: Amarrar tudo no App (ordem correta dos providers)

Um erro comum é colocar o Router fora do AuthProvider e perder acesso ao estado de autenticação em guards, ou não usar o AuthGate e renderizar cedo demais. Uma composição típica:

// App.tsx
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth/AuthProvider";
import { AuthGate } from "./auth/AuthGate";
import { AppRoutes } from "./routes/AppRoutes";

export function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <AuthGate>
          <AppRoutes />
        </AuthGate>
      </AuthProvider>
    </BrowserRouter>
  );
}

O AuthGate garante que as rotas só são avaliadas quando a sessão já foi resolvida (autenticada ou não).

Checklist de validação do mini-projeto (testes manuais guiados)

Cenário A: Primeiro acesso sem sessão

  • Acesse diretamente uma rota privada (ex.: /app/profile).
  • Verifique se você é redirecionado para /login?returnUrl=/app/profile.
  • Após login, confirme que voltou para /app/profile.

Cenário B: Recarregar a página autenticado

  • Faça login e navegue para uma rota privada.
  • Dê refresh no navegador.
  • Confirme que o app mostra “Carregando sessão...” brevemente e retorna autenticado, sem piscar conteúdo público.

Cenário C: Expiração do access token durante uso

  • Com o app aberto, force expiração do access (por tempo curto no backend ou alterando o token).
  • Dispare uma requisição (abrir Perfil, atualizar dados, etc.).
  • Confirme que ocorre refresh e a requisição original é repetida com sucesso.

Cenário D: Refresh inválido

  • Invalide o refresh token no backend (ou remova do storage).
  • Dispare uma requisição que gere 401.
  • Confirme que o app limpa tokens e volta ao login.

Cenário E: Logout

  • Estando em uma rota privada, clique em “Sair”.
  • Confirme redirecionamento para login e impossibilidade de voltar para a página privada via botão “voltar”.

Ajustes finos comuns (para deixar o mini-projeto “pronto para produção”)

Evitar chamadas duplicadas ao /me

Se você chama /me no bootstrap e também em páginas privadas ao montar, pode haver duplicidade. Uma abordagem simples é: usar o user do contexto para UI e chamar endpoints específicos apenas quando necessário. Se precisar de dados completos de perfil, diferencie /me (mínimo) de /profile (detalhado).

Sincronizar múltiplas abas

Se o usuário faz logout em uma aba, a outra aba deve reagir. Uma solução prática é escutar o evento storage e, ao detectar remoção do token, atualizar o estado para unauthenticated. Isso evita inconsistências de UI em múltiplas abas.

// auth/useTokenSync.ts
import { useEffect } from "react";
import { tokenStore } from "../services/tokenStore";

export function useTokenSync(onLogout: () => void) {
  useEffect(() => {
    function handler(e: StorageEvent) {
      if (e.key === "app.access" && !tokenStore.getAccessToken()) {
        onLogout();
      }
    }
    window.addEventListener("storage", handler);
    return () => window.removeEventListener("storage", handler);
  }, [onLogout]);
}

Você pode chamar esse hook dentro do AuthProvider para manter o estado consistente.

Padronizar mensagens de erro

Em vez de cada página decidir como exibir erros, padronize: erros 401/403/500 podem virar mensagens específicas. No mini-projeto, um passo prático é mapear erros do axios e retornar um tipo de erro de domínio (ex.: InvalidCredentialsError, NetworkError), simplificando a UI.

Bloquear navegação para rotas públicas quando autenticado (opcional)

Se o usuário já está autenticado, acessar /login pode redirecionar automaticamente para /app. Isso reduz confusão e reforça o fluxo. Implemente isso na própria página de login verificando status === "authenticated" e navegando para o destino padrão.

Agora responda o exercício sobre o conteúdo:

Qual e o principal motivo de usar um AuthGate para bloquear a renderizacao enquanto o status esta como checking?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

O AuthGate impede que a aplicacao avalie e renderize rotas privadas antes de concluir o bootstrap da sessao. Assim, evita requests e UI inconsistentes enquanto o app ainda esta verificando se o usuario esta autenticado.

Próximo capitúlo

Mini-projeto guiado: permissões por role, ajustes finais e validação do fluxo ponta a ponta

Arrow Right Icon
Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.