Saltar al contenido principal

Autenticación JWT

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, en el/los commit(s) JWT authentication (Java), JWT authentication (Kotlin), JWT authentication (Groovy).

En los documentos anteriores vimos qué maneja Spring Security, qué no, y cómo funciona la arquitectura de cadenas de filtros por debajo. Ahora pongamos esa teoría en práctica agregando autenticación JWT stateless para que la API pueda distinguir entre lectores anónimos y miembros del staff autenticados.

Vista general de archivos​

Archivos a Crear/Modificar
File Tree
.
├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ ├── security
│ │ │ │ ├── SecurityConfig.java
│ │ │ │ ├── handler
│ │ │ │ │ ├── AuthenticationErrorResponseWriter.java
│ │ │ │ │ ├── CustomAccessDeniedHandler.java
│ │ │ │ │ └── CustomAuthenticationEntryPoint.java
│ │ │ │ ├── jwt
│ │ │ │ │ ├── JwtAuthenticationFilter.java
│ │ │ │ │ ├── JwtProperties.java
│ │ │ │ │ └── JwtService.java
│ │ │ │ └── userdetails
│ │ │ │ ├── SakilaUserDetails.java
│ │ │ │ └── SakilaUserDetailsService.java
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ └── sakila
│ │ ├── auth
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── AuthRestController.java
│ │ │ │ └── AuthRestMapper.java
│ │ │ └── domain
│ │ │ ├── port
│ │ │ │ └── in
│ │ │ │ └── AuthUseCases.java
│ │ │ └── service
│ │ │ └── AuthUseCasesImpl.java
│ │ └── staff
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── StaffJpaMapper.java
│ │ │ ├── StaffJpaRepository.java
│ │ │ └── StaffRepositoryImpl.java
│ │ └── domain
│ │ ├── model
│ │ │ └── Staff.java
│ │ └── port
│ │ └── out
│ │ └── StaffRepository.java
│ └── resources
│ ├── application.yaml
│ ├── openapi.yaml
│ ├── sakila-data.sql
│ └── sakila-schema.sql
└── test
└── java
└── dev
└── pollito
└── spring_java
├── config
│ └── security
│ ├── SecurityConfigMockMvcTest.java
│ ├── jwt
│ │ ├── JwtAuthenticationFilterTest.java
│ │ └── JwtServiceTest.java
│ └── userdetails
│ └── SakilaUserDetailsServiceTest.java
└── sakila
├── auth
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── AuthRestControllerMockMvcTest.java
│ └── domain
│ └── service
│ └── AuthUseCasesImplTest.java
└── film
└── adapter
└── in
└── rest
└── FilmRestControllerMockMvcTest.java
info

Este documento no va a profundizar en cómo se construye el slice de Staff (entidad, repositorio, mapper y modelo de dominio). Sigue el mismo patrón usado para el slice de Film en los documentos de integración de persistencia.

Dependencias​

Necesitamos Spring Security para la cadena de filtros y JJWT para la generación y validación de tokens. Las dependencias se agregan al archivo de build:

build.gradle
def jjwtVersion = '0.12.6'
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
  • spring-boot-starter-security trae Spring Security y su cadena de filtros.
  • jjwt-api, jjwt-impl y jjwt-jackson se encargan de la creación y el parseo de tokens.
  • spring-security-test nos da helpers para testear endpoints protegidos.

Compatibilidad de almacenamiento de contraseñas​

La base de datos de ejemplo Sakila original almacena las contraseñas del staff usando hashes MD5/SHA1. Spring Security 6.x no soporta estos algoritmos por defecto, así que las migramos a BCrypt usando una migración de Flyway:

V5__migrate_staff_passwords_to_bcrypt.sql
ALTER TABLE staff ALTER COLUMN password TYPE VARCHAR(60);

UPDATE staff SET password = '$2a$10$BuPx936L/1mmFsSEvFUanOR/dvE4nhAciLvCovbSZQxVbVA9gVDrO' WHERE staff_id = 1;
UPDATE staff SET password = '$2a$10$Gw380J97F3u0AfFfvNkJUOhbrGcaUsL9oaRoyMaoPmr07ovBLodBe' WHERE staff_id = 2;
precaución

Si mantenés scripts separados de datos y esquema para desarrollo y testing (por ejemplo, sakila-data.sql y sakila-schema.sql), asegurate de actualizarlos con contraseñas codificadas en BCrypt también. Si no, los tests de integración y las ejecuciones locales van a fallar durante la autenticación.

Configuración JWT​

Externalizamos el secreto y la expiración en application.yaml para poder rotar las claves sin recompilar:

resources/application.yaml
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000

Un objeto de propiedades tipado vincula esos valores en tiempo de ejecución:

config/security/jwt/JwtProperties.java
package dev.pollito.spring_java.config.security.jwt;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(String secret, long expirationMs) {}
Cómo crear un secreto JWT seguro

En producción, tu JWT_SECRET debería ser un string aleatorio criptográficamente seguro de al menos 256 bits (32 bytes).

Mejores prácticas

AspectoRecomendación
LongitudAl menos 32 bytes (256 bits). Para HS256/HS512, más largo está bien pero 32+ es el mínimo.
GeneraciónUsá un generador de aleatorios criptográficamente seguro. Ejemplos: openssl rand -hex 32 o openssl rand -base64 32
AlmacenamientoGuardalo como una variable de entorno (que tu proyecto ya hace vía ${JWT_SECRET}) — nunca lo commitees a Git.
RotaciónTené una estrategia para rotarlo periódicamente sin invalidar todas las sesiones activas inmediatamente (por ejemplo, soportar headers de key IDs / kid).
UnicidadUsá un secreto diferente por ambiente (dev, staging, prod).

Comandos rápidos de generación

# 32 bytes como hex (64 caracteres hex)
openssl rand -hex 32

# 32 bytes como base64
openssl rand -base64 32

Configuración de seguridad​

SecurityConfig es el corazón de la configuración. Deshabilita CSRF (somos stateless), establece el manejo de sesiones a STATELESS, y define las reglas de autorización:

  • GET /api/films/** es público.
  • POST /api/auth/login es público.
  • requestMatchers("/actuator/**") es público.
  • requestMatchers("/h2-console/**") es público.
  • requestMatchers("/error") es público.
  • Todo lo demás requiere autenticación.

También conecta el JwtAuthenticationFilter antes del UsernamePasswordAuthenticationFilter de Spring para que cada request entrante tenga su token inspeccionado primero.

config/security/SecurityConfig.java
package dev.pollito.spring_java.config.security;

import static org.springframework.http.HttpMethod.GET;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;

import dev.pollito.spring_java.config.security.handler.CustomAccessDeniedHandler;
import dev.pollito.spring_java.config.security.handler.CustomAuthenticationEntryPoint;
import dev.pollito.spring_java.config.security.jwt.JwtAuthenticationFilter;
import dev.pollito.spring_java.config.security.jwt.JwtProperties;
import dev.pollito.spring_java.config.security.jwt.JwtService;
import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties.class)
@RequiredArgsConstructor
public class SecurityConfig {

private final SakilaUserDetailsService sakilaUserDetailsService;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;

@Bean
public SecurityFilterChain securityFilterChain(
@NonNull HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(
auth ->
auth.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers("/h2-console/**")
.permitAll()
.requestMatchers("/error")
.permitAll()
.requestMatchers(GET, "/api/films/**")
.permitAll()
.requestMatchers("/api/auth/login")
.permitAll()
.anyRequest()
.authenticated())
.exceptionHandling(
ex ->
ex.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(
JwtService jwtService,
org.springframework.security.core.userdetails.UserDetailsService userDetailsService) {
return new JwtAuthenticationFilter(jwtService, userDetailsService);
}

@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider =
new DaoAuthenticationProvider(sakilaUserDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}

@Bean
public AuthenticationManager authenticationManager(@NonNull AuthenticationConfiguration config) {
return config.getAuthenticationManager();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

Servicio JWT​

JwtService se encarga de la creación de tokens, la extracción del username y la validación. Usa la API builder de JJWT para firmar tokens con una clave HMAC derivada del secreto configurado:

config/security/jwt/JwtService.java
package dev.pollito.spring_java.config.security.jwt;

import static io.jsonwebtoken.Jwts.parser;
import static io.jsonwebtoken.security.Keys.hmacShaKeyFor;
import static java.nio.charset.StandardCharsets.UTF_8;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import java.util.Date;
import java.util.function.Function;
import javax.crypto.SecretKey;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

@Service
public class JwtService {

private final JwtProperties jwtProperties;

public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}

public String generateToken(@NonNull UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.expirationMs()))
.signWith(getSignInKey())
.compact();
}

public boolean isTokenValid(String token, @NonNull UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername()) && !isTokenExpired(token);
}

public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}

private <T> T extractClaim(String token, @NonNull Function<Claims, T> claimsResolver) {
try {
return claimsResolver.apply(
parser().verifyWith(getSignInKey()).build().parseSignedClaims(token).getPayload());
} catch (ExpiredJwtException e) {
return claimsResolver.apply(e.getClaims());
}
}

private SecretKey getSignInKey() {
return hmacShaKeyFor(jwtProperties.secret().getBytes(UTF_8));
}
}

Filtro de autenticación​

JwtAuthenticationFilter es un servlet filter que corre una vez por request. Busca un header Authorization que empiece con Bearer , extrae el token, lo valida contra el user details service, y pobla el contexto de seguridad si todo chequea:

config/security/jwt/JwtAuthenticationFilter.java
package dev.pollito.spring_java.config.security.jwt;

import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

String token = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
throw new InsufficientAuthenticationException("Invalid or malformed JWT token", e);
}

if (username == null || getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}

UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (!jwtService.isTokenValid(token, userDetails)) {
filterChain.doFilter(request, response);
return;
}

UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
getContext().setAuthentication(authToken);

filterChain.doFilter(request, response);
}
}

User details​

Spring Security necesita un UserDetailsService para buscar usuarios por username. Implementamos SakilaUserDetailsService respaldado por un StaffRepository, y SakilaUserDetails adapta el modelo de dominio Staff al contrato UserDetails de Spring:

config/security/userdetails/SakilaUserDetails.java
package dev.pollito.spring_java.config.security.userdetails;

import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public record SakilaUserDetails(Staff staff) implements UserDetails {

@Override
public String getUsername() {
return staff.getUsername();
}

@Override
public String getPassword() {
return staff.getPassword();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_STAFF"));
}

@Override
public boolean isAccountNonLocked() {
return staff.isActive();
}
}
config/security/userdetails/SakilaUserDetailsService.java
package dev.pollito.spring_java.config.security.userdetails;

import dev.pollito.spring_java.sakila.staff.domain.port.out.StaffRepository;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SakilaUserDetailsService implements UserDetailsService {

private final StaffRepository staffRepository;

@Override
public UserDetails loadUserByUsername(@NonNull String username) throws UsernameNotFoundException {
// Composite lookup: tries staff first, ready for future customer fallback.
// Example: .or(() -> customerRepository.findByUsername(username).map(SakilaUserDetails::new))
// Both staff and customers will share /api/auth/login.
return staffRepository
.findByUsername(username)
.map(SakilaUserDetails::new)
.orElseThrow(() -> new UsernameNotFoundException(username));
}
}

Endpoints de auth​

El AuthRestController expone dos endpoints: POST /api/auth/login para autenticarse y recibir un token, y GET /api/auth/me para obtener los detalles del usuario actual. Implementa la interfaz AuthApi generada desde la especificación OpenAPI.

También actualizamos openapi.yaml con un esquema de seguridad bearerAuth y lo aplicamos a los endpoints mutantes de film (POST, PUT, DELETE). Los endpoints GET permanecen abiertos:

sakila/auth/adapter/in/rest/AuthRestController.java
package dev.pollito.spring_java.sakila.auth.adapter.in.rest;

import static io.opentelemetry.api.trace.Span.current;
import static java.time.OffsetDateTime.now;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.ResponseEntity.ok;

import dev.pollito.spring_java.sakila.auth.domain.port.in.AuthUseCases;
import dev.pollito.spring_java.sakila.generated.api.AuthApi;
import dev.pollito.spring_java.sakila.generated.model.LoginRequest;
import dev.pollito.spring_java.sakila.generated.model.LoginResponse;
import dev.pollito.spring_java.sakila.generated.model.LoginResponseAllOfData;
import dev.pollito.spring_java.sakila.generated.model.UserDetailsResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NonNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AuthRestController implements AuthApi {

private final AuthUseCases authUseCases;
private final AuthRestMapper mapper;
private final HttpServletRequest request;

@Override
public ResponseEntity<LoginResponse> login(@NonNull LoginRequest loginRequest) {
return ok(
new LoginResponse()
.data(
new LoginResponseAllOfData()
.token(
authUseCases.authenticate(
loginRequest.getUsername(), loginRequest.getPassword())))
.instance(request.getRequestURI())
.status(OK.value())
.timestamp(now())
.trace(current().getSpanContext().getTraceId()));
}

@Override
public ResponseEntity<UserDetailsResponse> getCurrentUserDetails() {
return ok(
new UserDetailsResponse()
.data(mapper.map(authUseCases.getCurrentUser()))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}
}

Manejo de errores para fallos de auth​

Para mantener las respuestas de error consistentes con el resto de la API, agregamos un @ExceptionHandler(AuthenticationException.class) al ControllerAdvice existente y conectamos implementaciones custom de AuthenticationEntryPoint y AccessDeniedHandler. Reutilizan el mismo formato JSON de Problem Details que introdujo el manejo de errores.

config/web/ControllerAdvice.java
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Error> handle(AuthenticationException e) {
return buildProblemDetail(e, UNAUTHORIZED);
}

Testing​

Cada componente introducido en este documento tiene una clase de test correspondiente. La suite cubre tests unitarios para el JWT service, el filtro de autenticación, el loader de user details, y las reglas de seguridad, más tests de integración basados en MockMvc para el REST controller y la configuración de seguridad.

consejo

Las clases @WebMvcTest preexistentes que no ejercitan la cadena de filtros de seguridad deberían estar anotadas con @AutoConfigureMockMvc(addFilters = false) para que JwtAuthenticationFilter no interfiera con el setup del test.

JwtAuthenticationFilter​

config/security/jwt/JwtAuthenticationFilterTest.java
package dev.pollito.spring_java.config.security.jwt;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.core.context.SecurityContextHolder.clearContext;
import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import io.jsonwebtoken.MalformedJwtException;
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.Collections;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {

@Mock private JwtService jwtService;
@Mock private UserDetailsService userDetailsService;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@Mock private FilterChain filterChain;
@InjectMocks private JwtAuthenticationFilter filter;

@BeforeEach
void setUp() {
clearContext();
}

@AfterEach
void tearDown() {
clearContext();
}

@Test
void doFilterInternal_noAuthHeader_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn(null);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertNull(getContext().getAuthentication());
}

@Test
void doFilterInternal_authHeaderWithoutBearer_proceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Basic dXNlcjpwYXNz");

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertNull(getContext().getAuthentication());
}

@Test
void doFilterInternal_malformedToken_throwsInsufficientAuthenticationException() {
when(request.getHeader("Authorization")).thenReturn("Bearer invalid-token");
when(jwtService.extractUsername("invalid-token")).thenThrow(new MalformedJwtException("bad"));

assertThrows(
InsufficientAuthenticationException.class,
() -> filter.doFilterInternal(request, response, filterChain));
}

@Test
void doFilterInternal_nullUsername_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn(null);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertNull(getContext().getAuthentication());
}

@Test
void doFilterInternal_existingAuthentication_proceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

Authentication existingAuth = mock(Authentication.class);
getContext().setAuthentication(existingAuth);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertSame(existingAuth, getContext().getAuthentication());
}

@Test
void doFilterInternal_invalidToken_proceedsChain() throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

UserDetails userDetails =
User.builder()
.username("Mike")
.password("password")
.authorities(Collections.emptyList())
.build();
when(userDetailsService.loadUserByUsername("Mike")).thenReturn(userDetails);
when(jwtService.isTokenValid("token", userDetails)).thenReturn(false);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
assertNull(getContext().getAuthentication());
}

@Test
void doFilterInternal_validToken_setsAuthenticationAndProceedsChain()
throws ServletException, IOException {
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtService.extractUsername("token")).thenReturn("Mike");

UserDetails userDetails =
User.builder()
.username("Mike")
.password("password")
.authorities(Collections.emptyList())
.build();
when(userDetailsService.loadUserByUsername("Mike")).thenReturn(userDetails);
when(jwtService.isTokenValid("token", userDetails)).thenReturn(true);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);

Authentication auth = getContext().getAuthentication();
assertNotNull(auth);
assertEquals(userDetails, auth.getPrincipal());
assertTrue(auth.isAuthenticated());
}
}

JwtService​

config/security/jwt/JwtServiceTest.java
package dev.pollito.spring_java.config.security.jwt;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.security.core.userdetails.User.withUsername;

import org.junit.jupiter.api.Test;
import org.springframework.security.core.userdetails.UserDetails;

class JwtServiceTest {

private final JwtProperties jwtProperties =
new JwtProperties("this-is-a-test-secret-key-make-it-long", 3600000);
private final JwtService jwtService = new JwtService(jwtProperties);

@Test
void generateTokenAndValidate() {
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = jwtService.generateToken(userDetails);
assertNotNull(token);
assertTrue(jwtService.isTokenValid(token, userDetails));
assertEquals("Mike", jwtService.extractUsername(token));
}

@Test
void invalidTokenFailsValidation() {
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = jwtService.generateToken(userDetails);
UserDetails otherUser = withUsername("Jon").password("password").roles("STAFF").build();
assertFalse(jwtService.isTokenValid(token, otherUser));
}

@Test
void expiredTokenFailsValidation() {
JwtProperties expiredProperties =
new JwtProperties("this-is-a-test-secret-key-make-it-long", -1);
JwtService expiredJwtService = new JwtService(expiredProperties);
UserDetails userDetails = withUsername("Mike").password("password").roles("STAFF").build();
String token = expiredJwtService.generateToken(userDetails);
assertFalse(expiredJwtService.isTokenValid(token, userDetails));
}
}

SakilaUserDetailsService​

config/security/userdetails/SakilaUserDetailsServiceTest.java
package dev.pollito.spring_java.config.security.userdetails;

import static java.util.Optional.empty;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import dev.pollito.spring_java.sakila.staff.domain.port.out.StaffRepository;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@ExtendWith(MockitoExtension.class)
class SakilaUserDetailsServiceTest {

@Mock private StaffRepository staffRepository;

@InjectMocks private SakilaUserDetailsService sakilaUserDetailsService;

@Test
void loadUserByUsername() {
Staff staff =
Staff.builder()
.id(1)
.username("Mike")
.password("encoded")
.firstName("Mike")
.lastName("Hillyer")
.active(true)
.build();
when(staffRepository.findByUsername("Mike")).thenReturn(Optional.of(staff));

SakilaUserDetails userDetails =
(SakilaUserDetails) sakilaUserDetailsService.loadUserByUsername("Mike");

assertEquals("Mike", userDetails.getUsername());
assertEquals("encoded", userDetails.getPassword());
assertTrue(userDetails.isAccountNonLocked());
assertEquals(staff, userDetails.staff());
}

@Test
void loadUserByUsernameNotFound() {
when(staffRepository.findByUsername("Unknown")).thenReturn(empty());
assertThrows(
UsernameNotFoundException.class,
() -> sakilaUserDetailsService.loadUserByUsername("Unknown"));
}
}

SecurityConfig​

config/security/SecurityConfigMockMvcTest.java
package dev.pollito.spring_java.config.security;

import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class SecurityConfigMockMvcTest {

@Autowired private MockMvc mockMvc;

private void assertEndpoint(
@NonNull HttpMethod method, String path, String body, int expectedStatus) throws Exception {
MockHttpServletRequestBuilder request =
switch (method.name()) {
case "GET" -> get(path);
case "POST" -> post(path);
default -> throw new IllegalArgumentException("Unsupported method: " + method);
};

if (body != null && !body.isBlank()) {
request = request.contentType(APPLICATION_JSON).content(body);
}

mockMvc.perform(request.accept(APPLICATION_JSON)).andExpect(status().is(expectedStatus));
}

@Nested
@DisplayName("Film endpoints")
class Films {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /api/films | | 200
GET | /api/films/1 | | 200
POST | /api/films | {} | 401
""")
void filmEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Auth endpoints")
class Auth {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
POST | /api/auth/login | {"username":"Mike","password":"password"} | 200
GET | /api/auth/me | | 401
""")
void authEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Actuator endpoints")
class Actuator {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /actuator/health | | 200
""")
void actuatorEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("H2 Console")
class H2Console {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /h2-console | | 404
""")
void h2ConsoleEndpoints(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}

@Nested
@DisplayName("Error endpoint")
class ErrorEndpoint {

@ParameterizedTest(name = "{0} {1} returns {3}")
@CsvSource(
delimiter = '|',
textBlock =
"""
GET | /error | | 500
""")
void errorEndpoint(String method, String path, String body, int expectedStatus)
throws Exception {
assertEndpoint(HttpMethod.valueOf(method), path, body, expectedStatus);
}
}
}

AuthRestController​

sakila/auth/adapter/in/rest/AuthRestControllerMockMvcTest.java
package dev.pollito.spring_java.sakila.auth.adapter.in.rest;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetails;
import dev.pollito.spring_java.sakila.auth.domain.port.in.AuthUseCases;
import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(AuthRestController.class)
@AutoConfigureMockMvc(addFilters = false)
@Import({AuthRestMapperImpl.class})
class AuthRestControllerMockMvcTest {

@Autowired private MockMvc mockMvc;

@MockitoBean private AuthUseCases authUseCases;

@MockitoSpyBean private AuthRestMapper authRestMapper;

@MockitoBean private UserDetailsService userDetailsService;

@Test
void loginReturnsToken() throws Exception {
when(authUseCases.authenticate(any(), any())).thenReturn("jwt-token-123");

mockMvc
.perform(
post("/api/auth/login")
.contentType(APPLICATION_JSON)
.content("{\"username\":\"Mike\",\"password\":\"password\"}")
.accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").value("jwt-token-123"));
}

static @NonNull Stream<SakilaUserDetails> getCurrentUserDetailsReturnsUserDetailsArgs() {
return Stream.of(
new SakilaUserDetails(
Staff.builder().id(1).firstName("Mike").active(true).username("Mike").build()),
null);
}

@ParameterizedTest
@MethodSource("getCurrentUserDetailsReturnsUserDetailsArgs")
void getCurrentUserDetailsReturnsUserDetails(SakilaUserDetails sakilaUserDetails)
throws Exception {
when(authUseCases.getCurrentUser()).thenReturn(sakilaUserDetails);

var resultActions =
mockMvc.perform(get("/api/auth/me").accept(APPLICATION_JSON)).andExpect(status().isOk());

if (sakilaUserDetails != null) {
resultActions
.andExpect(jsonPath("$.data.username").value("Mike"))
.andExpect(jsonPath("$.data.accountNonLocked").value(true))
.andExpect(jsonPath("$.data.authorities[0]").value("ROLE_STAFF"))
.andExpect(jsonPath("$.data.staff.username").value("Mike"));
}
}
}

AuthUseCasesImpl​

sakila/auth/domain/service/AuthUseCasesImplTest.java
package dev.pollito.spring_java.sakila.auth.domain.service;

import static java.util.Collections.emptyList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.security.core.context.SecurityContextHolder.clearContext;
import static org.springframework.security.core.context.SecurityContextHolder.getContext;

import dev.pollito.spring_java.config.security.jwt.JwtService;
import dev.pollito.spring_java.config.security.userdetails.SakilaUserDetails;
import dev.pollito.spring_java.sakila.staff.domain.model.Staff;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

@ExtendWith(MockitoExtension.class)
class AuthUseCasesImplTest {

@Mock private AuthenticationManager authenticationManager;
@Mock private JwtService jwtService;
@InjectMocks private AuthUseCasesImpl authUseCases;

@BeforeEach
void setUp() {
clearContext();
}

@AfterEach
void tearDown() {
clearContext();
}

private static @NonNull Stream<Arguments> provideInvalidAuthentications() {
Authentication authentication = mock(Authentication.class);
when(authentication.getPrincipal()).thenReturn("not-a-user-details");
return Stream.of(Arguments.of((Authentication) null), Arguments.of(authentication));
}

@Test
void authenticateReturnsToken() {
UserDetails userDetails =
User.builder().username("Mike").password("password").authorities(emptyList()).build();
String token = "jwt-token-123";
Authentication authentication = mock(Authentication.class);

when(authentication.getPrincipal()).thenReturn(userDetails);
when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.thenReturn(authentication);
when(jwtService.generateToken(userDetails)).thenReturn(token);

assertEquals(token, authUseCases.authenticate("Mike", "password"));
}

@Test
void getCurrentUserReturnsUser() {
Staff staff = Staff.builder().id(1).username("Mike").active(true).build();
SakilaUserDetails userDetails = new SakilaUserDetails(staff);
Authentication authentication = mock(Authentication.class);

when(authentication.getPrincipal()).thenReturn(userDetails);
getContext().setAuthentication(authentication);

SakilaUserDetails result = authUseCases.getCurrentUser();

assertEquals("Mike", result.getUsername());
assertTrue(result.isAccountNonLocked());
assertEquals(staff, result.staff());
}

@ParameterizedTest
@MethodSource("provideInvalidAuthentications")
void getCurrentUserInvalidAuthenticationThrowsIllegalStateException(
Authentication authentication) {
getContext().setAuthentication(authentication);

IllegalStateException ex =
assertThrows(IllegalStateException.class, () -> authUseCases.getCurrentUser());

assertEquals("No authenticated user found", ex.getMessage());
}
}

Probálo​

Ejemplo de autenticación​

Terminal
$ curl -s 'POST' \
'http://localhost:8080/api/auth/login' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"username": "Mike",
"password": "password"
}' | jq
{
"instance": "/api/auth/login",
"status": 200,
"timestamp": "2026-04-28T22:55:22.915386475+01:00",
"trace": "89784b93d7c8e7e5fc5dc26175d8315f",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJNaWtlIiwiaWF0IjoxNzc3NDEzMzIyLCJleHAiOjE3Nzc0MTY5MjJ9.BJHNKRefBZHF8iRU9cS7N2hyQvo5MtMIrPG0IcOZGhE"
}
}
Scroll to zoom • Drag corner to resize

Ejemplo autorizado​

Recuperar el usuario autenticado actual​

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJNaWtlIiwiaWF0IjoxNzc3NDE0MjQyLCJleHAiOjE3Nzc0MTc4NDJ9.uvSsC412-2tX5ab_lAQFeSG3BrnzJw1JQj9Msr320ls' | jq
{
"instance": "/api/auth/me",
"status": 200,
"timestamp": "2026-04-28T23:11:30.415677563+01:00",
"trace": "e9ef841cc7107f2acc62d5a0bf25b422",
"data": {
"username": "Mike",
"accountNonLocked": true,
"authorities": [
"ROLE_STAFF"
],
"staff": {
"id": 1,
"firstName": "Mike",
"lastName": "Hillyer",
"username": "Mike",
"active": true,
"email": "Mike.Hillyer@sakilastaff.com"
}
}
}
Scroll to zoom • Drag corner to resize

Ejemplo no autorizado​

Falta el header Authorization​

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' | jq
{
"title": "Unauthorized",
"detail": "Full authentication is required to access this resource",
"instance": "/api/auth/me",
"status": 401,
"timestamp": "2026-04-28T23:24:03.462397829+01:00",
"trace": "0b6455353a71f296304ce5a0814d1f49"
}
Scroll to zoom • Drag corner to resize

Token de Authorization inválido​

Terminal
$ curl -s 'GET' \
'http://localhost:8080/api/auth/me' \
-H 'accept: application/json' \
-H 'Authorization: Bearer non-valid-token' | jq
{
"title": "Unauthorized",
"detail": "Invalid or malformed JWT token",
"instance": "/api/auth/me",
"status": 401,
"timestamp": "2026-04-28T23:28:28.845341983+01:00",
"trace": "2ff938e56d19299321753f40e60ceb20"
}
Scroll to zoom • Drag corner to resize