- Java
- Spring Boot
- Spring Security
- JWT
- PostgreSQL
- Docker
- React
- TypeScript
- CI/CD
Heracles Tracker
API REST e app PWA para registrar treinos, execuções e acompanhar a progressão de carga, com autenticação JWT.
O Heracles é um projeto full-stack inspirado nos 12 trabalhos de Hércules: o usuário cria planos de treino, registra execuções e acompanha a evolução da carga. O front-end é um PWA (React + TypeScript) hospedado na Vercel; nesta página eu detalho a API REST, que é o coração do repositório.
A documentação interativa da API em produção, com todos os endpoints, schemas e exemplos, está publicada no Swagger/OpenAPI.
Arquitetura e stack
A API é um monólito Spring Boot com camadas clássicas: controller, service, repository, model, dto e security, com um handler global de erros e configurações em config. A stack do pom.xml:
- Java 21 + Spring Boot 4.1.0 com Spring Web MVC, Data JPA (Hibernate) e Bean Validation.
- Spring Security 6 com JWT (jjwt 0.12.6), autenticação stateless.
- springdoc-openapi 2.8.8 para Swagger/OpenAPI, bucket4j 8.10.1 para rate limiting e Spring Actuator para health check.
- PostgreSQL (Neon) em produção, H2 em memória no desenvolvimento, Lombok e Maven wrapper.
Modelo de dados e isolamento por usuário
São seis entidades: Usuario, Treino, Exercicio, Execucao, ExercicioRealizado e RefreshToken. A regra central é o isolamento: cada usuário só enxerga o que é dele. Nos services, quando um recurso existe mas pertence a outro dono, eu lanço EntityNotFoundException com a mesma mensagem de “não encontrado”, para não confirmar a existência de dados alheios:
Treino treino = treinoRepository.findById(treinoId)
.orElseThrow(() -> new EntityNotFoundException("Treino nao encontrado com ID: " + treinoId));
if (!treino.getUsuario().getId().equals(usuarioId)) {
throw new EntityNotFoundException("Treino nao encontrado com ID: " + treinoId);
}
Na criação de uma execução eu ainda valido que cada exercício informado existe e pertence àquele treino, reaproveitando a entidade real em vez do id solto:
Exercicio exercicio = exercicioRepository.findById(realizado.getExercicio().getId())
.orElseThrow(() -> new IllegalArgumentException("Exercicio nao encontrado com ID: " + realizado.getExercicio().getId()));
if (!exercicio.getTreino().getId().equals(treinoId)) {
throw new IllegalArgumentException(
"Exercicio '" + exercicio.getNome() + "' nao pertence ao treino " + treino.getNome());
}
realizado.setExercicio(exercicio);
No cadastro de treino uso usuarioRepository.getReferenceById(...) para gravar só a FK, sem um SELECT extra só para montar o proxy.
Autenticação
Os endpoints de conta ficam em AuthController (/auth). O register checa email duplicado (409) e salva a senha com BCrypt. O login carrega o usuário pelo email, confere a senha e devolve um par de tokens:
UserDetails userDetails;
try {
userDetails = userDetailsServiceImpl.loadUserByUsername(request.getEmail());
} catch (UsernameNotFoundException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!passwordEncoder.matches(request.getPassword(), userDetails.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Usuario usuario = usuarioRepository.findByEmail(request.getEmail()).get();
String accessToken = jwtService.generateToken(userDetails);
RefreshToken refreshToken = refreshTokenService.criar(usuario);
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken.getToken()));
O access token é um JWT com expiração configurável (15 minutos no application.properties); o refresh token é um UUID persistido no banco com validade de 30 dias. O refresh valida o UUID, gera um access novo e responde com o mesmo refresh token enviado:
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
RefreshToken refreshToken = refreshTokenService.validar(request.getRefreshToken());
String accessToken = jwtService.generateToken(
userDetailsServiceImpl.loadUserByUsername(refreshToken.getUsuario().getEmail()));
return ResponseEntity.ok(new AuthResponse(accessToken, request.getRefreshToken()));
}
O logout valida o refresh e revoga os tokens do usuário (deleteByUsuario).
A cadeia de segurança
O JwtService gera e valida os tokens com jjwt, usando o email como subject. O JwtAuthFilter é um OncePerRequestFilter que roda antes do filtro de autenticação do Spring: lê o header Authorization, extrai o email e, se o token for válido, monta o UsernamePasswordAuthenticationToken no contexto:
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String email = jwtService.extractEmail(token);
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(email);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
}
filterChain.doFilter(request, response);
A SecurityConfig define a cadeia: CSRF desligado, sessão stateless, CORS liberado e os endpoints públicos declarados explicitamente; todo o resto exige autenticação:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/", "/h2-console/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
Erros e rate limiting
O GlobalExceptionHandler (@RestControllerAdvice) padroniza as respostas de erro em um ErrorResponse com timestamp, status, mensagem, path e, na validação, um mapa fieldErrors por campo. EntityNotFoundException e rota inexistente viram 404, MethodArgumentNotValidException e IllegalArgumentException viram 400, e o restante vira 500 genérico (logado no servidor).
O rate limiting usa bucket4j num HandlerInterceptor: 100 requisições por minuto por IP, com retorno 429 e cabeçalho Retry-After:
Bandwidth limit = Bandwidth.builder()
.capacity(100)
.refillIntervally(100, Duration.ofMinutes(1))
.initialTokens(100)
.build();
Bucket bucket = buckets.computeIfAbsent(ip, k -> createBucket());
if (bucket.tryConsume(1)) {
return true;
}
response.setStatus(429);
response.setHeader("Retry-After", "60");
response.getWriter().write("Muitas Requisições.");
return false;
Testes, CI e deploy
São 31 testes unitários com JUnit 5 + Mockito (services, controller de auth e lógica JWT). O GitHub Actions roda mvn test no push para a main e, se passar, dispara o deploy no Render via Deploy Hook. A imagem Docker usa multi-stage build e, em produção, o perfil prod troca o H2 pelo PostgreSQL via variáveis de ambiente (DATABASE_URL, JWT_SECRET etc.). O DataLoader popula o banco com dados de exemplo e um usuário de teste, mas só fora do perfil prod.
Particularidades e pontos que revisaria
-
401 que na prática vira 403. Não configurei um
AuthenticationEntryPoint; quando o token falta ou é inválido, oJwtAuthFiltersó segue a cadeia e o Spring Security responde 403 no lugar do 401 que eu documento no README. Além disso, ocatch (Exception e)vazio do filtro engole em silêncio qualquer erro de parsing do token (assinatura inválida, expirado). -
Refresh token sem rotação. O
/auth/refreshdevolve o mesmo refresh token recebido. Se ele vazar, fica válido por 30 dias e só o logout o derruba; o ideal seria rotacionar, emitindo outro token e invalidando o anterior. -
Formatos de resposta inconsistentes. O
registerresponde 201 com texto puro"Conta criada com sucesso"(umaString), e ologindevolve 401 com corpo vazio; o resto da API segue oErrorResponseJSON. Vale padronizar tudo. -
Rate limiter com memória infinita. O mapa de buckets cresce por IP e nunca é limpo; numa instância de longa duração com muitos IPs, isso vaza memória. E a resposta 429 é texto puro (
Muitas Requisições.), fora do formatoErrorResponse. -
Lazy loading fora de transação. As listagens e a montagem das respostas dependem do open-in-view do Spring Boot (padrão ligado) para carregar os relacionamentos preguiçosos. Se eu desligar
spring.jpa.open-in-view=false, essas leituras quebram; o certo seriafetch joinou montar os DTOs dentro da transação. -
Segredos e seed no perfil dev. O
app.jwt.secretfica fixo noapplication.propertiese oDataLoadercriateste@email.comcom senha123456. Ambos estão restritos ao ambiente de desenvolvimento (oprodusa variáveis de ambiente e não roda o loader), mas o segredo embutido é um risco se o perfil errado for ativado.
Pontos de melhoria
- Configurar um
AuthenticationEntryPointque devolva 401 em JSON quando o token faltar ou for inválido, e logar o motivo no filtro. - Rotacionar o refresh token no
/auth/refresh(novo UUID + invalidar o anterior). - Padronizar as respostas de sucesso e erro do
/authe o 429 no formatoErrorResponse. - Adicionar limpeza ou TTL no mapa de buckets do rate limiter.
- Revisar o carregamento dos relacionamentos (fetch join/
@EntityGraph) e desligar o open-in-view. - Mover o segredo JWT de desenvolvimento para variável de ambiente com fallback apenas no perfil dev.
Gabriel Rocha