Spring Security 6: Autenticación JWT Completa

Guía práctica para implementar autenticación JWT con Spring Security 6. Configuración, generación de tokens, validación y buenas prácticas de seguridad.

Autenticación JWT con Spring Security 6

La autenticación con JWT (JSON Web Token) se ha convertido en el estándar para proteger las APIs REST modernas. Spring Security 6 introduce un enfoque de configuración funcional que simplifica la implementación y refuerza la seguridad. Esta guía recorre todo el proceso, desde la configuración inicial hasta la protección de los endpoints.

Requisitos previos

Este tutorial utiliza Spring Boot 3.2+ y Spring Security 6.2+. Los conceptos siguen siendo válidos en versiones posteriores, con pequeños ajustes de sintaxis.

Arquitectura JWT en Spring Security

La autenticación JWT se basa en un principio stateless: el servidor no almacena ninguna sesión. Cada petición contiene un token firmado que demuestra la identidad del usuario. Esta arquitectura permite escalar horizontalmente sin tener que compartir sesiones entre instancias.

El flujo de autenticación JWT se divide en varias etapas. El usuario se autentica con sus credenciales, recibe un token JWT firmado y luego incluye ese token en cada petición posterior. El servidor valida la firma y extrae la información del usuario contenida en el token.

JwtAuthenticationFlow.javajava
// Conceptual representation of the authentication flow
public class JwtAuthenticationFlow {

    // 1. Initial authentication: POST /api/auth/login
    // → Verify credentials against database
    // → Generate signed JWT token
    // → Return token to client

    // 2. Authenticated requests: GET /api/protected
    // → Header: Authorization: Bearer <token>
    // → Extract and validate token
    // → Create SecurityContext
    // → Access protected resource
}

Este enfoque elimina los problemas de sticky session y simplifica el despliegue en entornos distribuidos.

Configuración de las dependencias Maven

El proyecto requiere las dependencias de Spring Security y una librería JWT. JJWT (Java JWT) ofrece una API fluida y bien mantenida para manipular los tokens.

xml
<!-- pom.xml -->
<dependencies>
    <!-- Spring Security for authentication management -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- Spring Web for REST endpoints -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- JJWT: token generation and validation -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <!-- Spring Data JPA for user persistence -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

La separación en tres módulos JJWT (api, impl, jackson) sigue el principio de encapsulación: solo la API queda visible en tiempo de compilación, mientras que la implementación es un detalle en tiempo de ejecución.

Servicio de generación y validación JWT

El servicio JWT centraliza todas las operaciones sobre los tokens: generación, extracción de claims y validación. Una clave secreta segura firma cada token y garantiza su integridad.

JwtService.javajava
@Service
public class JwtService {

    // Secret key injected from application.yml
    @Value("${app.jwt.secret}")
    private String secretKey;

    // Token validity duration (24 hours by default)
    @Value("${app.jwt.expiration:86400000}")
    private long jwtExpiration;

    // Generates a JWT token for an authenticated user
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    // Generates a token with custom claims
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
            .claims(extraClaims)                           // Additional claims (roles, permissions)
            .subject(userDetails.getUsername())            // Principal identifier
            .issuedAt(new Date())                          // Creation date
            .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
            .signWith(getSigningKey(), Jwts.SIG.HS256)     // HMAC-SHA256 signature
            .compact();
    }

    // Extracts the username (subject) from the token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Extracts a specific claim via an extraction function
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // Validates the token: correct signature and not expired
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    // Checks if the token has expired
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // Extracts the expiration date
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // Parses the token and extracts all claims
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())    // Verifies the signature
            .build()
            .parseSignedClaims(token)       // Parses the signed token
            .getPayload();                  // Returns the claims
    }

    // Generates the signing key from the Base64-encoded secret
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

La clave secreta debe ser lo suficientemente larga (256 bits mínimo para HS256) y almacenarse de forma segura, nunca en el código fuente.

Seguridad de la clave secreta

Utiliza una variable de entorno o un gestor de secretos para la clave JWT. Una clave comprometida permite falsificar tokens válidos para cualquier usuario.

Configuración de la entidad User

La entidad de usuario implementa UserDetails de Spring Security, lo que permite una integración directa con el sistema de autenticación.

User.javajava
@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String firstName;

    @Column(nullable = false)
    private String lastName;

    // Role stored as enum for type safety
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    // UserDetails implementation: returns user authorities
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    // Username corresponds to email in this implementation
    @Override
    public String getUsername() {
        return email;
    }

    // Account always active (adapt as needed)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    // Getters and setters omitted for brevity
}
Role.javajava
public enum Role {
    USER,      // Standard user
    ADMIN      // Administrator with extended privileges
}

El enum Role limita los valores posibles y simplifica la verificación de autorizaciones en las expresiones SpEL.

Filtro de autenticación JWT

El filtro JWT intercepta cada petición para extraer y validar el token. Se inserta en la cadena de filtros de Spring Security antes del filtro de autenticación estándar.

JwtAuthenticationFilter.javajava
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        // Retrieve the Authorization header
        final String authHeader = request.getHeader("Authorization");

        // Check for Bearer prefix
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract the token (without the "Bearer " prefix)
        final String jwt = authHeader.substring(7);

        try {
            // Extract username from token
            final String userEmail = jwtService.extractUsername(jwt);

            // Check that user is not already authenticated
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {

                // Load user details from database
                UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);

                // Validate token (signature + expiration + user match)
                if (jwtService.isTokenValid(jwt, userDetails)) {

                    // Create authentication object
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );

                    // Add request details
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );

                    // Set authentication in security context
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (ExpiredJwtException e) {
            // Token expired: user must re-authenticate
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Token expired");
            return;
        } catch (JwtException e) {
            // Invalid token: incorrect signature or malformed format
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
            return;
        }

        // Continue the filter chain
        filterChain.doFilter(request, response);
    }
}

OncePerRequestFilter garantiza que el filtro se ejecute una sola vez por petición, incluso en caso de forward o include.

¿Listo para aprobar tus entrevistas de Spring Boot?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Configuración de Spring Security 6

Spring Security 6 emplea un enfoque funcional con lambdas para configurar la cadena de seguridad. Esta configuración define las reglas de acceso e integra el filtro JWT.

SecurityConfig.javajava
@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Enables @PreAuthorize and @PostAuthorize
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            // Disable CSRF since API is stateless (no session cookies)
            .csrf(csrf -> csrf.disable())

            // Configure authorization rules
            .authorizeHttpRequests(auth -> auth
                // Public endpoints: authentication and registration
                .requestMatchers("/api/auth/**").permitAll()
                // API documentation accessible without authentication
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                // Admin endpoints reserved for administrators
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                // All other requests require authentication
                .anyRequest().authenticated()
            )

            // Stateless mode: no server-side HTTP session
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // Custom authentication provider
            .authenticationProvider(authenticationProvider)

            // Insert JWT filter before standard authentication filter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

            .build();
    }
}
ApplicationConfig.javajava
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository userRepository;

    // User loading service for Spring Security
    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
    }

    // Authentication provider with password encoder
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    // Authentication manager exposed as bean
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
        throws Exception {
        return config.getAuthenticationManager();
    }

    // BCrypt encoder for secure password hashing
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Desactivar CSRF resulta seguro para una API stateless porque la autenticación se basa en una cabecera explícita y no en una cookie que el navegador envíe automáticamente.

Controlador de autenticación

El controlador expone los endpoints de registro e inicio de sesión. Estos endpoints son públicos y devuelven el token JWT tras una autenticación exitosa.

AuthenticationController.javajava
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationService authService;

    // POST /api/auth/register - Register a new user
    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
        @Valid @RequestBody RegisterRequest request
    ) {
        return ResponseEntity.ok(authService.register(request));
    }

    // POST /api/auth/login - Login existing user
    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(
        @Valid @RequestBody AuthenticationRequest request
    ) {
        return ResponseEntity.ok(authService.authenticate(request));
    }

    // POST /api/auth/refresh - Token refresh (optional)
    @PostMapping("/refresh")
    public ResponseEntity<AuthenticationResponse> refresh(
        @RequestHeader("Authorization") String authHeader
    ) {
        return ResponseEntity.ok(authService.refreshToken(authHeader));
    }
}
AuthenticationRequest.javajava
public record AuthenticationRequest(
    @NotBlank(message = "Email required")
    @Email(message = "Invalid email format")
    String email,

    @NotBlank(message = "Password required")
    String password
) {}
RegisterRequest.javajava
public record RegisterRequest(
    @NotBlank(message = "First name required")
    String firstName,

    @NotBlank(message = "Last name required")
    String lastName,

    @NotBlank(message = "Email required")
    @Email(message = "Invalid email format")
    String email,

    @NotBlank(message = "Password required")
    @Size(min = 8, message = "Password must contain at least 8 characters")
    String password
) {}
AuthenticationResponse.javajava
public record AuthenticationResponse(
    String token,
    String type,
    long expiresIn
) {
    public AuthenticationResponse(String token, long expiresIn) {
        this(token, "Bearer", expiresIn);
    }
}

Los records de Java simplifican la definición de los DTO al tiempo que garantizan su inmutabilidad.

Servicio de autenticación

El servicio orquesta la lógica de registro y autenticación, delegando en JwtService la generación de los tokens.

AuthenticationService.javajava
@Service
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    @Value("${app.jwt.expiration:86400000}")
    private long jwtExpiration;

    // Register a new user
    @Transactional
    public AuthenticationResponse register(RegisterRequest request) {
        // Check that email is not already in use
        if (userRepository.existsByEmail(request.email())) {
            throw new EmailAlreadyExistsException("Email already registered");
        }

        // Create user entity with hashed password
        User user = new User();
        user.setFirstName(request.firstName());
        user.setLastName(request.lastName());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));
        user.setRole(Role.USER);

        // Persist the user
        userRepository.save(user);

        // Generate and return JWT token
        String jwtToken = jwtService.generateToken(user);
        return new AuthenticationResponse(jwtToken, jwtExpiration);
    }

    // Authenticate an existing user
    public AuthenticationResponse authenticate(AuthenticationRequest request) {
        // Delegate verification to AuthenticationManager
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.email(),
                request.password()
            )
        );

        // Load user (authentication succeeded)
        User user = userRepository.findByEmail(request.email())
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Generate token with additional claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", user.getRole().name());
        claims.put("userId", user.getId());

        String jwtToken = jwtService.generateToken(claims, user);
        return new AuthenticationResponse(jwtToken, jwtExpiration);
    }

    // Refresh token (extend session)
    public AuthenticationResponse refreshToken(String authHeader) {
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new InvalidTokenException("Invalid token");
        }

        String oldToken = authHeader.substring(7);
        String userEmail = jwtService.extractUsername(oldToken);

        User user = userRepository.findByEmail(userEmail)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Generate new token
        String newToken = jwtService.generateToken(user);
        return new AuthenticationResponse(newToken, jwtExpiration);
    }
}

AuthenticationManager centraliza la verificación de credenciales y permite cambiar la estrategia de autenticación con facilidad.

Refresh Token

Para reforzar la seguridad, conviene implementar un sistema de refresh token con una validez más larga, almacenado en base de datos y revocable.

Protección de endpoints con anotaciones

La seguridad a nivel de método permite un control de autorizaciones granular directamente en el código de negocio.

UserController.javajava
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // Accessible to all authenticated users
    @GetMapping("/me")
    public ResponseEntity<UserDTO> getCurrentUser(
        @AuthenticationPrincipal User currentUser
    ) {
        return ResponseEntity.ok(UserDTO.from(currentUser));
    }

    // Only the concerned user or an admin can modify the profile
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    @PutMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(
        @PathVariable Long id,
        @Valid @RequestBody UpdateUserRequest request
    ) {
        return ResponseEntity.ok(userService.updateUser(id, request));
    }

    // Reserved for administrators
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping
    public ResponseEntity<List<UserDTO>> getAllUsers() {
        return ResponseEntity.ok(userService.findAll());
    }

    // Deletion with post-execution verification
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
AdminController.javajava
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")  // All methods require ADMIN
public class AdminController {

    private final AdminService adminService;

    @GetMapping("/dashboard")
    public ResponseEntity<DashboardDTO> getDashboard() {
        return ResponseEntity.ok(adminService.getDashboardStats());
    }

    @PostMapping("/users/{id}/role")
    public ResponseEntity<UserDTO> changeUserRole(
        @PathVariable Long id,
        @RequestParam Role newRole
    ) {
        return ResponseEntity.ok(adminService.changeUserRole(id, newRole));
    }
}

La anotación @AuthenticationPrincipal inyecta directamente al usuario autenticado y evita acceder al SecurityContext manualmente.

Manejo de errores de autenticación

Un manejo centralizado de errores garantiza respuestas coherentes e informativas cuando la autenticación falla.

SecurityExceptionHandler.javajava
@RestControllerAdvice
public class SecurityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(SecurityExceptionHandler.class);

    // Authentication error (incorrect credentials)
    @ExceptionHandler(BadCredentialsException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleBadCredentials(BadCredentialsException ex) {
        return new ErrorResponse(
            "INVALID_CREDENTIALS",
            "Invalid email or password",
            null
        );
    }

    // Access denied (authenticated but not authorized)
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
        return new ErrorResponse(
            "ACCESS_DENIED",
            "Access to this resource is not authorized",
            null
        );
    }

    // Expired JWT token
    @ExceptionHandler(ExpiredJwtException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleExpiredToken(ExpiredJwtException ex) {
        return new ErrorResponse(
            "TOKEN_EXPIRED",
            "Session expired, please log in again",
            null
        );
    }

    // Invalid JWT token
    @ExceptionHandler(JwtException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleInvalidToken(JwtException ex) {
        log.warn("Invalid JWT token: {}", ex.getMessage());
        return new ErrorResponse(
            "INVALID_TOKEN",
            "Invalid authentication token",
            null
        );
    }

    // Email already in use during registration
    @ExceptionHandler(EmailAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleEmailExists(EmailAlreadyExistsException ex) {
        return new ErrorResponse(
            "EMAIL_EXISTS",
            ex.getMessage(),
            null
        );
    }
}
ErrorResponse.javajava
public record ErrorResponse(
    String code,
    String message,
    Map<String, String> details
) {}

Los códigos de error estandarizados facilitan el procesamiento en el cliente y la depuración.

Configuración de application.yml

Una configuración externalizada permite adaptar los parámetros JWT por entorno.

yaml
# application.yml
app:
  jwt:
    # Base64 secret key (256 bits minimum for HS256)
    # Generate with: openssl rand -base64 32
    secret: ${JWT_SECRET:yourSuperSecretKeyOf256BitsMinimum}
    # Validity duration in milliseconds (24 hours)
    expiration: 86400000

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/springjwt
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
yaml
# application-dev.yml
app:
  jwt:
    # Short expiration for development (1 hour)
    expiration: 3600000

spring:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update

logging:
  level:
    org.springframework.security: DEBUG

En producción, la clave secreta debe provenir de una variable de entorno o de un servicio de gestión de secretos como Vault.

Pruebas de integración

Las pruebas verifican el comportamiento completo del sistema de autenticación, desde el registro hasta el acceso a los recursos protegidos.

AuthenticationIntegrationTest.javajava
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = Replace.ANY)
class AuthenticationIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void shouldRegisterAndAuthenticateUser() {
        // Registration
        RegisterRequest registerRequest = new RegisterRequest(
            "John", "Doe", "john@example.com", "password123"
        );

        ResponseEntity<AuthenticationResponse> registerResponse = restTemplate
            .postForEntity("/api/auth/register", registerRequest, AuthenticationResponse.class);

        assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(registerResponse.getBody().token()).isNotBlank();

        // Login with same credentials
        AuthenticationRequest loginRequest = new AuthenticationRequest(
            "john@example.com", "password123"
        );

        ResponseEntity<AuthenticationResponse> loginResponse = restTemplate
            .postForEntity("/api/auth/login", loginRequest, AuthenticationResponse.class);

        assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(loginResponse.getBody().token()).isNotBlank();
    }

    @Test
    void shouldRejectInvalidCredentials() {
        AuthenticationRequest request = new AuthenticationRequest(
            "unknown@example.com", "wrongpassword"
        );

        ResponseEntity<ErrorResponse> response = restTemplate
            .postForEntity("/api/auth/login", request, ErrorResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void shouldAccessProtectedResourceWithValidToken() {
        // Register to get a token
        RegisterRequest registerRequest = new RegisterRequest(
            "Jane", "Doe", "jane@example.com", "password123"
        );

        AuthenticationResponse authResponse = restTemplate
            .postForObject("/api/auth/register", registerRequest, AuthenticationResponse.class);

        // Access protected resource with token
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(authResponse.token());

        ResponseEntity<UserDTO> response = restTemplate.exchange(
            "/api/users/me",
            HttpMethod.GET,
            new HttpEntity<>(headers),
            UserDTO.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().email()).isEqualTo("jane@example.com");
    }

    @Test
    void shouldRejectAccessWithoutToken() {
        ResponseEntity<Void> response = restTemplate
            .getForEntity("/api/users/me", Void.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

Estas pruebas cubren los escenarios principales: registro, inicio de sesión, acceso autorizado y acceso denegado.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Conclusión

La implementación de la autenticación JWT con Spring Security 6 sigue un patrón bien definido. El servicio JWT se encarga de generar y validar los tokens, el filtro intercepta las peticiones para establecer el contexto de seguridad y la configuración define las reglas de acceso.

Lista de verificación para el despliegue:

  • ✅ Clave secreta de 256 bits mínimo almacenada en variable de entorno
  • ✅ HTTPS obligatorio en producción para proteger los tokens en tránsito
  • ✅ Duración de validez del token adaptada al contexto (15 min a 24 h)
  • ✅ Refresh token para sesiones largas sin volver a autenticarse
  • ✅ Manejo de errores estandarizado con códigos de negocio
  • ✅ Registro de los intentos de autenticación fallidos
  • ✅ Pruebas de integración que cubran los escenarios de autenticación
  • ✅ Rate limiting en los endpoints de autenticación

Esta arquitectura proporciona una base sólida para asegurar APIs REST y resulta extensible para necesidades más complejas como la autenticación multifactor o la integración OAuth2.

Etiquetas

#spring security
#jwt
#java
#spring boot
#authentication

Compartir

Artículos relacionados