Proyecto práctico: API REST segura en Java con Spring Boot, JWT y refresh tokens
Objetivo: construir una API REST con autenticación basada en JWT (access + refresh tokens), roles, almacenamiento de refresh tokens para revocación, y despliegue mínimo con Docker. Código funcional y explicaciones de por qué cada componente existe.
Requisitos previos
- Java 17+
- Maven
- Docker (opcional para contenedor)
- Conocimientos básicos de Spring Boot y JPA
Estructura de carpetas
demo-secure-api/
├─ pom.xml
├─ Dockerfile
├─ docker-compose.yml (opcional)
└─ src/
└─ main/
├─ java/com/example/demo/
│ ├─ DemoApplication.java
│ ├─ config/SecurityConfig.java
│ ├─ config/JwtFilter.java
│ ├─ controller/AuthController.java
│ ├─ controller/TestController.java
│ ├─ dto/AuthRequest.java
│ ├─ dto/AuthResponse.java
│ ├─ model/User.java
│ ├─ model/Role.java
│ ├─ model/RefreshToken.java
│ ├─ repository/UserRepository.java
│ ├─ repository/RefreshTokenRepository.java
│ ├─ service/UserService.java
│ └─ util/JwtUtil.java
└─ resources/application.yml
pom.xml (dependencias principales)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo-secure-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.1.3</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
show-sql: true
app:
jwt:
secret: verySecretKeyThatShouldBeStoredSafelyAndLongEnough123456
access-exp-ms: 900000 # 15 min
refresh-exp-ms: 2592000000 # 30 days
DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
model/Role.java
package com.example.demo.model;
public enum Role {
ROLE_USER, ROLE_ADMIN
}
model/User.java
package com.example.demo.model;
import jakarta.persistence.*;
import java.util.Set;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private Set roles;
// getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public Set getRoles() { return roles; }
public void setRoles(Set roles) { this.roles = roles; }
}
model/RefreshToken.java
package com.example.demo.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private Instant expiryDate;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Instant getExpiryDate() { return expiryDate; }
public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
}
repository/UserRepository.java
package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository {
Optional findByUsername(String username);
boolean existsByUsername(String username);
}
repository/RefreshTokenRepository.java
package com.example.demo.repository;
import com.example.demo.model.RefreshToken;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
int deleteByUser(User user);
}
dto/AuthRequest.java
package com.example.demo.dto;
public class AuthRequest {
private String username;
private String password;
// getters/setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
dto/AuthResponse.java
package com.example.demo.dto;
public class AuthResponse {
private String accessToken;
private String refreshToken;
public AuthResponse(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
// getters
public String getAccessToken() { return accessToken; }
public String getRefreshToken() { return refreshToken; }
}
util/JwtUtil.java
package com.example.demo.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtil {
private final Key key;
private final long accessExpMs;
public JwtUtil(@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.access-exp-ms}") long accessExpMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.accessExpMs = accessExpMs;
}
public String generateAccessToken(String username, Map claims) {
Date now = new Date();
Date exp = new Date(now.getTime() + accessExpMs);
return Jwts.builder()
.setSubject(username)
.addClaims(claims)
.setIssuedAt(now)
.setExpiration(exp)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
return parseClaims(token).getSubject();
}
public Claims parseClaims(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
}
service/UserService.java
package com.example.demo.service;
import com.example.demo.model.RefreshToken;
import com.example.demo.model.Role;
import com.example.demo.model.User;
import com.example.demo.repository.RefreshTokenRepository;
import com.example.demo.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class UserService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final long refreshExpMs;
public UserService(UserRepository userRepository,
RefreshTokenRepository refreshTokenRepository,
@Value("${app.jwt.refresh-exp-ms}") long refreshExpMs) {
this.userRepository = userRepository;
this.refreshTokenRepository = refreshTokenRepository;
this.refreshExpMs = refreshExpMs;
}
public User register(String username, String rawPassword) {
if (userRepository.existsByUsername(username)) throw new RuntimeException("Usuario ya existe");
User u = new User();
u.setUsername(username);
u.setPassword(passwordEncoder.encode(rawPassword));
u.setRoles(java.util.Set.of(Role.ROLE_USER));
return userRepository.save(u);
}
public Optional findByUsername(String username) {
return userRepository.findByUsername(username);
}
public boolean checkPassword(User user, String rawPassword) {
return passwordEncoder.matches(rawPassword, user.getPassword());
}
public RefreshToken createRefreshToken(User user) {
RefreshToken rt = new RefreshToken();
rt.setUser(user);
rt.setToken(UUID.randomUUID().toString());
rt.setExpiryDate(Instant.now().plusMillis(refreshExpMs));
return refreshTokenRepository.save(rt);
}
public Optional<RefreshToken> findRefreshToken(String token) {
return refreshTokenRepository.findByToken(token);
}
public void deleteUserRefreshTokens(User user) {
refreshTokenRepository.deleteByUser(user);
}
}
config/SecurityConfig.java
package com.example.demo.config;
import com.example.demo.util.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtFilter jwtFilter;
public SecurityConfig(JwtFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}
config/JwtFilter.java
package com.example.demo.config;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserService userService;
public JwtFilter(JwtUtil jwtUtil, UserService userService) {
this.jwtUtil = jwtUtil;
this.userService = userService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
var claims = jwtUtil.parseClaims(token);
var roles = (List<String>)claims.get("roles");
var authorities = roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.AuthRequest;
import com.example.demo.dto.AuthResponse;
import com.example.demo.model.RefreshToken;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
public AuthController(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody AuthRequest req) {
User u = userService.register(req.getUsername(), req.getPassword());
return ResponseEntity.ok("Usuario creado: " + u.getUsername());
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AuthRequest req) {
var userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty() || !userService.checkPassword(userOpt.get(), req.getPassword())) {
return ResponseEntity.status(401).body("Credenciales inválidas");
}
User user = userOpt.get();
Map<String, Object> claims = new HashMap<>();
claims.put("roles", user.getRoles().stream().map(Enum::name).toList());
String accessToken = jwtUtil.generateAccessToken(user.getUsername(), claims);
RefreshToken refreshToken = userService.createRefreshToken(user);
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken.getToken()));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> body) {
String rtoken = body.get("refreshToken");
if (rtoken == null) return ResponseEntity.badRequest().body("refreshToken missing");
var rtOpt = userService.findRefreshToken(rtoken);
if (rtOpt.isEmpty()) return ResponseEntity.status(401).body("Refresh token inválido");
RefreshToken rt = rtOpt.get();
if (rt.getExpiryDate().isBefore(java.time.Instant.now())) {
return ResponseEntity.status(401).body("Refresh token expirado");
}
User user = rt.getUser();
Map<String, Object> claims = new HashMap<>();
claims.put("roles", user.getRoles().stream().map(Enum::name).toList());
String newAccess = jwtUtil.generateAccessToken(user.getUsername(), claims);
return ResponseEntity.ok(new AuthResponse(newAccess, rtoken));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody Map<String, String> body) {
String username = body.get("username");
var uOpt = userService.findByUsername(username);
if (uOpt.isPresent()) {
userService.deleteUserRefreshTokens(uOpt.get());
}
return ResponseEntity.ok("Logged out");
}
}
controller/TestController.java
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/api/hello")
public String hello() {
return "Hola usuario autenticado";
}
@GetMapping("/api/admin")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public String admin() { return "Zona admin"; }
}
Dockerfile
FROM eclipse-temurin:17-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/demo-secure-api-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
Por qué esta arquitectura
- Access tokens cortos (stateless): uso para autorizar peticiones sin consultar la DB en cada request.
- Refresh tokens persistidos: permiten revocar tokens (p. ej. en logout) y limitar sesiones. Si se almacenaran sólo en cliente no podrías invalidarlos.
- BCrypt para contraseñas: estándar seguro y lento para resistir ataques por fuerza bruta.
- Filtro JWT que coloca Authentication en SecurityContext: mantiene el modelo de seguridad de Spring.
- Roles en claims: simplifica autorización basada en roles sin ir a DB en cada request. Si necesitas cambios inmediatos de roles deberías validar con DB.
Pruebas rápidas
- Levanta la app (mvn spring-boot:run).
- POST /auth/register {"username":"u","password":"p"}
- POST /auth/login con mismas credenciales → recibirás accessToken y refreshToken.
- GET /api/hello con header Authorization: Bearer <accessToken> → OK.
- Cuando expire el accessToken, POST /auth/refresh {"refreshToken":"..."} para obtener nuevo accessToken.
Notas de seguridad y siguientes pasos
- No guardes secretos (app.jwt.secret) en el repositorio: usa vault o variables de entorno en producción.
- Protege el endpoint de refresh: podrías añadir fingerprinting (IP, user-agent), rotación de refresh tokens y registro de uso para detectar abuso.
- Considera implementar limitación de tasa (rate limiting) y lista negra de refresh tokens comprometidos.
Consejo avanzado: usa refresh tokens rotativos —cada vez que uses un refresh token para obtener un nuevo access token, emite un nuevo refresh token y marca el anterior como inválido; así reduces el riesgo de uso repetido de tokens robados. Y una advertencia: nunca confíes en JWTs sin validar firma y expiración, y evita incluir información sensible en los claims.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación