Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@aws-sdk/client-s3": "^3.621.0",
"@aws-sdk/s3-request-presigner": "^3.621.0",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
Expand All @@ -36,6 +37,7 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/aplicativo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express, { Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import registrador from './utils/registrador';
import { registro, medirRequisicoes } from './utils/metricas';
import rotasVerificacao from './routes/verificacao';
Expand Down Expand Up @@ -47,6 +48,9 @@ aplicativo.use(
})
);

// Cookie parser
aplicativo.use(cookieParser());

// Analisador de corpo
aplicativo.use(express.json({ limit: '10mb' }));
aplicativo.use(express.urlencoded({ limit: '10mb', extended: true }));
Expand Down
7 changes: 4 additions & 3 deletions backend/src/middleware/autenticacao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ declare global {
}

export function autenticar(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
const token = req.cookies?.valida_token
?? (req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined);

if (!token) {
res.status(401).json({ erro: 'Token não fornecido' });
return;
}

const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, configuracao.jwt.segredo) as PayloadJWT;
req.usuario = payload;
Expand Down
51 changes: 48 additions & 3 deletions backend/src/modulos/auth/rotas.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Router } from 'express';
import { Router, type CookieOptions } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { configuracao } from '../../configuracao';
import { tratarErro } from '../../utils/erros';
import registrador from '../../utils/registrador';
import { autenticar } from '../../middleware/autenticacao';
import {
buscarPorEmailParaLogin,
buscarPorEmail,
Expand All @@ -14,6 +15,29 @@

const router = Router();

function parseDuracaoMs(duracao: string): number {
const match = duracao.match(/^(\d+)([smhd])$/);

Check warning on line 19 in backend/src/modulos/auth/rotas.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=icrcode_valida-ai&issues=AZ720Y3wxgGiYuON_4IQ&open=AZ720Y3wxgGiYuON_4IQ&pullRequest=71
if (!match) return 7 * 24 * 60 * 60 * 1000;
const n = parseInt(match[1], 10);

Check warning on line 21 in backend/src/modulos/auth/rotas.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=icrcode_valida-ai&issues=AZ720Y3wxgGiYuON_4IR&open=AZ720Y3wxgGiYuON_4IR&pullRequest=71
switch (match[2]) {
case 's': return n * 1000;
case 'm': return n * 60 * 1000;
case 'h': return n * 60 * 60 * 1000;
case 'd': return n * 24 * 60 * 60 * 1000;
default: return 7 * 24 * 60 * 60 * 1000;
}
}

function opcoesCookie(): CookieOptions {
return {
httpOnly: true,
secure: configuracao.ambienteNode === 'production',
sameSite: 'lax',
path: '/',
maxAge: parseDuracaoMs(configuracao.jwt.expiraEm),
};
}

async function gerarToken(
usuario: Omit<UsuarioParaLogin, 'dominios_email' | 'ativo' | 'criado_em' | 'atualizado_em' | 'senha_hash'>,
): Promise<string> {
Expand Down Expand Up @@ -113,8 +137,8 @@
instituicao: usuario.instituicao_nome,
});

