Proyecto: API REST con Spring Boot y JWT (access + refresh tokens)
En este tutorial crearás una API REST en Java usando Spring Boot que implementa autenticación con JWT (access token) y refresh tokens almacenados en base de datos. Incluye registro, login, refresh y un endpoint protegido. Código completo y explicaciones de por qué se eligen ciertas decisiones.
Requisitos previos
- Java 17+
- Maven
- IDE (IntelliJ, VSCode, Eclipse)
- Conocimientos básicos de Spring Boot y JPA
Estructura de carpetas
src/main/java/com/example/jwtproject
├── JwtProjectApplication.java
├── config
│ ├── SecurityConfig.java
│ ├── JwtFilter.java
│ └── JwtUtil.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── AuthRequest.java
│ └── AuthResponse.java
├── model
│ ├── User.java
│ └── RefreshToken.java
├── repository
│ ├── UserRepository.java
│ └── RefreshTokenRepository.java
└── service
└── AuthService.java
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jwtproject</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
</parent>
<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>
</dependencies>
</project>
application.properties (mínimo)
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
jwt.secret=ChangeThisVerySecretKeyForDemoOnly
jwt.access.expiration=900000 # 15 minutes in ms
jwt.refresh.expiration=604800000 # 7 days in ms
Clase principal
package com.example.jwtproject;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtProjectApplication {
public static void main(String[] args) {
SpringApplication.run(JwtProjectApplication.class, args);
}
}
Modelo: User
package com.example.jwtproject.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; // BCrypt
// getters/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; }
}
Modelo: RefreshToken
package com.example.jwtproject.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;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
private Instant expiryDate;
// getters/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; }
}
Repositorios
package com.example.jwtproject.repository;
import com.example.jwtproject.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository {
Optional<User> findByUsername(String username);
}
package com.example.jwtproject.repository;
import com.example.jwtproject.model.RefreshToken;
import com.example.jwtproject.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);
}
DTOs
package com.example.jwtproject.dto;
public record AuthRequest(String username, String password) {}
package com.example.jwtproject.dto;
public record AuthResponse(String accessToken, String refreshToken) {}
JwtUtil (gestiona creación y validación)
package com.example.jwtproject.config;
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;
@Component
public class JwtUtil {
private final Key key;
private final long accessExpirationMs;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.access.expiration}") long accessExpirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.accessExpirationMs = accessExpirationMs;
}
public String generateAccessToken(String username) {
Date now = new Date();
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessExpirationMs))
.signWith(key)
.compact();
}
public String getUsernameFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody().getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
JwtFilter (aplica el token a la seguridad de Spring)
package com.example.jwtproject.config;
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.User;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@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);
UserDetails userDetails = User.withUsername(username).password("").authorities(Collections.emptyList()).build();
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
SecurityConfig
package com.example.jwtproject.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
private final JwtFilter jwtFilter;
public SecurityConfig(JwtFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
AuthService
package com.example.jwtproject.service;
import com.example.jwtproject.config.JwtUtil;
import com.example.jwtproject.dto.AuthRequest;
import com.example.jwtproject.dto.AuthResponse;
import com.example.jwtproject.model.RefreshToken;
import com.example.jwtproject.model.User;
import com.example.jwtproject.repository.RefreshTokenRepository;
import com.example.jwtproject.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.Optional;
import java.util.UUID;
@Service
public class AuthService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final long refreshTokenDurationMs;
public AuthService(UserRepository userRepository,
RefreshTokenRepository refreshTokenRepository,
BCryptPasswordEncoder passwordEncoder,
JwtUtil jwtUtil,
@Value("${jwt.refresh.expiration}") long refreshTokenDurationMs) {
this.userRepository = userRepository;
this.refreshTokenRepository = refreshTokenRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
this.refreshTokenDurationMs = refreshTokenDurationMs;
}
public void register(AuthRequest req) {
if (userRepository.findByUsername(req.username()).isPresent()) {
throw new RuntimeException("Username already taken");
}
User u = new User();
u.setUsername(req.username());
u.setPassword(passwordEncoder.encode(req.password()));
userRepository.save(u);
}
public AuthResponse login(AuthRequest req) {
User user = userRepository.findByUsername(req.username())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
String accessToken = jwtUtil.generateAccessToken(user.getUsername());
RefreshToken refreshToken = createRefreshToken(user);
return new AuthResponse(accessToken, refreshToken.getToken());
}
private RefreshToken createRefreshToken(User user) {
// remove existing
refreshTokenRepository.deleteByUser(user);
RefreshToken rt = new RefreshToken();
rt.setUser(user);
rt.setToken(UUID.randomUUID().toString());
rt.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
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 accessToken = jwtUtil.generateAccessToken(rt.getUser().getUsername());
// issue new refresh token (rotating)
refreshTokenRepository.delete(rt);
RefreshToken newRt = createRefreshToken(rt.getUser());
return new AuthResponse(accessToken, newRt.getToken());
}
public void logout(String username) {
Optional<User> u = userRepository.findByUsername(username);
u.ifPresent(user -> refreshTokenRepository.deleteByUser(user));
}
}
AuthController
package com.example.jwtproject.controller;
import com.example.jwtproject.dto.AuthRequest;
import com.example.jwtproject.dto.AuthResponse;
import com.example.jwtproject.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) { this.authService = authService; }
@PostMapping("/register")
public ResponseEntity<Void> register(@RequestBody AuthRequest req) {
authService.register(req);
return ResponseEntity.ok().build();
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
return ResponseEntity.ok(authService.login(req));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody String refreshToken) {
// expect raw token in body; in prod, wrap in JSON
return ResponseEntity.ok(authService.refreshToken(refreshToken.replaceAll("\"", "")));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestParam String username) {
authService.logout(username);
return ResponseEntity.ok().build();
}
}
UserController (endpoint protegido)
package com.example.jwtproject.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
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 UserController {
@GetMapping("/me")
public String me(@AuthenticationPrincipal UserDetails ud) {
return "Hello, " + ud.getUsername();
}
}
Por qué estas decisiones
- H2 para demo: arranque rápido sin infra adicional.
- BCrypt para almacenar contraseñas: estándar fuerte y adaptativo.
- Access token corto (15m) y refresh token persistente: minimiza exposición y permite renovar sin re-login.
- Refresh tokens en DB: permite revocación y rotación (eliminamos el antiguo al emitir uno nuevo).
- JwtFilter simple: no cargamos UserDetails desde DB por cada petición (trade-off). En APIs más completas cargarías roles/claims.
Cómo probar
- Levanta la app: mvn spring-boot:run
- POST /auth/register Body: {"username":"alice","password":"pass"}
- POST /auth/login Body: {"username":"alice","password":"pass"} — recibirás accessToken y refreshToken
- GET /api/me con header Authorization: Bearer <accessToken>
- Cuando caduque accessToken, POST /auth/refresh con body el refreshToken (raw string o JSON según implementación) para obtener tokens nuevos.
Mejoras y advertencias de seguridad
- En producción, usa una clave JWT de al menos 256 bits guardada en un vault (no en properties).
- Considera incluir jti y aud/iss claims, y una lista de revocación si necesitas forzar logout de tokens activos.
- Para mayor seguridad, haz que los refresh tokens sean httpOnly cookies y protege endpoints contra CSRF si despliegas en navegadores.
- Mantén la rotación de refresh tokens y monitorea intentos de uso repetido (indicador de robo de token).
Siguiente paso: integra roles y scopes, y reemplaza el filtro que solo crea una autenticación básica por un UserDetailsService que cargue roles desde la base de datos para autorización basada en roles/permiso.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación