API de autenticación con JWT y refresh tokens en Spring Boot (Java 17)
Proyecto práctico: crea una API en Java con Spring Boot que implemente registro/login con JWT de acceso y refresh tokens guardados en BD. Incluye endpoints: /api/auth/register, /api/auth/login, /api/auth/refresh, /api/auth/logout y un endpoint protegido /api/secure.
Requisitos previos
- Java 17+
- Maven
- Conocimientos básicos de Spring Boot, Spring Security y JPA
Estructura de carpetas
demo-jwt-refresh/
├── pom.xml
└── src/main/java/com/example/demo/
├── DemoApplication.java
├── controller/
│ ├── AuthController.java
│ └── TestController.java
├── dto/
│ ├── AuthRequest.java
│ ├── AuthResponse.java
│ ├── RegisterRequest.java
│ └── RefreshRequest.java
├── model/
│ ├── User.java
│ └── RefreshToken.java
├── repository/
│ ├── UserRepository.java
│ └── RefreshTokenRepository.java
├── security/
│ ├── JwtUtil.java
│ ├── JwtAuthenticationFilter.java
│ └── SecurityConfig.java
└── service/
└── AuthService.java
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo-jwt-refresh</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
&nbs p;<properties>
<java.version>17</java.version>
<spring.boot.version>3.1.4</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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</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.properties
spring.datasource.url=jdbc:h2:mem:testdb
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=ChangemeReplaceWithAStrongSecretKey
jwt.expiration-ms=600000 # 10 min
jwt.refresh-expiration-ms=1209600000 # 14 days
server.port=8080
Archivos Java principales (código completo)
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/User.java
package com.example.demo.model;
import jakarta.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;
// 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; }
}
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;
@OneToOne
private User user;
private Instant expiryDate;
// getters y 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 User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public Instant getExpiryDate() { return expiryDate; }
public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
}
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<User> 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);
void deleteByUser(User user);
}
dto/RegisterRequest.java
package com.example.demo.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.demo.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.demo.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; }
}
dto/RefreshRequest.java
package com.example.demo.dto;
public class RefreshRequest {
private String refreshToken;
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
}
security/JwtUtil.java
package com.example.demo.security;
import io.jsonwebtoken.*;
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 jwtSecret;
@Value("${jwt.expiration-ms}")
private long jwtExpirationMs;
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(SignatureAlgorithm.HS256, jwtSecret)
.compact();
}
public String getUsernameFromToken(String token) {
return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
security/JwtAuthenticationFilter.java
package com.example.demo.security;
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.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String header = request.getHeader("Authorization");
String username = null;
String token = null;
if (header != null && header.startsWith("Bearer ")) {
token = header.substring(7);
if (jwtUtil.validateToken(token)) {
username = jwtUtil.getUsernameFromToken(token);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
}
security/SecurityConfig.java
package com.example.demo.security;
import com.example.demo.repository.UserRepository;
import com.example.demo.model.User;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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;
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserRepository userRepository;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, UserRepository userRepository) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userRepository = userRepository;
}
@Bean
public UserDetailsService userDetailsService() {
return username -> {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities("USER")
.build();
};
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers.frameOptions(frame -> frame.disable())); // para H2 console
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
service/AuthService.java
package com.example.demo.service;
import com.example.demo.dto.AuthRequest;
import com.example.demo.dto.AuthResponse;
import com.example.demo.dto.RegisterRequest;
import com.example.demo.model.RefreshToken;
import com.example.demo.model.User;
import com.example.demo.repository.RefreshTokenRepository;
import com.example.demo.repository.UserRepository;
import com.example.demo.security.JwtUtil;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class AuthService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@Value("${jwt.refresh-expiration-ms}")
private long refreshExpirationMs;
public AuthService(UserRepository userRepository, RefreshTokenRepository refreshTokenRepository,
PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.refreshTokenRepository = refreshTokenRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
public void register(RegisterRequest req) {
if (userRepository.existsByUsername(req.getUsername())) {
throw new RuntimeException("Username already taken");
}
User u = new User();
u.setUsername(req.getUsername());
u.setPassword(passwordEncoder.encode(req.getPassword()));
userRepository.save(u);
}
@Transactional
public AuthResponse login(AuthRequest req) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
);
String access = jwtUtil.generateToken(req.getUsername());
RefreshToken refreshToken = createRefreshToken(req.getUsername());
return new AuthResponse(access, refreshToken.getToken());
}
private RefreshToken createRefreshToken(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
// eliminar token existente para este usuario (opcional)
refreshTokenRepository.deleteByUser(user);
RefreshToken rt = new RefreshToken();
rt.setUser(user);
rt.setToken(UUID.randomUUID().toString());
rt.setExpiryDate(Instant.now().plusMillis(refreshExpirationMs));
return refreshTokenRepository.save(rt);
}
public AuthResponse refreshToken(String token) {
RefreshToken rt = refreshTokenRepository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Refresh token not found"));
if (rt.getExpiryDate().isBefore(Instant.now())) {
refreshTokenRepository.delete(rt);
throw new RuntimeException("Refresh token expired");
}
String access = jwtUtil.generateToken(rt.getUser().getUsername());
// opcional: rotar refresh token
RefreshToken newRt = createRefreshToken(rt.getUser().getUsername());
refreshTokenRepository.delete(rt);
return new AuthResponse(access, newRt.getToken());
}
public void logout(String username) {
User user = userRepository.findByUsername(username).orElseThrow();
refreshTokenRepository.deleteByUser(user);
}
}
controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.*;
import com.example.demo.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
authService.register(req);
return ResponseEntity.ok().build();
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
AuthResponse resp = authService.login(req);
return ResponseEntity.ok(resp);
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
AuthResponse resp = authService.refreshToken(req.getRefreshToken());
return ResponseEntity.ok(resp);
}
@PostMapping("/logout")
public ResponseEntity<?> logout(Authentication authentication) {
if (authentication != null) {
authService.logout(authentication.getName());
}
return ResponseEntity.ok().build();
}
}
controller/TestController.java (endpoint protegido)
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class TestController {
@GetMapping("/secure")
public String secure() {
return "Acceso autorizado";
}
}
Por qué estas decisiones
- JWT para token de acceso: permite validación sin consultar BD en cada request y es rápido.
- Refresh tokens guardados en la BD: permiten invalidación de sesión (logout) y rotación segura.
- BCrypt para hashing de contraseñas: estándar seguro resistente a ataques de fuerza bruta.
- Filtro OncePerRequest para validar JWT: integra bien con Spring Security y mantiene la autenticación en el contexto.
- H2 en memoria para desarrollo: facilita pruebas locales. Para producción usa PostgreSQL/MySQL y secretos gestionados.
Probando la API
- Inicia la app: mvn spring-boot:run
- Registrar: POST /api/auth/register {"username":"user","password":"pass"}
- Login: POST /api/auth/login {"username":"user","password":"pass"} => devuelve accessToken y refreshToken
- Llamar endpoint protegido: GET /api/secure con header Authorization: Bearer <accessToken>
- Refrescar token: POST /api/auth/refresh {"refreshToken":"..."} => nuevo accessToken (+ nuevo refreshToken)
- Logout: POST /api/auth/logout (con access token) => elimina refresh token en BD
Consideraciones de seguridad y próximos pasos
- No uses el secreto en application.properties en producción; gestiona secretos con un vault y cámbialo regularmente.
- Incrementa la longitud y aleatoriedad del refresh token si lo almacenas en BD; considerar tokens firmados además del UUID.
- Implementa limitación de intentos (rate limiting) y monitorización de accesos fallidos para prevenir ataques de fuerza bruta.
- Para escalado, considera almacenar refresh tokens en una BD compartida o usar Redis, y la invalidación coordinada.
Siguiente paso: añade scopes/roles más finos y autorización basada en métodos (annotations @PreAuthorize) o implementa Single Sign-On con OAuth2 si necesitas integración con terceros.
Advertencia de seguridad: siempre valida y escapa entradas, usa HTTPS en producción y rota claves periódicamente.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación