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