Construye un microservicio REST en Java con Spring Boot, JWT y Docker
Proyecto práctico: una API REST mínima en Java con Spring Boot que implementa autenticación mediante JWT, almacenamiento en H2 (fácil de cambiar a Postgres), pruebas unitarias básicas y empaquetado con Docker.
Requisitos previos
- JDK 17+
- Maven 3.6+
- Docker (opcional para ejecutar en contenedor)
- Conocimientos básicos de Spring Boot y JWT
Qué vamos a construir
- Registro de usuario (email + password)
- Login que devuelve JWT
- Endpoint protegido /api/profile que devuelve datos del usuario autenticado
- Configuración de seguridad con PasswordEncoder y filtro JWT
- Dockerfile para empaquetado
Estructura de carpetas
spring-jwt-demo
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com.example.demo
│ │ │ ├── DemoApplication.java
│ │ │ ├── config
│ │ │ │ └── SecurityConfig.java
│ │ │ ├── controller
│ │ │ │ └── AuthController.java
│ │ │ ├── dto
│ │ │ │ ├── AuthRequest.java
│ │ │ │ └── AuthResponse.java
│ │ │ ├── filter
│ │ │ │ └── JwtFilter.java
│ │ │ ├── model
│ │ │ │ └── User.java
│ │ │ ├── repository
│ │ │ │ └── UserRepository.java
│ │ │ └── util
│ │ │ └── JwtUtil.java
│ │ └── resources
│ │ └── application.properties
│ └── test
│ └── java
│ └── com.example.demo
│ └── AuthControllerTest.java
├── pom.xml
└── Dockerfile
pom.xml (resumen con dependencias clave)
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>spring-jwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.1.0</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>...spring-boot-maven-plugin...</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=ReplaceThisWithAStrongSecretKeyOfAtLeast32Chars
jwt.expirationMs=3600000
server.port=8080
Clases Java principales
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;
private String email;
private String password; // hashed
// getters y setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
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> findByEmail(String email);
}
dto/AuthRequest.java
package com.example.demo.dto;
public record AuthRequest(String email, String password) {}
dto/AuthResponse.java
package com.example.demo.dto;
public record AuthResponse(String token) {}
util/JwtUtil.java
package com.example.demo.util;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtUtil {
private final SecretKey key;
private final long expirationMs;
public JwtUtil(@Value("${jwt.secret}") String secret, @Value("${jwt.expirationMs}") long expirationMs) {
// Usamos la librería jjwt; convertir la clave en bytes
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.expirationMs = expirationMs;
}
public String generateToken(String subject) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String extractSubject(String token) {
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
filter/JwtFilter.java
package com.example.demo.filter;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.JwtUtil;
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.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
public JwtFilter(JwtUtil jwtUtil, UserRepository userRepository) {
this.jwtUtil = jwtUtil;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
final String token = authHeader.substring(7);
try {
String email = jwtUtil.extractSubject(token);
// Cargar usuario (simple): si existe, crear Authentication
var opt = userRepository.findByEmail(email);
if (opt.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = new User(opt.get().getEmail(), opt.get().getPassword(), new ArrayList<>());
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (Exception e) {
// token inválido: no autenticamos
}
}
filterChain.doFilter(request, response);
}
}
config/SecurityConfig.java
package com.example.demo.config;
import com.example.demo.filter.JwtFilter;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
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 securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable(); // para H2 console
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
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.User;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
}
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody AuthRequest req) {
if (userRepository.findByEmail(req.email()).isPresent()) {
return ResponseEntity.badRequest().body("Email already in use");
}
User u = new User();
u.setEmail(req.email());
u.setPassword(passwordEncoder.encode(req.password()));
userRepository.save(u);
return ResponseEntity.ok("User registered");
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest req) {
return userRepository.findByEmail(req.email())
.filter(u -> passwordEncoder.matches(req.password(), u.getPassword()))
.map(u -> ResponseEntity.ok(new AuthResponse(jwtUtil.generateToken(u.getEmail()))))
.orElseGet(() -> ResponseEntity.status(401).build());
}
@GetMapping("/profile")
public ResponseEntity<String> profile(org.springframework.security.core.Authentication authentication) {
return ResponseEntity.ok("Hello " + authentication.getName());
}
}
Prueba básica: AuthControllerTest.java
package com.example.demo;
import com.example.demo.dto.AuthRequest;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
public class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Test
void registerAndLogin() throws Exception {
String json = "{\"email\":\"t@t.com\",\"password\":\"123456\"}";
mockMvc.perform(post("/auth/register").contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isOk());
mockMvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isOk());
}
}
Dockerfile
FROM eclipse-temurin:17-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
Cómo ejecutar
- Construye: mvn clean package -DskipTests
- Ejecuta local: java -jar target/spring-jwt-demo-0.0.1-SNAPSHOT.jar
- O con Docker:
docker build -t spring-jwt-demo . docker run -p 8080:8080 spring-jwt-demo - Endpoints útiles:
- POST /auth/register {"email":"a@b.com","password":"123456"}
- POST /auth/login → {"token":"..."}
- GET /auth/profile con header Authorization: Bearer <token>
Por qué estas decisiones (breve)
- JWT stateless: evita sesiones de servidor; bueno para microservicios y escalado horizontal.
- BCrypt para contraseñas: resistencia a ataques de fuerza bruta y salt incorporado.
- OncePerRequestFilter para validar token en cada petición: simple y compatible con Spring Security moderno.
- H2 en memoria: para desarrollo rápido; cambia a Postgres ajustando datasource.
- Se permite /auth/** sin seguridad; todo lo demás requiere autenticación.
Mejoras y consideraciones de seguridad
- Almacena jwt.secret en un gestor de secretos (Vault, AWS Secrets Manager) y no en properties.
- Implementa refresh tokens para sesiones más seguras y revocación.
- Valida claims del token (issuer, audience) según tu arquitectura.
- Considera usar JWKs y rotación de claves si expones muchos microservicios.
Siguiente paso sugerido: añade roles y autorización por endpoints (hasRole/hasAuthority), y separa la lógica de usuario en un UserDetailsService para integrarlo con el mecanismo de AuthenticationManager —esto facilita usar autenticación basada en base de datos, LDAP o SSO en el futuro.
Advertencia de seguridad: no uses claves JWT cortas ni las incluyas en repositorios. Rotar claves y soportar revocación son esenciales en producción.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación