API de autenticación con JWT y Refresh Tokens usando Spring Boot (Java)
Proyecto práctico: construimos una API REST con Spring Boot que gestiona registro, login, emisión de JWT (access token) y refresh token, con almacenamiento del refresh token en la BD y rotación básica. Incluye seguridad con BCrypt y un filtro que valida el token en cada petición.
Requisitos previos
- Java 17+
- Maven
- Conocimientos básicos de Spring Boot, JPA y Spring Security
Estructura de carpetas
auth-jwt-springboot/
├─ pom.xml
└─ src/main/java/com/example/auth/
├─ AuthApplication.java
├─ controller/
│ └─ AuthController.java
├─ dto/
│ ├─ AuthRequest.java
│ ├─ AuthResponse.java
│ └─ RegisterRequest.java
├─ entity/
│ └─ User.java
├─ repository/
│ └─ UserRepository.java
└─ security/
├─ JwtUtil.java
├─ JwtFilter.java
└─ SecurityConfig.java
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" ... >
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>auth-jwt-springboot</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<spring-boot.version>2.7.9</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</artifactId>
<version>0.9.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties
spring.datasource.url=jdbc:h2:mem:authdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
jwt.secret=ChangeThisSecretKeyToAStrongOne
jwt.expiration-ms=900000 # 15 minutos
jwt.refresh-expiration-ms=604800000 # 7 días
server.port=8080
Archivos Java principales (código completo)
AuthApplication.java
package com.example.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}
entity/User.java
package com.example.auth.entity;
import javax.persistence.*;
@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;
@Column
private String role = "USER";
// Guardamos el refresh token actual (simple enfoque)
@Column(length = 1000)
private String refreshToken;
// getters y 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 String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
}
repository/UserRepository.java
package com.example.auth.repository;
import com.example.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}
security/JwtUtil.java
package com.example.auth.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration-ms}")
private long jwtExpirationMs;
public String generateAccessToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception ex) {
return false;
}
}
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return claims.getSubject();
}
}
security/JwtFilter.java
package com.example.auth.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
String username = null;
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
username = jwtUtil.getUsernameFromToken(token);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
security/SecurityConfig.java
package com.example.auth.security;
import com.example.auth.repository.UserRepository;
import com.example.auth.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.util.Collections;
@Configuration
public class SecurityConfig {
@Autowired
private JwtFilter jwtFilter;
@Autowired
private UserRepository userRepository;
@Bean
public UserDetailsService userDetailsService() {
return username -> {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(Collections.emptyList())
.build();
};
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
auth.authenticationProvider(authenticationProvider());
return auth.build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated();
// para H2 console
http.headers().frameOptions().sameOrigin();
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
dto/RegisterRequest.java
package com.example.auth.dto;
public class RegisterRequest {
private String username;
private String password;
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/AuthRequest.java
package com.example.auth.dto;
public class AuthRequest {
private String username;
private String password;
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.auth.dto;
public class AuthResponse {
private String accessToken;
private String refreshToken;
public AuthResponse(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
}
controller/AuthController.java
package com.example.auth.controller;
import com.example.auth.dto.AuthRequest;
import com.example.auth.dto.AuthResponse;
import com.example.auth.dto.RegisterRequest;
import com.example.auth.entity.User;
import com.example.auth.repository.UserRepository;
import com.example.auth.security.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@Value("${jwt.refresh-expiration-ms}")
private long refreshExpMs;
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
if (userRepository.findByUsername(req.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body("Usuario ya existe");
}
User u = new User();
u.setUsername(req.getUsername());
u.setPassword(passwordEncoder.encode(req.getPassword()));
userRepository.save(u);
return ResponseEntity.ok("Usuario creado");
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));
String accessToken = jwtUtil.generateAccessToken(req.getUsername());
// Refresh token simple: UUID (mejor usar token más seguro o JWT para refresh)
String refreshToken = UUID.randomUUID().toString();
Optional<User> optionalUser = userRepository.findByUsername(req.getUsername());
if (optionalUser.isPresent()) {
User u = optionalUser.get();
u.setRefreshToken(refreshToken);
userRepository.save(u);
}
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestParam String username, @RequestParam String refreshToken) {
Optional<User> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isEmpty()) return ResponseEntity.status(401).body("Usuario no encontrado");
User u = optionalUser.get();
if (u.getRefreshToken() == null || !u.getRefreshToken().equals(refreshToken)) {
return ResponseEntity.status(401).body("Refresh token inválido");
}
// Generamos nuevo access token y rotamos refresh token
String newAccessToken = jwtUtil.generateAccessToken(username);
String newRefreshToken = UUID.randomUUID().toString();
u.setRefreshToken(newRefreshToken);
userRepository.save(u);
return ResponseEntity.ok(new AuthResponse(newAccessToken, newRefreshToken));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestParam String username) {
Optional<User> optionalUser = userRepository.findByUsername(username);
if (optionalUser.isPresent()) {
User u = optionalUser.get();
u.setRefreshToken(null);
userRepository.save(u);
}
return ResponseEntity.ok("Logged out");
}
}
Por qué esta arquitectura y decisiones
- JWT como access token: compacto y apto para APIs stateless. Lo validamos en un filtro por cada petición.
- Refresh tokens guardados en BD por usuario: sencillo de implementar y permite revocación (se borra al logout).
- BCrypt para hashear contraseñas: estándar seguro para almacenamiento de contraseñas.
- Rotación de refresh token: cada vez que se refresca, se emite uno nuevo para reducir ventanas de vulnerabilidad.
- H2 en memoria para ejemplo; en producción usa una BD real y HTTPS.
Cómo ejecutar
- Clona/crea el proyecto con la estructura indicada.
- Configura application.properties (cambia jwt.secret por uno fuerte).
- Ejecuta: mvn spring-boot:run
- Endpoints principales:
- POST /auth/register {username, password}
- POST /auth/login {username, password} -> devuelve accessToken y refreshToken
- POST /auth/refresh?username=<u>&refreshToken=<rt> -> devuelve nuevos tokens
- POST /auth/logout?username=<u>
Mejoras y notas de seguridad (prácticas recomendadas)
- Siempre transmitir tokens por HTTPS.
- No almacenes refresh tokens en localStorage si usas frontend; prefiere HttpOnly cookies para mitigar XSS.
- Considera usar JWTs firmados con clave asimétrica (RS256) para mayor seguridad y facilidad de rotación de claves.
- Implementa detección de uso anómalo de refresh tokens, expiración y lista de revocación si necesitas más control.
- Limita intentos de login y añade logging/monitoring para detectar ataques de fuerza bruta.
Siguiente paso práctico: integra este servicio con un frontend (React/Vue) y maneja los tokens en HttpOnly cookies; después, reemplaza el almacenamiento de refresh tokens por una tabla dedicada que registre fecha de emisión, ip/ua y estado (revoked) para auditoría y revocación selectiva.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación