Construye un microservicio REST en Java con Spring Boot, JWT y Docker (paso a paso)

java Construye un microservicio REST en Java con Spring Boot, JWT y Docker (paso a paso)

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

  1. Construye: mvn clean package -DskipTests
  2. Ejecuta local: java -jar target/spring-jwt-demo-0.0.1-SNAPSHOT.jar
  3. O con Docker:
    docker build -t spring-jwt-demo .
    docker run -p 8080:8080 spring-jwt-demo
  4. 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión