Cristhian Villegas
Cursos15 min read1 views

Curso JWT en Spring Boot 3 #1: Fundamentos y Configuración Inicial

Curso JWT en Spring Boot 3 #1: Fundamentos y Configuración Inicial

Bienvenido al Curso — Parte 1 de 4

Este es el primer artículo de una serie de cuatro partes donde construiremos un sistema de autenticación completo con JWT (JSON Web Tokens) en Spring Boot 3. Al finalizar el curso tendrás una API REST segura, lista para producción, con las mejores prácticas de Spring Security.

¿Qué aprenderás en todo el curso?

  • Parte 1 (este artículo): Fundamentos de JWT, configuración del proyecto, servicio JWT, filtro de autenticación y endpoints básicos.

  • Parte 2: Refresh tokens — rotación segura, almacenamiento en base de datos y flujo de renovación automática.

  • Parte 3: Roles y permisos — autorización basada en roles (ROLE_ADMIN, ROLE_USER), anotaciones @PreAuthorize y control de acceso granular.

  • Parte 4: Seguridad avanzada — blacklisting de tokens con Redis, protección contra ataques comunes (CSRF, XSS, replay) y despliegue seguro.

Prerrequisitos

  • Java 17+ instalado (recomendado Java 21 LTS)

  • Spring Boot 3.2+ (usaremos 3.4.x en los ejemplos)

  • Maven como gestor de dependencias

  • Conocimientos básicos de Spring Boot y Spring MVC

  • Un IDE como IntelliJ IDEA, VS Code con extensiones Java, o Eclipse

Código fuente: Todo el código de este curso está disponible y es completamente funcional. Cada clase que mostramos es compilable y puedes seguir el tutorial paso a paso para construir tu propio proyecto desde cero.

¿Qué es JWT?

Un JSON Web Token (JWT) es un estándar abierto (RFC 7519) que define un formato compacto y autocontenido para transmitir información de forma segura entre dos partes como un objeto JSON. Esta información puede ser verificada y confiable porque está firmada digitalmente.

Los JWT se pueden firmar usando un secreto (con el algoritmo HMAC) o un par de claves pública/privada usando RSA o ECDSA. En este curso usaremos HMAC-SHA256 por su simplicidad, aunque en entornos con microservicios se recomienda RSA.

¿Por qué usar JWT para autenticación?

  • Stateless: El servidor no necesita almacenar sesiones. Toda la información del usuario viaja dentro del token.

  • Escalable: Al no depender de estado en el servidor, puedes escalar horizontalmente sin compartir sesiones entre instancias.

  • Cross-domain: Los JWT funcionan perfectamente en arquitecturas con múltiples dominios, microservicios y aplicaciones móviles.

  • Autocontenido: El token contiene toda la información necesaria sobre el usuario, eliminando la necesidad de consultar la base de datos en cada petición.

Importante: Un JWT no está cifrado por defecto — está firmado. Cualquiera puede decodificar el payload en jwt.io. Nunca incluyas información sensible (contraseñas, números de tarjeta) en el payload de un JWT.

Estructura de un JWT

Un JWT consta de tres partes separadas por puntos (.):

text
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGVtYWlsLmNvbSIsImlhdCI6MTcxMjAwMDAwMCwiZXhwIjoxNzEyMDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Cada parte tiene un propósito específico:

1. Header (Encabezado)

Contiene el tipo de token y el algoritmo de firma. Se codifica en Base64Url:

json
1{
2  "alg": "HS256",
3  "typ": "JWT"
4}

2. Payload (Carga útil)

Contiene los claims — las declaraciones sobre el usuario y metadatos adicionales. Existen tres tipos de claims:

  • Registered claims: Predefinidos por el estándar — sub (subject), iat (issued at), exp (expiration), iss (issuer).

  • Public claims: Definidos libremente, pero deben registrarse en el registro IANA para evitar colisiones.

  • Private claims: Claims personalizados acordados entre las partes — por ejemplo, roles, userId.

json
1{
2  "sub": "[email protected]",
3  "iat": 1712000000,
4  "exp": 1712003600,
5  "roles": ["ROLE_USER"]
6}

3. Signature (Firma)

Se genera tomando el header y payload codificados, y firmándolos con el algoritmo especificado y una clave secreta:

text
1HMACSHA256(
2  base64UrlEncode(header) + "." + base64UrlEncode(payload),
3  secret
4)

La firma garantiza que el token no ha sido alterado. Si alguien modifica el payload, la firma no coincidirá y el servidor rechazará el token.

JWT vs Sesiones Tradicionales

Antes de JWT, la autenticación web se manejaba con sesiones del lado del servidor. Ambos enfoques tienen ventajas y desventajas que debes conocer para elegir el adecuado:

Característica

Sesiones (Stateful)

JWT (Stateless)

Almacenamiento

Servidor (memoria/Redis)

Cliente (localStorage/cookie)

Escalabilidad

Requiere sesiones compartidas (sticky sessions o Redis)

Escala horizontalmente sin configuración adicional

Revocación

Inmediata — eliminas la sesión del servidor

Compleja — el token es válido hasta que expire (necesitas blacklist)

Rendimiento

Consulta al almacén de sesiones en cada request

Validación criptográfica local, sin I/O

Cross-domain

Difícil — cookies están limitadas al dominio

Fácil — se envía en el header Authorization

Tamaño

ID de sesión pequeño (~32 bytes)

Token más grande (~800+ bytes con claims)

Microservicios

Necesita servicio centralizado de sesiones

Cada servicio valida el token independientemente

¿Cuándo usar cada uno? Usa sesiones cuando necesites revocación inmediata y tengas un monolito. Usa JWT cuando tengas microservicios, APIs públicas, o aplicaciones móviles como consumidores.

Configuración del Proyecto

Vamos a crear el proyecto desde cero usando Spring Initializr. Necesitamos las siguientes dependencias:

  • Spring Web — para crear endpoints REST

  • Spring Security — para el framework de seguridad

  • Spring Data JPA — para la capa de persistencia

  • H2 Database — base de datos en memoria para desarrollo

  • JJWT — la librería para generar y validar JWT (versión 0.12.x)

  • Lombok — para reducir boilerplate (opcional)

Agrega las siguientes dependencias a tu pom.xml:

xml
1<?xml version="1.0" encoding="UTF-8"?>
2<project xmlns="http://maven.apache.org/POM/4.0.0"
3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
5         https://maven.apache.org/xsd/maven-4.0.0.xsd">
6    <modelVersion>4.0.0</modelVersion>
7
8    <parent>
9        <groupId>org.springframework.boot</groupId>
10        <artifactId>spring-boot-starter-parent</artifactId>
11        <version>3.4.1</version>
12        <relativePath/>
13    </parent>
14
15    <groupId>com.example</groupId>
16    <artifactId>jwt-security-demo</artifactId>
17    <version>1.0.0</version>
18    <name>jwt-security-demo</name>
19    <description>JWT Authentication with Spring Boot 3</description>
20
21    <properties>
22        <java.version>17</java.version>
23        <jjwt.version>0.12.6</jjwt.version>
24    </properties>
25
26    <dependencies>
27        <!-- Spring Boot Starters -->
28        <dependency>
29            <groupId>org.springframework.boot</groupId>
30            <artifactId>spring-boot-starter-web</artifactId>
31        </dependency>
32        <dependency>
33            <groupId>org.springframework.boot</groupId>
34            <artifactId>spring-boot-starter-security</artifactId>
35        </dependency>
36        <dependency>
37            <groupId>org.springframework.boot</groupId>
38            <artifactId>spring-boot-starter-data-jpa</artifactId>
39        </dependency>
40        <dependency>
41            <groupId>org.springframework.boot</groupId>
42            <artifactId>spring-boot-starter-validation</artifactId>
43        </dependency>
44
45        <!-- H2 Database -->
46        <dependency>
47            <groupId>com.h2database</groupId>
48            <artifactId>h2</artifactId>
49            <scope>runtime</scope>
50        </dependency>
51
52        <!-- JJWT (JSON Web Token) -->
53        <dependency>
54            <groupId>io.jsonwebtoken</groupId>
55            <artifactId>jjwt-api</artifactId>
56            <version>${jjwt.version}</version>
57        </dependency>
58        <dependency>
59            <groupId>io.jsonwebtoken</groupId>
60            <artifactId>jjwt-impl</artifactId>
61            <version>${jjwt.version}</version>
62            <scope>runtime</scope>
63        </dependency>
64        <dependency>
65            <groupId>io.jsonwebtoken</groupId>
66            <artifactId>jjwt-jackson</artifactId>
67            <version>${jjwt.version}</version>
68            <scope>runtime</scope>
69        </dependency>
70
71        <!-- Lombok (opcional) -->
72        <dependency>
73            <groupId>org.projectlombok</groupId>
74            <artifactId>lombok</artifactId>
75            <optional>true</optional>
76        </dependency>
77
78        <!-- Test -->
79        <dependency>
80            <groupId>org.springframework.boot</groupId>
81            <artifactId>spring-boot-starter-test</artifactId>
82            <scope>test</scope>
83        </dependency>
84        <dependency>
85            <groupId>org.springframework.security</groupId>
86            <artifactId>spring-security-test</artifactId>
87            <scope>test</scope>
88        </dependency>
89    </dependencies>
90
91    <build>
92        <plugins>
93            <plugin>
94                <groupId>org.springframework.boot</groupId>
95                <artifactId>spring-boot-maven-plugin</artifactId>
96                <configuration>
97                    <excludes>
98                        <exclude>
99                            <groupId>org.projectlombok</groupId>
100                            <artifactId>lombok</artifactId>
101                        </exclude>
102                    </excludes>
103                </configuration>
104            </plugin>
105        </plugins>
106    </build>
107</project>

Agrega la configuración de JWT en tu application.yml:

yaml
1spring:
2  datasource:
3    url: jdbc:h2:mem:jwtdb
4    driver-class-name: org.h2.Driver
5    username: sa
6    password:
7  jpa:
8    hibernate:
9      ddl-auto: create-drop
10    show-sql: true
11  h2:
12    console:
13      enabled: true
14
15jwt:
16  secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
17  expiration: 3600000  # 1 hora en milisegundos

¡No hardcodees el secreto! En este tutorial usamos un valor fijo para simplificar, pero en producción debes usar una variable de entorno o un servicio de gestión de secretos (AWS Secrets Manager, HashiCorp Vault). El secreto debe tener al menos 256 bits (32 bytes) para HMAC-SHA256.

Creando el Servicio JWT

El JwtService es el corazón de nuestra autenticación. Se encarga de generar tokens, validarlos y extraer la información del usuario. Usaremos la API de JJWT 0.12.x, que introdujo cambios significativos respecto a versiones anteriores.

java
1package com.example.jwtsecuritydemo.service;
2
3import io.jsonwebtoken.Claims;
4import io.jsonwebtoken.Jwts;
5import io.jsonwebtoken.io.Decoders;
6import io.jsonwebtoken.security.Keys;
7import org.springframework.beans.factory.annotation.Value;
8import org.springframework.security.core.userdetails.UserDetails;
9import org.springframework.stereotype.Service;
10
11import javax.crypto.SecretKey;
12import java.util.Date;
13import java.util.HashMap;
14import java.util.Map;
15import java.util.function.Function;
16
17@Service
18public class JwtService {
19
20    @Value("${jwt.secret}")
21    private String secretKey;
22
23    @Value("${jwt.expiration}")
24    private long jwtExpiration;
25
26    /**
27     * Extrae el username (subject) del token.
28     */
29    public String extractUsername(String token) {
30        return extractClaim(token, Claims::getSubject);
31    }
32
33    /**
34     * Extrae un claim específico del token usando un resolver.
35     */
36    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
37        final Claims claims = extractAllClaims(token);
38        return claimsResolver.apply(claims);
39    }
40
41    /**
42     * Genera un token JWT para el usuario dado.
43     */
44    public String generateToken(UserDetails userDetails) {
45        return generateToken(new HashMap<>(), userDetails);
46    }
47
48    /**
49     * Genera un token JWT con claims adicionales.
50     */
51    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
52        return buildToken(extraClaims, userDetails, jwtExpiration);
53    }
54
55    /**
56     * Valida que el token pertenezca al usuario y no haya expirado.
57     */
58    public boolean isTokenValid(String token, UserDetails userDetails) {
59        final String username = extractUsername(token);
60        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
61    }
62
63    /**
64     * Construye el token JWT con los parámetros dados.
65     * Usa la API de JJWT 0.12.x con el builder actualizado.
66     */
67    private String buildToken(
68            Map<String, Object> extraClaims,
69            UserDetails userDetails,
70            long expiration
71    ) {
72        return Jwts.builder()
73                .claims(extraClaims)
74                .subject(userDetails.getUsername())
75                .issuedAt(new Date(System.currentTimeMillis()))
76                .expiration(new Date(System.currentTimeMillis() + expiration))
77                .signWith(getSigningKey())
78                .compact();
79    }
80
81    /**
82     * Verifica si el token ha expirado.
83     */
84    private boolean isTokenExpired(String token) {
85        return extractExpiration(token).before(new Date());
86    }
87
88    /**
89     * Extrae la fecha de expiración del token.
90     */
91    private Date extractExpiration(String token) {
92        return extractClaim(token, Claims::getExpiration);
93    }
94
95    /**
96     * Extrae todos los claims del token.
97     * Usa el parser actualizado de JJWT 0.12.x.
98     */
99    private Claims extractAllClaims(String token) {
100        return Jwts.parser()
101                .verifyWith(getSigningKey())
102                .build()
103                .parseSignedClaims(token)
104                .getPayload();
105    }
106
107    /**
108     * Construye la clave de firma a partir del secreto en Base64.
109     */
110    private SecretKey getSigningKey() {
111        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
112        return Keys.hmacShaKeyFor(keyBytes);
113    }
114}

JJWT 0.12.x vs versiones anteriores: Si has usado JJWT 0.11.x, notarás cambios importantes. El método setSubject() ahora es subject(), setIssuedAt() es issuedAt(), setExpiration() es expiration(), y signWith(key, algorithm) ahora infiere el algoritmo automáticamente con signWith(key). El parser usa Jwts.parser().verifyWith(key) en lugar de Jwts.parserBuilder().setSigningKey(key).

Implementando el Filtro JWT

El filtro JWT intercepta cada petición HTTP, extrae el token del header Authorization, lo valida y establece la autenticación en el SecurityContext de Spring. Extiende OncePerRequestFilter para garantizar que se ejecuta una sola vez por petición.

java
1package com.example.jwtsecuritydemo.filter;
2
3import com.example.jwtsecuritydemo.service.JwtService;
4import jakarta.servlet.FilterChain;
5import jakarta.servlet.ServletException;
6import jakarta.servlet.http.HttpServletRequest;
7import jakarta.servlet.http.HttpServletResponse;
8import org.springframework.lang.NonNull;
9import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10import org.springframework.security.core.context.SecurityContextHolder;
11import org.springframework.security.core.userdetails.UserDetails;
12import org.springframework.security.core.userdetails.UserDetailsService;
13import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
14import org.springframework.stereotype.Component;
15import org.springframework.web.filter.OncePerRequestFilter;
16
17import java.io.IOException;
18
19@Component
20public class JwtAuthenticationFilter extends OncePerRequestFilter {
21
22    private final JwtService jwtService;
23    private final UserDetailsService userDetailsService;
24
25    public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
26        this.jwtService = jwtService;
27        this.userDetailsService = userDetailsService;
28    }
29
30    @Override
31    protected void doFilterInternal(
32            @NonNull HttpServletRequest request,
33            @NonNull HttpServletResponse response,
34            @NonNull FilterChain filterChain
35    ) throws ServletException, IOException {
36
37        // 1. Extraer el header Authorization
38        final String authHeader = request.getHeader("Authorization");
39
40        // 2. Si no hay header o no empieza con "Bearer ", continuar la cadena
41        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
42            filterChain.doFilter(request, response);
43            return;
44        }
45
46        // 3. Extraer el token (quitar "Bearer ")
47        final String jwt = authHeader.substring(7);
48        final String userEmail = jwtService.extractUsername(jwt);
49
50        // 4. Si tenemos username y no hay autenticación previa en el contexto
51        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
52
53            // 5. Cargar el usuario desde la base de datos
54            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
55
56            // 6. Validar el token contra los datos del usuario
57            if (jwtService.isTokenValid(jwt, userDetails)) {
58
59                // 7. Crear el objeto de autenticación
60                UsernamePasswordAuthenticationToken authToken =
61                        new UsernamePasswordAuthenticationToken(
62                                userDetails,
63                                null,
64                                userDetails.getAuthorities()
65                        );
66
67                // 8. Agregar detalles de la petición
68                authToken.setDetails(
69                        new WebAuthenticationDetailsSource().buildDetails(request)
70                );
71
72                // 9. Establecer la autenticación en el SecurityContext
73                SecurityContextHolder.getContext().setAuthentication(authToken);
74            }
75        }
76
77        // 10. Continuar la cadena de filtros
78        filterChain.doFilter(request, response);
79    }
80}

El flujo del filtro es el siguiente:

  1. La petición llega al filtro antes de alcanzar cualquier controller.

  2. Se busca el header Authorization con formato Bearer <token>.

  3. Se extrae el username del token.

  4. Se carga el usuario de la base de datos y se valida que el token sea correcto y no haya expirado.

  5. Si todo es válido, se establece la autenticación en el SecurityContext, permitiendo el acceso a los endpoints protegidos.

Configuración de Spring Security

La clase de configuración de seguridad define qué endpoints son públicos, cuáles requieren autenticación, y cómo se integra nuestro filtro JWT en la cadena de seguridad de Spring.

java
1package com.example.jwtsecuritydemo.config;
2
3import com.example.jwtsecuritydemo.filter.JwtAuthenticationFilter;
4import org.springframework.context.annotation.Bean;
5import org.springframework.context.annotation.Configuration;
6import org.springframework.security.authentication.AuthenticationManager;
7import org.springframework.security.authentication.AuthenticationProvider;
8import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
9import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
10import org.springframework.security.config.annotation.web.builders.HttpSecurity;
11import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
12import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
13import org.springframework.security.config.http.SessionCreationPolicy;
14import org.springframework.security.core.userdetails.UserDetailsService;
15import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
16import org.springframework.security.crypto.password.PasswordEncoder;
17import org.springframework.security.web.SecurityFilterChain;
18import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
19
20@Configuration
21@EnableWebSecurity
22public class SecurityConfig {
23
24    private final JwtAuthenticationFilter jwtAuthFilter;
25    private final UserDetailsService userDetailsService;
26
27    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
28                          UserDetailsService userDetailsService) {
29        this.jwtAuthFilter = jwtAuthFilter;
30        this.userDetailsService = userDetailsService;
31    }
32
33    @Bean
34    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
35        return http
36                // Deshabilitar CSRF (no necesario con JWT stateless)
37                .csrf(AbstractHttpConfigurer::disable)
38                // Configurar autorización de endpoints
39                .authorizeHttpRequests(auth -> auth
40                        .requestMatchers("/auth/**").permitAll()
41                        .requestMatchers("/h2-console/**").permitAll()
42                        .anyRequest().authenticated()
43                )
44                // Configurar sesión stateless
45                .sessionManagement(session -> session
46                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
47                )
48                // Registrar el proveedor de autenticación
49                .authenticationProvider(authenticationProvider())
50                // Agregar filtro JWT antes del filtro de Spring Security
51                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
52                // Permitir frames para H2 Console
53                .headers(headers -> headers
54                        .frameOptions(frame -> frame.sameOrigin())
55                )
56                .build();
57    }
58
59    @Bean
60    public AuthenticationProvider authenticationProvider() {
61        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
62        authProvider.setUserDetailsService(userDetailsService);
63        authProvider.setPasswordEncoder(passwordEncoder());
64        return authProvider;
65    }
66
67    @Bean
68    public AuthenticationManager authenticationManager(
69            AuthenticationConfiguration config
70    ) throws Exception {
71        return config.getAuthenticationManager();
72    }
73
74    @Bean
75    public PasswordEncoder passwordEncoder() {
76        return new BCryptPasswordEncoder();
77    }
78}

¿Por qué deshabilitamos CSRF? La protección CSRF es esencial en aplicaciones basadas en sesiones/cookies, pero con JWT stateless no es necesaria. El token se envía en el header Authorization, que un atacante no puede inyectar automáticamente desde un sitio malicioso (a diferencia de las cookies que se envían automáticamente).

Endpoint de Autenticación

Ahora vamos a crear la entidad de usuario, el repositorio, el servicio de autenticación y el controller con los endpoints de login y registro.

Entidad de Usuario

java
1package com.example.jwtsecuritydemo.entity;
2
3import jakarta.persistence.*;
4import org.springframework.security.core.GrantedAuthority;
5import org.springframework.security.core.authority.SimpleGrantedAuthority;
6import org.springframework.security.core.userdetails.UserDetails;
7
8import java.util.Collection;
9import java.util.List;
10
11@Entity
12@Table(name = "app_user")
13public class UserEntity implements UserDetails {
14
15    @Id
16    @GeneratedValue(strategy = GenerationType.IDENTITY)
17    private Long id;
18
19    @Column(nullable = false)
20    private String firstName;
21
22    @Column(nullable = false)
23    private String lastName;
24
25    @Column(nullable = false, unique = true)
26    private String email;
27
28    @Column(nullable = false)
29    private String password;
30
31    @Enumerated(EnumType.STRING)
32    @Column(nullable = false)
33    private Role role;
34
35    public enum Role {
36        USER, ADMIN
37    }
38
39    // Constructor vacío requerido por JPA
40    public UserEntity() {}
41
42    public UserEntity(String firstName, String lastName, String email,
43                      String password, Role role) {
44        this.firstName = firstName;
45        this.lastName = lastName;
46        this.email = email;
47        this.password = password;
48        this.role = role;
49    }
50
51    // --- Implementación de UserDetails ---
52
53    @Override
54    public Collection<? extends GrantedAuthority> getAuthorities() {
55        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
56    }
57
58    @Override
59    public String getUsername() {
60        return email;  // Usamos email como username
61    }
62
63    @Override
64    public String getPassword() {
65        return password;
66    }
67
68    @Override
69    public boolean isAccountNonExpired() { return true; }
70
71    @Override
72    public boolean isAccountNonLocked() { return true; }
73
74    @Override
75    public boolean isCredentialsNonExpired() { return true; }
76
77    @Override
78    public boolean isEnabled() { return true; }
79
80    // --- Getters y Setters ---
81    public Long getId() { return id; }
82    public String getFirstName() { return firstName; }
83    public String getLastName() { return lastName; }
84    public String getEmail() { return email; }
85    public Role getRole() { return role; }
86}

Repositorio

java
1package com.example.jwtsecuritydemo.repository;
2
3import com.example.jwtsecuritydemo.entity.UserEntity;
4import org.springframework.data.jpa.repository.JpaRepository;
5
6import java.util.Optional;
7
8public interface UserRepository extends JpaRepository<UserEntity, Long> {
9    Optional<UserEntity> findByEmail(String email);
10    boolean existsByEmail(String email);
11}

DTOs con Records de Java 17

java
1// RegisterRequest.java
2package com.example.jwtsecuritydemo.dto;
3
4import jakarta.validation.constraints.Email;
5import jakarta.validation.constraints.NotBlank;
6import jakarta.validation.constraints.Size;
7
8public record RegisterRequest(
9        @NotBlank(message = "El nombre es obligatorio")
10        String firstName,
11
12        @NotBlank(message = "El apellido es obligatorio")
13        String lastName,
14
15        @NotBlank(message = "El email es obligatorio")
16        @Email(message = "El email debe tener un formato válido")
17        String email,
18
19        @NotBlank(message = "La contraseña es obligatoria")
20        @Size(min = 8, message = "La contraseña debe tener al menos 8 caracteres")
21        String password
22) {}
23
24// LoginRequest.java
25package com.example.jwtsecuritydemo.dto;
26
27import jakarta.validation.constraints.Email;
28import jakarta.validation.constraints.NotBlank;
29
30public record LoginRequest(
31        @NotBlank @Email String email,
32        @NotBlank String password
33) {}
34
35// AuthResponse.java
36package com.example.jwtsecuritydemo.dto;
37
38public record AuthResponse(
39        String token,
40        String type,
41        String email
42) {
43    public AuthResponse(String token, String email) {
44        this(token, "Bearer", email);
45    }
46}

Servicio de Autenticación

java
1package com.example.jwtsecuritydemo.service;
2
3import com.example.jwtsecuritydemo.dto.AuthResponse;
4import com.example.jwtsecuritydemo.dto.LoginRequest;
5import com.example.jwtsecuritydemo.dto.RegisterRequest;
6import com.example.jwtsecuritydemo.entity.UserEntity;
7import com.example.jwtsecuritydemo.repository.UserRepository;
8import org.springframework.security.authentication.AuthenticationManager;
9import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
10import org.springframework.security.crypto.password.PasswordEncoder;
11import org.springframework.stereotype.Service;
12
13@Service
14public class AuthService {
15
16    private final UserRepository userRepository;
17    private final JwtService jwtService;
18    private final PasswordEncoder passwordEncoder;
19    private final AuthenticationManager authenticationManager;
20
21    public AuthService(UserRepository userRepository, JwtService jwtService,
22                       PasswordEncoder passwordEncoder,
23                       AuthenticationManager authenticationManager) {
24        this.userRepository = userRepository;
25        this.jwtService = jwtService;
26        this.passwordEncoder = passwordEncoder;
27        this.authenticationManager = authenticationManager;
28    }
29
30    public AuthResponse register(RegisterRequest request) {
31        // Verificar si el email ya existe
32        if (userRepository.existsByEmail(request.email())) {
33            throw new IllegalArgumentException("El email ya está registrado");
34        }
35
36        // Crear el usuario
37        var user = new UserEntity(
38                request.firstName(),
39                request.lastName(),
40                request.email(),
41                passwordEncoder.encode(request.password()),
42                UserEntity.Role.USER
43        );
44
45        userRepository.save(user);
46
47        // Generar el token JWT
48        String token = jwtService.generateToken(user);
49        return new AuthResponse(token, user.getEmail());
50    }
51
52    public AuthResponse login(LoginRequest request) {
53        // Autenticar con Spring Security
54        authenticationManager.authenticate(
55                new UsernamePasswordAuthenticationToken(
56                        request.email(),
57                        request.password()
58                )
59        );
60
61        // Si la autenticación es exitosa, buscar el usuario
62        var user = userRepository.findByEmail(request.email())
63                .orElseThrow(() -> new IllegalArgumentException("Usuario no encontrado"));
64
65        // Generar el token JWT
66        String token = jwtService.generateToken(user);
67        return new AuthResponse(token, user.getEmail());
68    }
69}

Controller de Autenticación

java
1package com.example.jwtsecuritydemo.controller;
2
3import com.example.jwtsecuritydemo.dto.AuthResponse;
4import com.example.jwtsecuritydemo.dto.LoginRequest;
5import com.example.jwtsecuritydemo.dto.RegisterRequest;
6import com.example.jwtsecuritydemo.service.AuthService;
7import jakarta.validation.Valid;
8import org.springframework.http.ResponseEntity;
9import org.springframework.web.bind.annotation.*;
10
11@RestController
12@RequestMapping("/auth")
13public class AuthController {
14
15    private final AuthService authService;
16
17    public AuthController(AuthService authService) {
18        this.authService = authService;
19    }
20
21    @PostMapping("/register")
22    public ResponseEntity<AuthResponse> register(
23            @Valid @RequestBody RegisterRequest request
24    ) {
25        return ResponseEntity.ok(authService.register(request));
26    }
27
28    @PostMapping("/login")
29    public ResponseEntity<AuthResponse> login(
30            @Valid @RequestBody LoginRequest request
31    ) {
32        return ResponseEntity.ok(authService.login(request));
33    }
34}
35
36// --- Endpoint protegido de ejemplo ---
37package com.example.jwtsecuritydemo.controller;
38
39import org.springframework.http.ResponseEntity;
40import org.springframework.security.core.Authentication;
41import org.springframework.web.bind.annotation.GetMapping;
42import org.springframework.web.bind.annotation.RequestMapping;
43import org.springframework.web.bind.annotation.RestController;
44
45import java.util.Map;
46
47@RestController
48@RequestMapping("/api")
49public class DemoController {
50
51    @GetMapping("/profile")
52    public ResponseEntity<Map<String, String>> getProfile(Authentication authentication) {
53        return ResponseEntity.ok(Map.of(
54                "message", "Bienvenido a tu perfil protegido",
55                "user", authentication.getName(),
56                "authorities", authentication.getAuthorities().toString()
57        ));
58    }
59}

No olvides configurar el UserDetailsService para que Spring Security pueda cargar los usuarios:

java
1package com.example.jwtsecuritydemo.config;
2
3import com.example.jwtsecuritydemo.repository.UserRepository;
4import org.springframework.context.annotation.Bean;
5import org.springframework.context.annotation.Configuration;
6import org.springframework.security.core.userdetails.UserDetailsService;
7import org.springframework.security.core.userdetails.UsernameNotFoundException;
8
9@Configuration
10public class ApplicationConfig {
11
12    private final UserRepository userRepository;
13
14    public ApplicationConfig(UserRepository userRepository) {
15        this.userRepository = userRepository;
16    }
17
18    @Bean
19    public UserDetailsService userDetailsService() {
20        return username -> userRepository.findByEmail(username)
21                .orElseThrow(() ->
22                        new UsernameNotFoundException("Usuario no encontrado: " + username));
23    }
24}

Probando la API

Con todo configurado, ejecuta la aplicación y prueba los endpoints con curl o cualquier cliente HTTP como Postman o Insomnia.

1. Registrar un usuario

bash
1curl -X POST http://localhost:8080/auth/register \
2  -H "Content-Type: application/json" \
3  -d '{
4    "firstName": "Ana",
5    "lastName": "García",
6    "email": "[email protected]",
7    "password": "MiPassword123"
8  }'

Respuesta esperada:

json
1{
2  "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbmEuZ2FyY2lhQGV4YW1wbGUuY29tIiwiaWF0IjoxNzEyMDAwMDAwLCJleHAiOjE3MTIwMDM2MDB9.xxxxx",
3  "type": "Bearer",
4  "email": "[email protected]"
5}

2. Iniciar sesión

bash
1curl -X POST http://localhost:8080/auth/login \
2  -H "Content-Type: application/json" \
3  -d '{
4    "email": "[email protected]",
5    "password": "MiPassword123"
6  }'

3. Acceder a un endpoint protegido

bash
1# Reemplaza <TU_TOKEN> con el token recibido en la respuesta anterior
2curl -X GET http://localhost:8080/api/profile \
3  -H "Authorization: Bearer <TU_TOKEN>"

Respuesta esperada:

json
1{
2  "message": "Bienvenido a tu perfil protegido",
3  "user": "[email protected]",
4  "authorities": "[ROLE_USER]"
5}

4. Verificar que sin token se rechaza la petición

bash
1curl -X GET http://localhost:8080/api/profile
2# Respuesta: 403 Forbidden

Depuración: Si recibes un 403 inesperado, verifica que: (1) el token no haya expirado, (2) el header sea exactamente Authorization: Bearer <token> con un espacio después de "Bearer", y (3) la clave secreta sea la misma para generar y validar.

Próximos Pasos

En este primer artículo construimos los fundamentos completos de autenticación JWT en Spring Boot 3:

  • Entendimos la estructura y funcionamiento de los JSON Web Tokens.

  • Configuramos un proyecto Spring Boot con todas las dependencias necesarias.

  • Creamos un JwtService usando la API moderna de JJWT 0.12.x.

  • Implementamos un filtro JWT que intercepta y valida cada petición.

  • Configuramos Spring Security en modo stateless.

  • Creamos endpoints de registro e inicio de sesión funcionales.

Sin embargo, nuestro sistema tiene una limitación importante: cuando el access token expira, el usuario debe volver a iniciar sesión. En una aplicación real, esto sería una pésima experiencia de usuario.

En la Parte 2 resolveremos este problema implementando refresh tokens. Aprenderás a: almacenar refresh tokens en la base de datos, crear un endpoint /auth/refresh para renovar tokens sin re-autenticarse, implementar rotación de tokens para mayor seguridad, y manejar el flujo completo de renovación desde el frontend.

Si este artículo te fue útil, compártelo con otros desarrolladores Java. ¡Nos vemos en la siguiente parte del curso!

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles

Stay updated

Get notified when I publish new articles. No spam, unsubscribe anytime.