res.cookie('valida_token', token, opcoesCookie());
res.json({
token,
usuario: {
id: usuario.id,
nome: usuario.nome,
Expand Down Expand Up @@ -210,8 +234,8 @@
instituicao: curso.instituicao_nome,
});

res.cookie('valida_token', token, opcoesCookie());
res.status(201).json({
token,
usuario: {
id: novoUsuario.id,
nome: novoUsuario.nome,
Expand All @@ -228,4 +252,25 @@
}
});

router.get('/me', autenticar, (req, res) => {
const u = req.usuario!;
res.json({
usuario: {
id: u.sub,
nome: u.nome,
email: u.email,
perfil: u.perfil,
matricula: u.matricula ?? null,
curso_id: u.curso_id ?? null,
instituicao_id: u.instituicao_id ?? null,
instituicao_nome: u.instituicao_nome ?? null,
},
});
});

router.post('/logout', (_req, res) => {
res.clearCookie('valida_token', { path: '/' });
res.json({ mensagem: 'Logout realizado' });
});

export default router;
13 changes: 8 additions & 5 deletions backend/testes/rotas/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,13 @@ describe('POST /login', () => {
expect(res.body.erro).toContain('Domínio');
});

it('retorna 200 com token quando login é bem-sucedido (sem restrição de domínio)', async () => {
it('retorna 200 com cookie httpOnly quando login é bem-sucedido (sem restrição de domínio)', async () => {
mockUsuarios.buscarPorEmailParaLogin.mockResolvedValueOnce(USUARIO_LOGIN_MOCK);
mockBcryptCompare.mockResolvedValueOnce(true);
const res = await request(app).post('/login').send(DADOS_LOGIN);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []);
expect(cookies.some((c) => c.startsWith('valida_token=') && c.includes('HttpOnly'))).toBe(true);
expect(res.body.usuario.email).toBe('joao@uni.edu');
expect(res.body.usuario.perfil).toBe('estudante');
});
Expand All @@ -153,7 +154,8 @@ describe('POST /login', () => {
mockBcryptCompare.mockResolvedValueOnce(true);
const res = await request(app).post('/login').send(DADOS_LOGIN);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []);
expect(cookies.some((c) => c.startsWith('valida_token='))).toBe(true);
});

it('retorna 200 quando lista de domínios está vazia (sem restrição)', async () => {
Expand Down Expand Up @@ -234,14 +236,15 @@ describe('POST /cadastro', () => {
expect(res.status).toBe(403);
});

it('retorna 201 com token quando cadastro é bem-sucedido', async () => {
it('retorna 201 com cookie httpOnly quando cadastro é bem-sucedido', async () => {
mockUsuarios.buscarPorEmail.mockResolvedValueOnce(null);
mockCursos.buscarCursoPorId.mockResolvedValueOnce(CURSO_MOCK);
mockBcryptHash.mockResolvedValueOnce('$2b$10$hashed');
mockUsuarios.criarUsuario.mockResolvedValueOnce(USUARIO_CRIADO);
const res = await request(app).post('/cadastro').send(DADOS_CADASTRO);
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
const cookies = ([] as string[]).concat(res.headers['set-cookie'] ?? []);
expect(cookies.some((c) => c.startsWith('valida_token=') && c.includes('HttpOnly'))).toBe(true);
expect(res.body.usuario.perfil).toBe('estudante');
expect(res.body.usuario.email).toBe('maria@uni.edu');
});
Expand Down
20 changes: 8 additions & 12 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { Usuario } from '../types';
import { queryClient } from '../lib/queryClient';
import api from '../services/api';

interface AuthState {
token: string | null;
usuario: Usuario | null;
}

interface AuthContextValue extends AuthState {
login: (token: string, usuario: Usuario) => void;
login: (usuario: Usuario) => void;
logout: () => void;
isAuthenticated: boolean;
transitioning: boolean;
Expand All @@ -17,37 +17,33 @@
const AuthContext = createContext<AuthContextValue | null>(null);

function carregarEstadoInicial(): AuthState {
const token = localStorage.getItem('token');
const raw = localStorage.getItem('usuario');
const usuario = raw ? (JSON.parse(raw) as Usuario) : null;
return { token, usuario };
return { usuario };
}

export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>(carregarEstadoInicial);
const [transitioning, setTransitioning] = useState(false);

const login = useCallback((token: string, usuario: Usuario) => {
// Limpa cache da sessão anterior antes de renderizar qualquer dado novo
const login = useCallback((usuario: Usuario) => {
queryClient.clear();
setTransitioning(true);
localStorage.setItem('token', token);
localStorage.setItem('usuario', JSON.stringify(usuario));
setState({ token, usuario });
// Aguarda dois frames: um para montar a tela de carregamento, outro para liberar
setState({ usuario });
requestAnimationFrame(() => requestAnimationFrame(() => setTransitioning(false)));
}, []);

const logout = useCallback(() => {
api.post('/api/auth/logout').catch(() => {});
queryClient.clear();
localStorage.removeItem('token');
localStorage.removeItem('usuario');
setState({ token: null, usuario: null });
setState({ usuario: null });
}, []);

return (
<AuthContext.Provider
value={{ ...state, login, logout, isAuthenticated: !!state.token, transitioning }}
value={{ ...state, login, logout, isAuthenticated: !!state.usuario, transitioning }}

Check warning on line 46 in frontend/src/contexts/AuthContext.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The object passed as the value prop to the Context provider changes every render. To fix this consider wrapping it in a useMemo hook.

See more on https://sonarcloud.io/project/issues?id=icrcode_valida-ai&issues=AZ720ZAMxgGiYuON_4IS&open=AZ720ZAMxgGiYuON_4IS&pullRequest=71
>
{children}
</AuthContext.Provider>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/Cadastro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,14 @@ export function Cadastro() {
setErro('');
setLoading(true);
try {
const { token, usuario } = await authService.cadastrar({
const { usuario } = await authService.cadastrar({
nome: nome.trim(),
email: email.trim().toLowerCase(),
senha,
matricula: matricula.trim(),
curso_id: cursoId,
});
login(token, usuario);
login(usuario);
navigate('/dashboard');
} catch (err: unknown) {
setErro(mensagemErroSegura(err, 'Falha ao criar conta. Tente novamente.'));
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export function Login() {
setErro('');
setLoading(true);
try {
const { token, usuario } = await authService.login(email.trim().toLowerCase(), senha);
login(token, usuario);
const { usuario } = await authService.login(email.trim().toLowerCase(), senha);
login(usuario);
navigate('/dashboard');
} catch (err: unknown) {
setErro(mensagemErroSegura(err, 'Falha ao conectar com o servidor'));
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/pages/Perfil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export function Perfil() {
const { usuario, login } = useAuth();
const { addToast } = useToast();
const queryClient = useQueryClient();
const token = localStorage.getItem('token') ?? '';

const [modo, setModo] = useState<Modo>('ver');

Expand Down Expand Up @@ -87,7 +86,7 @@ export function Perfil() {
endereco: temCpfEndereco ? (endereco.trim() || null) : undefined,
}),
onSuccess: (atualizado) => {
login(token, atualizado);
login(atualizado);
queryClient.setQueryData(['perfil'], atualizado);
addToast('Dados atualizados com sucesso!', 'success');
setModo('ver');
Expand Down
12 changes: 1 addition & 11 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,14 @@ import { queryClient } from '../lib/queryClient';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
timeout: 15_000,
withCredentials: false,
});

api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
withCredentials: true,
});

api.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
// Limpa o cache completo antes de redirecionar para evitar dados residuais de sessão
queryClient.clear();
localStorage.removeItem('token');
localStorage.removeItem('usuario');
globalThis.location.replace('/login');
}
Expand Down
Loading
Loading