Autenticación JWT
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
- Java
- Kotlin
- Groovy
.
├── 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
.
├── build.gradle.kts
├── ...
└── src
├── main
│ ├── kotlin
│ │ └── dev
│ │ └── pollito
│ │ └── spring_kotlin
│ │ ├── config
│ │ │ ├── security
│ │ │ │ ├── SecurityConfig.kt
│ │ │ │ ├── handler
│ │ │ │ │ ├── AuthenticationErrorResponseWriter.kt
│ │ │ │ │ ├── CustomAccessDeniedHandler.kt
│ │ │ │ │ └── CustomAuthenticationEntryPoint.kt
│ │ │ │ ├── jwt
│ │ │ │ │ ├── JwtAuthenticationFilter.kt
│ │ │ │ │ ├── JwtProperties.kt
│ │ │ │ │ └── JwtService.kt
│ │ │ │ └── userdetails
│ │ │ │ ├── SakilaUserDetails.kt
│ │ │ │ └── SakilaUserDetailsService.kt
│ │ │ └── web
│ │ │ └── ControllerAdvice.kt
│ │ └── sakila
│ │ ├── auth
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── AuthRestController.kt
│ │ │ │ └── AuthRestMapper.kt
│ │ │ └── domain
│ │ │ ├── port
│ │ │ │ └── in
│ │ │ │ └── AuthUseCases.kt
│ │ │ └── service
│ │ │ └── AuthUseCasesImpl.kt
│ │ └── staff
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── StaffJpaMapper.kt
│ │ │ ├── StaffJpaRepository.kt
│ │ │ └── StaffRepositoryImpl.kt
│ │ └── domain
│ │ ├── model
│ │ │ └── Staff.kt
│ │ └── port
│ │ └── out
│ │ └── StaffRepository.kt
│ └── resources
│ ├── application.yaml
│ ├── openapi.yaml
│ ├── sakila-data.sql
│ └── sakila-schema.sql
└── test
└── kotlin
└── dev
└── pollito
└── spring_kotlin
├── config
│ └── security
│ ├── SecurityConfigMockMvcTest.kt
│ ├── jwt
│ │ ├── JwtAuthenticationFilterTest.kt
│ │ └── JwtServiceTest.kt
│ └── userdetails
│ └── SakilaUserDetailsServiceTest.kt
└── sakila
├── auth
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── AuthRestControllerMockMvcTest.kt
│ └── domain
│ └── service
│ └── AuthUseCasesImplTest.kt
└── film
└── adapter
└── in
└── rest
└── FilmRestControllerMockMvcTest.kt
.
├── build.gradle
├── ...
└── src
├── main
│ ├── groovy
│ │ └── dev
│ │ └── pollito
│ │ └── spring_groovy
│ │ ├── config
│ │ │ ├── security
│ │ │ │ ├── SecurityConfig.groovy
│ │ │ │ ├── handler
│ │ │ │ │ ├── AuthenticationErrorResponseWriter.groovy
│ │ │ │ │ ├── CustomAccessDeniedHandler.groovy
│ │ │ │ │ └── CustomAuthenticationEntryPoint.groovy
│ │ │ │ ├── jwt
│ │ │ │ │ ├── JwtAuthenticationFilter.groovy
│ │ │ │ │ ├── JwtProperties.groovy
│ │ │ │ │ └── JwtService.groovy
│ │ │ │ └── userdetails
│ │ │ │ ├── SakilaUserDetails.groovy
│ │ │ │ └── SakilaUserDetailsService.groovy
│ │ │ └── web
│ │ │ └── ControllerAdvice.groovy
│ │ └── sakila
│ │ ├── auth
│ │ │ ├── adapter
│ │ │ │ └── in
│ │ │ │ └── rest
│ │ │ │ ├── AuthRestController.groovy
│ │ │ │ └── AuthRestMapper.groovy
│ │ │ └── domain
│ │ │ ├── port
│ │ │ │ └── in
│ │ │ │ └── AuthUseCases.groovy
│ │ │ └── service
│ │ │ └── AuthUseCasesImpl.groovy
│ │ └── staff
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── StaffJpaMapper.groovy
│ │ │ ├── StaffJpaRepository.groovy
│ │ │ └── StaffRepositoryImpl.groovy
│ │ └── domain
│ │ ├── model
│ │ │ └── Staff.groovy
│ │ └── port
│ │ └── out
│ │ └── StaffRepository.groovy
│ └── resources
│ ├── application.yaml
│ ├── openapi.yaml
│ ├── sakila-data.sql
│ └── sakila-schema.sql
└── test
└── groovy
└── dev
└── pollito
└── spring_groovy
├── config
│ └── security
│ ├── SecurityConfigMockMvcSpec.groovy
│ ├── jwt
│ │ ├── JwtAuthenticationFilterSpec.groovy
│ │ └── JwtServiceSpec.groovy
│ └── userdetails
│ └── SakilaUserDetailsServiceSpec.groovy
└── sakila
├── auth
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── AuthRestControllerMockMvcSpec.groovy
│ └── domain
│ └── service
│ └── AuthUseCasesImplSpec.groovy
└── film
└── adapter
└── in
└── rest
└── FilmRestControllerMockMvcSpec.groovy
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:
- Java
- Kotlin
- Groovy
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'
val 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")
kaptTest("org.springframework.boot:spring-boot-test-autoconfigure")
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-securitytrae Spring Security y su cadena de filtros.jjwt-api,jjwt-implyjjwt-jacksonse encargan de la creación y el parseo de tokens.spring-security-testnos 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:
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;
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:
- Java
- Kotlin
- Groovy
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
Un objeto de propiedades tipado vincula esos valores en tiempo de ejecución:
- Java
- Kotlin
- Groovy
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) {}
package dev.pollito.spring_kotlin.config.security.jwt
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(val secret: String, val expirationMs: Long)
package dev.pollito.spring_groovy.config.security.jwt
import groovy.transform.CompileStatic
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "jwt")
@CompileStatic
class JwtProperties {
String secret
long expirationMs
}
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
| Aspecto | Recomendación |
|---|---|
| Longitud | Al menos 32 bytes (256 bits). Para HS256/HS512, más largo está bien pero 32+ es el mínimo. |
| Generación | Usá un generador de aleatorios criptográficamente seguro. Ejemplos: openssl rand -hex 32 o openssl rand -base64 32 |
| Almacenamiento | Guardalo como una variable de entorno (que tu proyecto ya hace vía ${JWT_SECRET}) — nunca lo commitees a Git. |
| Rotación | Tené una estrategia para rotarlo periódicamente sin invalidar todas las sesiones activas inmediatamente (por ejemplo, soportar headers de key IDs / kid). |
| Unicidad | Usá 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/logines 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.
- Java
- Kotlin
- Groovy
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();
}
}
package dev.pollito.spring_kotlin.config.security
import dev.pollito.spring_kotlin.config.security.handler.CustomAccessDeniedHandler
import dev.pollito.spring_kotlin.config.security.handler.CustomAuthenticationEntryPoint
import dev.pollito.spring_kotlin.config.security.jwt.JwtAuthenticationFilter
import dev.pollito.spring_kotlin.config.security.jwt.JwtProperties
import dev.pollito.spring_kotlin.config.security.jwt.JwtService
import dev.pollito.spring_kotlin.config.security.userdetails.SakilaUserDetailsService
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
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.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.UserDetailsService
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)
class SecurityConfig(
private val sakilaUserDetailsService: SakilaUserDetailsService,
private val authenticationEntryPoint: CustomAuthenticationEntryPoint,
private val accessDeniedHandler: CustomAccessDeniedHandler,
) {
@Bean
fun securityFilterChain(
http: HttpSecurity,
jwtAuthenticationFilter: JwtAuthenticationFilter,
): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers("/h2-console/**")
.permitAll()
.requestMatchers("/error")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/films/**")
.permitAll()
.requestMatchers("/api/auth/login")
.permitAll()
.anyRequest()
.authenticated()
}
.exceptionHandling {
it.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun jwtAuthenticationFilter(
jwtService: JwtService,
userDetailsService: UserDetailsService,
): JwtAuthenticationFilter {
return JwtAuthenticationFilter(jwtService, userDetailsService)
}
@Bean
fun authenticationProvider(): AuthenticationProvider {
val authProvider = DaoAuthenticationProvider(sakilaUserDetailsService)
authProvider.setPasswordEncoder(passwordEncoder())
return authProvider
}
@Bean
fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager {
return config.authenticationManager
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}
package dev.pollito.spring_groovy.config.security
import static org.springframework.http.HttpMethod.GET
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS
import dev.pollito.spring_groovy.config.security.handler.CustomAccessDeniedHandler
import dev.pollito.spring_groovy.config.security.handler.CustomAuthenticationEntryPoint
import dev.pollito.spring_groovy.config.security.jwt.JwtAuthenticationFilter
import dev.pollito.spring_groovy.config.security.jwt.JwtProperties
import dev.pollito.spring_groovy.config.security.jwt.JwtService
import dev.pollito.spring_groovy.config.security.userdetails.SakilaUserDetailsService
import groovy.transform.CompileStatic
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.core.userdetails.UserDetailsService
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)
@CompileStatic
class SecurityConfig {
private final SakilaUserDetailsService sakilaUserDetailsService
private final CustomAuthenticationEntryPoint authenticationEntryPoint
private final CustomAccessDeniedHandler accessDeniedHandler
SecurityConfig(SakilaUserDetailsService sakilaUserDetailsService,
CustomAuthenticationEntryPoint authenticationEntryPoint,
CustomAccessDeniedHandler accessDeniedHandler) {
this.sakilaUserDetailsService = sakilaUserDetailsService
this.authenticationEntryPoint = authenticationEntryPoint
this.accessDeniedHandler = accessDeniedHandler
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) {
http
.csrf { AbstractHttpConfigurer csrf -> csrf.disable() }
.sessionManagement { it.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 {
it.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
}
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter)
http.build()
}
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
new JwtAuthenticationFilter(jwtService, userDetailsService)
}
@Bean
AuthenticationProvider authenticationProvider() {
def authProvider = new DaoAuthenticationProvider(sakilaUserDetailsService)
authProvider.setPasswordEncoder(passwordEncoder())
authProvider
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration config) {
config.authenticationManager
}
@Bean
PasswordEncoder passwordEncoder() {
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:
- Java
- Kotlin
- Groovy
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));
}
}
package dev.pollito.spring_kotlin.config.security.jwt
import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts.builder
import io.jsonwebtoken.Jwts.parser
import io.jsonwebtoken.security.Keys.hmacShaKeyFor
import java.lang.System.currentTimeMillis
import java.nio.charset.StandardCharsets.UTF_8
import java.util.Date
import javax.crypto.SecretKey
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
@Service
class JwtService(private val jwtProperties: JwtProperties) {
fun generateToken(userDetails: UserDetails): String {
return builder()
.subject(userDetails.username)
.issuedAt(Date(currentTimeMillis()))
.expiration(Date(currentTimeMillis() + jwtProperties.expirationMs))
.signWith(signInKey())
.compact()
}
fun isTokenValid(token: String, userDetails: UserDetails): Boolean {
return extractUsername(token) == userDetails.username && !isTokenExpired(token)
}
fun extractUsername(token: String): String? {
return extractClaim(token, Claims::getSubject)
}
fun <T> extractClaim(token: String, claimsResolver: (Claims) -> T): T? {
return try {
claimsResolver(extractAllClaims(token))
} catch (e: ExpiredJwtException) {
claimsResolver(e.claims)
}
}
private fun isTokenExpired(token: String): Boolean {
return extractClaim(token, Claims::getExpiration)?.before(Date()) ?: true
}
private fun extractAllClaims(token: String): Claims {
return parser().verifyWith(signInKey()).build().parseSignedClaims(token).payload
}
private fun signInKey(): SecretKey {
return hmacShaKeyFor(jwtProperties.secret.toByteArray(UTF_8))
}
}
package dev.pollito.spring_groovy.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 groovy.transform.CompileStatic
import io.jsonwebtoken.Claims
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import java.util.Date
import javax.crypto.SecretKey
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Service
@Service
@CompileStatic
class JwtService {
private final JwtProperties jwtProperties
JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties
}
String generateToken(UserDetails userDetails) {
Jwts.builder()
.subject(userDetails.username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.expirationMs))
.signWith(signInKey())
.compact()
}
boolean isTokenValid(String token, UserDetails userDetails) {
extractUsername(token) == userDetails.username && !isTokenExpired(token)
}
String extractUsername(String token) {
extractClaim(token, { Claims c -> c.subject })
}
private boolean isTokenExpired(String token) {
Date expiration = extractClaim(token, { Claims c -> c.expiration })
expiration != null ? expiration.before(new Date()) : true
}
private <T> T extractClaim(String token, Closure<T> claimsResolver) {
try {
claimsResolver.call(extractAllClaims(token))
} catch (ExpiredJwtException e) {
claimsResolver.call(e.claims)
}
}
private Claims extractAllClaims(String token) {
parser().verifyWith(signInKey()).build().parseSignedClaims(token).payload
}
private SecretKey signInKey() {
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:
- Java
- Kotlin
- Groovy
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);
}
}
package dev.pollito.spring_kotlin.config.security.jwt
import io.jsonwebtoken.JwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.InsufficientAuthenticationException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
class JwtAuthenticationFilter(
private val jwtService: JwtService,
private val userDetailsService: UserDetailsService,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
val token = authHeader.substring(7)
val username =
try {
jwtService.extractUsername(token)
} catch (e: JwtException) {
throw InsufficientAuthenticationException("Invalid or malformed JWT token", e)
}
if (username == null || SecurityContextHolder.getContext().authentication != null) {
filterChain.doFilter(request, response)
return
}
val userDetails = userDetailsService.loadUserByUsername(username)
if (!jwtService.isTokenValid(token, userDetails)) {
filterChain.doFilter(request, response)
return
}
val authToken = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
authToken.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = authToken
filterChain.doFilter(request, response)
}
}
package dev.pollito.spring_groovy.config.security.jwt
import groovy.transform.CompileStatic
import io.jsonwebtoken.JwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.InsufficientAuthenticationException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
@CompileStatic
class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService
private final UserDetailsService userDetailsService
JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService
this.userDetailsService = userDetailsService
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
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 || SecurityContextHolder.context.authentication != null) {
filterChain.doFilter(request, response)
return
}
def userDetails = userDetailsService.loadUserByUsername(username)
if (!jwtService.isTokenValid(token, userDetails)) {
filterChain.doFilter(request, response)
return
}
def authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
authToken.details = new WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.context.authentication = 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:
- Java
- Kotlin
- Groovy
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();
}
}
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));
}
}
package dev.pollito.spring_kotlin.config.security.userdetails
import dev.pollito.spring_kotlin.sakila.staff.domain.model.Staff
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
class SakilaUserDetails(val staff: Staff) : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority> {
return listOf(SimpleGrantedAuthority("ROLE_STAFF"))
}
override fun getPassword(): String {
return staff.password
}
override fun getUsername(): String {
return staff.username
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return staff.active
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
}
package dev.pollito.spring_kotlin.config.security.userdetails
import dev.pollito.spring_kotlin.sakila.staff.domain.port.out.StaffRepository
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
class SakilaUserDetailsService(private val staffRepository: StaffRepository) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
// Composite lookup: tries staff first, ready for future customer fallback.
// Example: .or { customerRepository.findByUsername(username).map(::SakilaUserDetails) }
// Both staff and customers will share /api/auth/login.
return staffRepository.findByUsername(username).map(::SakilaUserDetails).orElseThrow {
UsernameNotFoundException(username)
}
}
}
package dev.pollito.spring_groovy.config.security.userdetails
import dev.pollito.spring_groovy.sakila.staff.domain.model.Staff
import groovy.transform.CompileStatic
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
@CompileStatic
class SakilaUserDetails implements UserDetails {
final Staff staff
SakilaUserDetails(Staff staff) {
this.staff = staff
}
@Override
String getUsername() {
staff.username
}
@Override
String getPassword() {
staff.password
}
@Override
Collection<? extends GrantedAuthority> getAuthorities() {
[
new SimpleGrantedAuthority("ROLE_STAFF")
]
}
@Override
boolean isAccountNonLocked() {
staff.active
}
}
package dev.pollito.spring_groovy.config.security.userdetails
import dev.pollito.spring_groovy.sakila.staff.domain.port.out.StaffRepository
import groovy.transform.CompileStatic
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
@CompileStatic
class SakilaUserDetailsService implements UserDetailsService {
private final StaffRepository staffRepository
SakilaUserDetailsService(StaffRepository staffRepository) {
this.staffRepository = staffRepository
}
@Override
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
staffRepository.findByUsername(username)
.map { new SakilaUserDetails(it) }
.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:
- Java
- Kotlin
- Groovy
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()));
}
}
package dev.pollito.spring_kotlin.sakila.auth.adapter.`in`.rest
import dev.pollito.spring_kotlin.sakila.auth.domain.port.`in`.AuthUseCases
import dev.pollito.spring_kotlin.sakila.generated.api.AuthApi
import dev.pollito.spring_kotlin.sakila.generated.model.LoginRequest
import dev.pollito.spring_kotlin.sakila.generated.model.LoginResponse
import dev.pollito.spring_kotlin.sakila.generated.model.LoginResponseAllOfData
import dev.pollito.spring_kotlin.sakila.generated.model.UserDetailsResponse
import io.opentelemetry.api.trace.Span
import jakarta.servlet.http.HttpServletRequest
import java.time.OffsetDateTime
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
@RestController
class AuthRestController(
private val authUseCases: AuthUseCases,
private val authRestMapper: AuthRestMapper,
private val request: HttpServletRequest,
) : AuthApi {
override fun login(loginRequest: LoginRequest): ResponseEntity<LoginResponse> {
return ResponseEntity.ok(
LoginResponse(
instance = request.requestURI,
status = 200,
timestamp = OffsetDateTime.now(),
trace = Span.current().spanContext.traceId,
data =
LoginResponseAllOfData(
authUseCases.authenticate(loginRequest.username, loginRequest.password)
),
)
)
}
override fun getCurrentUserDetails(): ResponseEntity<UserDetailsResponse> {
val userDetails = authUseCases.getCurrentUser()
return ResponseEntity.ok(
UserDetailsResponse(
instance = request.requestURI,
status = 200,
timestamp = OffsetDateTime.now(),
trace = Span.current().spanContext.traceId,
data = authRestMapper.map(userDetails),
)
)
}
}
package dev.pollito.spring_groovy.sakila.auth.adapter.in.rest
import static java.time.OffsetDateTime.now
import static org.springframework.http.HttpStatus.OK
import static org.springframework.http.ResponseEntity.ok
import dev.pollito.spring_groovy.sakila.auth.domain.port.in.AuthUseCases
import dev.pollito.spring_groovy.sakila.generated.api.AuthApi
import dev.pollito.spring_groovy.sakila.generated.model.LoginRequest
import dev.pollito.spring_groovy.sakila.generated.model.LoginResponse
import dev.pollito.spring_groovy.sakila.generated.model.LoginResponseAllOfData
import dev.pollito.spring_groovy.sakila.generated.model.UserDetailsResponse
import groovy.transform.CompileStatic
import io.opentelemetry.api.trace.Span
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
@RestController
@CompileStatic
class AuthRestController implements AuthApi {
private final AuthUseCases authUseCases
private final AuthRestMapper mapper
private final HttpServletRequest request
AuthRestController(AuthUseCases authUseCases, AuthRestMapper mapper, HttpServletRequest request) {
this.authUseCases = authUseCases
this.mapper = mapper
this.request = request
}
@Override
ResponseEntity<LoginResponse> login(LoginRequest loginRequest) {
ok(
new LoginResponse()
.data(
new LoginResponseAllOfData()
.token(authUseCases.authenticate(loginRequest.username, loginRequest.password))
)
.instance(request.requestURI)
.status(OK.value())
.timestamp(now())
.trace(Span.current().spanContext.traceId)
)
}
@Override
ResponseEntity<UserDetailsResponse> getCurrentUserDetails() {
ok(
new UserDetailsResponse()
.data(mapper.map(authUseCases.getCurrentUser()))
.instance(request.requestURI)
.status(OK.value())
.timestamp(now())
.trace(Span.current().spanContext.traceId)
)
}
}
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.
- Java
- Kotlin
- Groovy
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Error> handle(AuthenticationException e) {
return buildProblemDetail(e, UNAUTHORIZED);
}
@ExceptionHandler(AuthenticationException::class)
fun handle(e: AuthenticationException): ResponseEntity<Error> {
return buildErrorResponse(e, UNAUTHORIZED)
}
@ExceptionHandler(AuthenticationException)
ResponseEntity<Error> handle(AuthenticationException e) {
buildErrorResponse(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.
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
- Java
- Kotlin
- Groovy
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());
}
}
package dev.pollito.spring_kotlin.config.security.jwt
import io.jsonwebtoken.MalformedJwtException
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import java.lang.reflect.InvocationTargetException
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import org.springframework.security.authentication.InsufficientAuthenticationException
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.context.SecurityContextHolder.clearContext
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
class JwtAuthenticationFilterTest {
private val jwtService: JwtService = mockk()
private val userDetailsService: UserDetailsService = mockk()
private val filter = JwtAuthenticationFilter(jwtService, userDetailsService)
private fun doFilterInternal(
filter: JwtAuthenticationFilter,
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val method =
JwtAuthenticationFilter::class
.java
.getDeclaredMethod(
"doFilterInternal",
HttpServletRequest::class.java,
HttpServletResponse::class.java,
FilterChain::class.java,
)
method.isAccessible = true
try {
method.invoke(filter, request, response, filterChain)
} catch (e: InvocationTargetException) {
throw e.cause ?: e
}
}
@Test
fun `no authorization header proceeds chain`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
every { request.getHeader("Authorization") } returns null
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
}
@Test
fun `authorization without Bearer prefix proceeds chain`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
every { request.getHeader("Authorization") } returns "Basic abc"
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
}
@Test
fun `malformed token throws InsufficientAuthenticationException`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
every { request.getHeader("Authorization") } returns "Bearer invalid-token"
every { jwtService.extractUsername("invalid-token") } throws MalformedJwtException("bad")
assertFailsWith<InsufficientAuthenticationException> {
doFilterInternal(filter, request, response, filterChain)
}
}
@Test
fun `null username from token proceeds chain`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
every { request.getHeader("Authorization") } returns "Bearer token"
every { jwtService.extractUsername("token") } returns null
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
}
@Test
fun `existing authentication proceeds chain`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
every { request.getHeader("Authorization") } returns "Bearer token"
every { jwtService.extractUsername("token") } returns "Mike"
SecurityContextHolder.getContext().authentication = mockk()
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
clearContext()
}
@Test
fun `invalid token proceeds chain`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
val userDetails = User("Mike", "password", emptyList())
every { request.getHeader("Authorization") } returns "Bearer token"
every { jwtService.extractUsername("token") } returns "Mike"
every { userDetailsService.loadUserByUsername("Mike") } returns userDetails
every { jwtService.isTokenValid("token", any()) } returns false
clearContext()
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
}
@Test
fun `valid token sets authentication`() {
val request: HttpServletRequest = mockk(relaxed = true)
val response: HttpServletResponse = mockk(relaxed = true)
val filterChain: FilterChain = mockk(relaxed = true)
val userDetails = User("Mike", "password", emptyList())
every { request.getHeader("Authorization") } returns "Bearer token"
every { jwtService.extractUsername("token") } returns "Mike"
every { userDetailsService.loadUserByUsername("Mike") } returns userDetails
every { jwtService.isTokenValid("token", any()) } returns true
clearContext()
doFilterInternal(filter, request, response, filterChain)
verify { filterChain.doFilter(request, response) }
assertNotNull(SecurityContextHolder.getContext().authentication)
clearContext()
}
}
package dev.pollito.spring_groovy.config.security.jwt
import static org.springframework.security.core.context.SecurityContextHolder.context
import io.jsonwebtoken.MalformedJwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.InsufficientAuthenticationException
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import spock.lang.Specification
class JwtAuthenticationFilterSpec extends Specification {
def jwtService = Mock(JwtService)
def userDetailsService = Mock(UserDetailsService)
def filter = new JwtAuthenticationFilter(jwtService, userDetailsService)
def request = Mock(HttpServletRequest)
def response = Mock(HttpServletResponse)
def filterChain = Mock(FilterChain)
def setup() {
SecurityContextHolder.clearContext()
}
def cleanup() {
SecurityContextHolder.clearContext()
}
def "no authorization header proceeds chain"() {
given:
request.getHeader("Authorization") >> null
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication == null
}
def "authorization without Bearer prefix proceeds chain"() {
given:
request.getHeader("Authorization") >> "Basic abc"
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication == null
}
def "malformed token throws InsufficientAuthenticationException"() {
given:
request.getHeader("Authorization") >> "Bearer invalid-token"
jwtService.extractUsername("invalid-token") >> { throw new MalformedJwtException("bad") }
when:
filter.doFilterInternal(request, response, filterChain)
then:
thrown(InsufficientAuthenticationException)
}
def "null username from token proceeds chain"() {
given:
request.getHeader("Authorization") >> "Bearer token"
jwtService.extractUsername("token") >> null
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication == null
}
def "existing authentication proceeds chain"() {
given:
request.getHeader("Authorization") >> "Bearer token"
jwtService.extractUsername("token") >> "Mike"
def existingAuth = Mock(Authentication)
context.authentication = existingAuth
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication.is(existingAuth)
}
def "invalid token proceeds chain"() {
given:
request.getHeader("Authorization") >> "Bearer token"
jwtService.extractUsername("token") >> "Mike"
def userDetails = User.builder().username("Mike").password("password").authorities([]).build()
userDetailsService.loadUserByUsername("Mike") >> userDetails
jwtService.isTokenValid("token", userDetails) >> false
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication == null
}
def "valid token sets authentication"() {
given:
request.getHeader("Authorization") >> "Bearer token"
jwtService.extractUsername("token") >> "Mike"
def userDetails = User.builder().username("Mike").password("password").authorities([]).build()
userDetailsService.loadUserByUsername("Mike") >> userDetails
jwtService.isTokenValid("token", userDetails) >> true
when:
filter.doFilterInternal(request, response, filterChain)
then:
1 * filterChain.doFilter(request, response)
context.authentication != null
context.authentication.principal == userDetails
context.authentication.authenticated
}
}
JwtService
- Java
- Kotlin
- Groovy
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));
}
}
package dev.pollito.spring_kotlin.config.security.jwt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import org.springframework.security.core.userdetails.User
class JwtServiceTest {
private val jwtProperties = JwtProperties(secret = TEST_SECRET, expirationMs = 3600000)
private val jwtService = JwtService(jwtProperties)
companion object {
private const val TEST_SECRET =
"test-secret-key-that-is-long-enough-for-HS256-algorithm-1234567890abcdef"
}
@Test
fun `generateToken and validate success`() {
val userDetails = User("Mike", "password", emptyList())
val token = jwtService.generateToken(userDetails)
assertNotNull(token)
assertTrue(jwtService.isTokenValid(token, userDetails))
assertEquals("Mike", jwtService.extractUsername(token))
}
@Test
fun `token fails validation for different user`() {
val userDetails = User("Mike", "password", emptyList())
val otherUser = User("Jon", "password", emptyList())
val token = jwtService.generateToken(userDetails)
assertFalse(jwtService.isTokenValid(token, otherUser))
}
@Test
fun `expired token fails validation`() {
val expiredProperties = JwtProperties(secret = TEST_SECRET, expirationMs = -1)
val expiredJwtService = JwtService(expiredProperties)
val userDetails = User("Mike", "password", emptyList())
val token = expiredJwtService.generateToken(userDetails)
assertFalse(expiredJwtService.isTokenValid(token, userDetails))
}
@Test
fun `token without expiration claim is considered expired`() {
val tokenWithoutExp =
io.jsonwebtoken.Jwts.builder()
.subject("Mike")
.signWith(
io.jsonwebtoken.security.Keys.hmacShaKeyFor(
TEST_SECRET.toByteArray(Charsets.UTF_8)))
.compact()
val userDetails = User("Mike", "password", emptyList())
assertFalse(jwtService.isTokenValid(tokenWithoutExp, userDetails))
}
}
package dev.pollito.spring_groovy.config.security.jwt
import static io.jsonwebtoken.Jwts.builder
import static io.jsonwebtoken.security.Keys.hmacShaKeyFor
import static java.nio.charset.StandardCharsets.UTF_8
import static org.springframework.security.core.userdetails.User.withUsername
import spock.lang.Specification
class JwtServiceSpec extends Specification {
private static final String SECRET = "this-is-a-test-secret-key-make-it-long"
private static final long EXPIRATION_MS = 3600000
private JwtProperties jwtProperties = new JwtProperties(secret: SECRET, expirationMs: EXPIRATION_MS)
private JwtService jwtService = new JwtService(jwtProperties)
def "generateTokenAndValidate"() {
given:
def userDetails = withUsername("Mike").password("password").roles("STAFF").build()
when:
def token = jwtService.generateToken(userDetails)
then:
token != null
jwtService.isTokenValid(token, userDetails)
jwtService.extractUsername(token) == "Mike"
}
def "invalidTokenFailsValidation"() {
given:
def userDetails = withUsername("Mike").password("password").roles("STAFF").build()
def token = jwtService.generateToken(userDetails)
def otherUser = withUsername("Jon").password("password").roles("STAFF").build()
expect:
!jwtService.isTokenValid(token, otherUser)
}
def "expiredTokenFailsValidation"() {
given:
def expiredProperties = new JwtProperties(secret: SECRET, expirationMs: -1)
def expiredJwtService = new JwtService(expiredProperties)
def userDetails = withUsername("Mike").password("password").roles("STAFF").build()
def token = expiredJwtService.generateToken(userDetails)
expect:
!expiredJwtService.isTokenValid(token, userDetails)
}
def "tokenWithoutExpirationIsConsideredExpired"() {
given:
def userDetails = withUsername("Mike").password("password").roles("STAFF").build()
def token = builder()
.subject(userDetails.username)
.signWith(hmacShaKeyFor(SECRET.getBytes(UTF_8)))
.compact()
expect:
!jwtService.isTokenValid(token, userDetails)
}
}
SakilaUserDetailsService
- Java
- Kotlin
- Groovy
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"));
}
}
package dev.pollito.spring_kotlin.config.security.userdetails
import dev.pollito.spring_kotlin.sakila.staff.domain.model.Staff
import dev.pollito.spring_kotlin.sakila.staff.domain.port.out.StaffRepository
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import io.mockk.verify
import java.util.Optional
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.security.core.userdetails.UsernameNotFoundException
@ExtendWith(MockKExtension::class)
class SakilaUserDetailsServiceTest {
@MockK private lateinit var staffRepository: StaffRepository
@InjectMockKs private lateinit var sakilaUserDetailsService: SakilaUserDetailsService
companion object {
private val STAFF =
Staff(
id = 1,
firstName = "Mike",
lastName = "Hillyer",
username = "Mike",
password = "password",
email = "Mike.Hillyer@sakilastaff.com",
active = true,
)
}
@Test
fun `loadUserByUsername returns user details`() {
every { staffRepository.findByUsername("Mike") } returns Optional.of(STAFF)
val result = sakilaUserDetailsService.loadUserByUsername("Mike")
assertEquals("Mike", result.username)
assertEquals("password", result.password)
assertTrue(result.isAccountNonLocked)
assertEquals(STAFF, (result as SakilaUserDetails).staff)
verify { staffRepository.findByUsername("Mike") }
}
@Test
fun `loadUserByUsername throws when staff not found`() {
every { staffRepository.findByUsername("Unknown") } returns Optional.empty()
assertFailsWith<UsernameNotFoundException> {
sakilaUserDetailsService.loadUserByUsername("Unknown")
}
}
}
package dev.pollito.spring_groovy.config.security.userdetails
import dev.pollito.spring_groovy.sakila.staff.domain.model.Staff
import dev.pollito.spring_groovy.sakila.staff.domain.port.out.StaffRepository
import org.springframework.security.core.userdetails.UsernameNotFoundException
import spock.lang.Specification
class SakilaUserDetailsServiceSpec extends Specification {
def staffRepository = Mock(StaffRepository)
def sakilaUserDetailsService = new SakilaUserDetailsService(staffRepository)
def "loadUserByUsername returns user details"() {
given:
def staff = new Staff(id: 1, username: "Mike", password: "encoded", firstName: "Mike", lastName: "Hillyer", active: true)
staffRepository.findByUsername("Mike") >> Optional.of(staff)
when:
def userDetails = sakilaUserDetailsService.loadUserByUsername("Mike")
then:
userDetails.username == "Mike"
userDetails.password == "encoded"
userDetails.accountNonLocked
((SakilaUserDetails) userDetails).staff == staff
}
def "loadUserByUsername throws UsernameNotFoundException when not found"() {
given:
staffRepository.findByUsername("Unknown") >> Optional.empty()
when:
sakilaUserDetailsService.loadUserByUsername("Unknown")
then:
thrown(UsernameNotFoundException)
}
}
SecurityConfig
- Java
- Kotlin
- Groovy
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);
}
}
}
package dev.pollito.spring_kotlin.config.security
import kotlin.test.Test
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.MediaType
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.get
import org.springframework.test.web.servlet.post
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql(scripts = ["/sakila-schema.sql", "/sakila-data.sql"])
class SecurityConfigMockMvcTest {
@Autowired private lateinit var mockMvc: MockMvc
@Test
fun `GET films returns 200`() {
mockMvc
.get("/api/films") { accept = MediaType.APPLICATION_JSON }
.andExpect { status { isOk() } }
}
@Test
fun `GET films by id returns 200`() {
mockMvc
.get("/api/films/1") { accept = MediaType.APPLICATION_JSON }
.andExpect { status { isOk() } }
}
@Test
fun `POST films without auth returns 401`() {
mockMvc
.post("/api/films") {
contentType = MediaType.APPLICATION_JSON
content =
"{\"title\":\"TEST\",\"language\":\"English\",\"rentalDuration\":3,\"rentalRate\":4.99,\"replacementCost\":20.99}"
}
.andExpect { status { isUnauthorized() } }
}
@Test
fun `POST login with valid credentials returns 200`() {
mockMvc
.post("/api/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = "{\"username\":\"Mike\",\"password\":\"password\"}"
}
.andExpect { status { isOk() } }
}
@Test
fun `GET auth me without auth returns 401`() {
mockMvc
.get("/api/auth/me") { accept = MediaType.APPLICATION_JSON }
.andExpect { status { isUnauthorized() } }
}
@Test
fun `GET actuator health returns 200`() {
mockMvc.get("/actuator/health").andExpect { status { isOk() } }
}
@Test
fun `GET h2-console returns 404 in test profile`() {
mockMvc.get("/h2-console").andExpect { status { isNotFound() } }
}
@Test
fun `GET error returns 500`() {
mockMvc.get("/error").andExpect { status { isInternalServerError() } }
}
}
package dev.pollito.spring_groovy.config.security
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.status
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
import spock.lang.Specification
import spock.lang.Unroll
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Sql(scripts = ["/sakila-schema.sql", "/sakila-data.sql"], executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class SecurityConfigMockMvcSpec extends Specification {
@Autowired
MockMvc mockMvc
private static final Map<HttpMethod, Closure<MockHttpServletRequestBuilder>> REQUEST_BUILDERS = [
(HttpMethod.GET) : { String p -> get(p) },
(HttpMethod.POST): { String p ->
post(p)
}
]
private void assertEndpoint(HttpMethod method, String path, String body, int expectedStatus) {
def builder = REQUEST_BUILDERS[method]
if (!builder) {
throw new IllegalArgumentException("Unsupported method: " + method)
}
MockHttpServletRequestBuilder request = builder.call(path)
if (body != null && !body.isBlank()) {
request = request.contentType(APPLICATION_JSON).content(body)
}
mockMvc.perform(request.accept(APPLICATION_JSON))
.andExpect(status().is(expectedStatus))
}
@Unroll
def "#method #path returns #expectedStatus"() {
when:
assertEndpoint(method, path, body, expectedStatus)
then:
noExceptionThrown()
where:
method | path | body | expectedStatus
HttpMethod.GET | "/api/films" | null | 200
HttpMethod.GET | "/api/films/1" | null | 200
HttpMethod.POST | "/api/films" | "{}" | 401
HttpMethod.POST | "/api/auth/login"| '{"username":"Mike","password":"password"}'| 200
HttpMethod.GET | "/api/auth/me" | null | 401
HttpMethod.GET | "/actuator/health"| null | 200
HttpMethod.GET | "/error" | null | 500
}
}
AuthRestController
- Java
- Kotlin
- Groovy
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"));
}
}
}
package dev.pollito.spring_kotlin.sakila.auth.adapter.`in`.rest
import com.ninjasquad.springmockk.MockkBean
import dev.pollito.spring_kotlin.config.security.userdetails.SakilaUserDetails
import dev.pollito.spring_kotlin.config.web.ControllerAdvice
import dev.pollito.spring_kotlin.sakila.auth.domain.port.`in`.AuthUseCases
import dev.pollito.spring_kotlin.sakila.staff.domain.model.Staff
import io.mockk.every
import kotlin.test.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
@WebMvcTest(AuthRestController::class)
@Import(ControllerAdvice::class, AuthRestMapperImpl::class)
class AuthRestControllerMockMvcTest {
@MockkBean private lateinit var authUseCases: AuthUseCases
@MockkBean private lateinit var userDetailsService: UserDetailsService
@Autowired private lateinit var mockMvc: MockMvc
@Test
fun `login returns token`() {
every { authUseCases.authenticate("Mike", "1234") } returns "jwt-token"
mockMvc
.post("/api/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = "{\"username\":\"Mike\",\"password\":\"1234\"}"
}
.andExpect {
status { isOk() }
jsonPath("$.data.token") { value("jwt-token") }
}
}
@ParameterizedTest
@CsvSource("Mike,true", "null,false")
fun `getCurrentUserDetails returns user details`(username: String, active: Boolean) {
val staff =
if (username == "null") null
else
Staff(
id = 1,
firstName = "Mike",
lastName = "Hillyer",
username = username,
password = "password",
email = "Mike.Hillyer@sakilastaff.com",
active = active,
)
val userDetails = staff?.let { SakilaUserDetails(it) }
if (userDetails != null) {
every { authUseCases.getCurrentUser() } returns userDetails
mockMvc
.get("/api/auth/me") { accept = MediaType.APPLICATION_JSON }
.andExpect {
status { isOk() }
jsonPath("$.data.username") { value(username) }
jsonPath("$.data.accountNonLocked") { value(active) }
jsonPath("$.data.authorities[0]") { value("ROLE_STAFF") }
jsonPath("$.data.staff.username") { value(username) }
}
}
}
}
package dev.pollito.spring_groovy.sakila.auth.adapter.in.rest
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_groovy.config.mapper.ModelMapperConfig
import dev.pollito.spring_groovy.config.security.userdetails.SakilaUserDetails
import dev.pollito.spring_groovy.config.web.ControllerAdvice
import dev.pollito.spring_groovy.sakila.auth.domain.port.in.AuthUseCases
import dev.pollito.spring_groovy.sakila.staff.domain.model.Staff
import org.spockframework.spring.SpringBean
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.web.servlet.MockMvc
import spock.lang.Specification
@WebMvcTest(AuthRestController)
@AutoConfigureMockMvc(addFilters = false)
@Import([ControllerAdvice, AuthRestMapper, ModelMapperConfig])
class AuthRestControllerMockMvcSpec extends Specification {
private static final String LOGIN_PATH = "/api/auth/login"
private static final String ME_PATH = "/api/auth/me"
@Autowired
MockMvc mockMvc
@SpringBean
AuthUseCases authUseCases = Mock()
@SpringBean
UserDetailsService userDetailsService = Mock()
def "login returns token"() {
given:
authUseCases.authenticate("Mike", "password") >> "jwt-token-123"
when:
def result = mockMvc.perform(
post(LOGIN_PATH)
.contentType(APPLICATION_JSON)
.content('{"username":"Mike","password":"password"}')
.accept(APPLICATION_JSON)
)
then:
result
.andExpect(status().isOk())
.andExpect(jsonPath('$.data.token').value('jwt-token-123'))
}
def "getCurrentUserDetails returns user details"() {
given:
def staff = new Staff(id: 1, firstName: "Mike", username: "Mike", active: true)
def sakilaUserDetails = new SakilaUserDetails(staff)
authUseCases.getCurrentUser() >> sakilaUserDetails
when:
def result = mockMvc.perform(get(ME_PATH).accept(APPLICATION_JSON))
then:
result
.andExpect(status().isOk())
.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'))
}
def "getCurrentUserDetails handles null user"() {
given:
authUseCases.getCurrentUser() >> null
when:
def result = mockMvc.perform(get(ME_PATH).accept(APPLICATION_JSON))
then:
result.andExpect(status().isOk())
}
}
AuthUseCasesImpl
- Java
- Kotlin
- Groovy
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());
}
}
package dev.pollito.spring_kotlin.sakila.auth.domain.service
import dev.pollito.spring_kotlin.config.security.jwt.JwtService
import dev.pollito.spring_kotlin.config.security.userdetails.SakilaUserDetails
import dev.pollito.spring_kotlin.sakila.staff.domain.model.Staff
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import io.mockk.mockk
import io.mockk.verify
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder.clearContext
import org.springframework.security.core.context.SecurityContextHolder.getContext
@ExtendWith(MockKExtension::class)
class AuthUseCasesImplTest {
@MockK private lateinit var authenticationManager: AuthenticationManager
@MockK private lateinit var jwtService: JwtService
@InjectMockKs private lateinit var authUseCases: AuthUseCasesImpl
companion object {
private val SAKILA_USER_DETAILS =
SakilaUserDetails(
Staff(
id = 1,
firstName = "Mike",
lastName = "Hillyer",
username = "Mike",
password = "password",
email = "Mike.Hillyer@sakilastaff.com",
active = true,
)
)
}
@Test
fun `authenticate returns token`() {
val authentication = UsernamePasswordAuthenticationToken(SAKILA_USER_DETAILS, null, emptyList())
every { authenticationManager.authenticate(any<UsernamePasswordAuthenticationToken>()) } returns
authentication
every { jwtService.generateToken(SAKILA_USER_DETAILS) } returns "jwt-token"
val result = authUseCases.authenticate("Mike", "1234")
assertEquals("jwt-token", result)
verify { authenticationManager.authenticate(any<UsernamePasswordAuthenticationToken>()) }
verify { jwtService.generateToken(SAKILA_USER_DETAILS) }
}
@Test
fun `getCurrentUser returns user details`() {
val authentication = UsernamePasswordAuthenticationToken(SAKILA_USER_DETAILS, null, emptyList())
getContext().authentication = authentication
val result = authUseCases.getCurrentUser()
assertEquals(SAKILA_USER_DETAILS, result)
clearContext()
}
@ParameterizedTest
@CsvSource("null", "not_sakila_user")
fun `getCurrentUser throws when no authenticated user`(principalType: String) {
val authentication = mockk<org.springframework.security.core.Authentication>()
every { authentication.principal } returns
if (principalType == "null") null else "not_a_user_details"
getContext().authentication = authentication
assertFailsWith<IllegalArgumentException> { authUseCases.getCurrentUser() }
clearContext()
}
}
package dev.pollito.spring_groovy.sakila.auth.domain.service
import dev.pollito.spring_groovy.config.security.jwt.JwtService
import dev.pollito.spring_groovy.config.security.userdetails.SakilaUserDetails
import dev.pollito.spring_groovy.sakila.staff.domain.model.Staff
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.User
import spock.lang.Specification
class AuthUseCasesImplSpec extends Specification {
def authenticationManager = Mock(AuthenticationManager)
def jwtService = Mock(JwtService)
def authUseCases = new AuthUseCasesImpl(authenticationManager, jwtService)
def setup() {
SecurityContextHolder.clearContext()
}
def cleanup() {
SecurityContextHolder.clearContext()
}
def "authenticate returns token"() {
given:
def userDetails = User.builder().username("Mike").password("password").authorities([]).build()
def authentication = Mock(Authentication)
authentication.principal >> userDetails
authenticationManager.authenticate(_ as UsernamePasswordAuthenticationToken) >> authentication
jwtService.generateToken(userDetails) >> "jwt-token-123"
expect:
authUseCases.authenticate("Mike", "password") == "jwt-token-123"
}
def "getCurrentUser returns user"() {
given:
def staff = new Staff(id: 1, username: "Mike", active: true)
def userDetails = new SakilaUserDetails(staff)
def authentication = Mock(Authentication)
authentication.principal >> userDetails
SecurityContextHolder.context.authentication = authentication
when:
def result = authUseCases.getCurrentUser()
then:
result.username == "Mike"
result.accountNonLocked
result.staff == staff
}
def "getCurrentUser with null authentication throws IllegalStateException"() {
when:
authUseCases.getCurrentUser()
then:
def ex = thrown(IllegalStateException)
ex.message == "No authenticated user found"
}
def "getCurrentUser with non-SakilaUserDetails principal throws IllegalStateException"() {
given:
def authentication = Mock(Authentication)
authentication.principal >> "not-a-user-details"
SecurityContextHolder.context.authentication = authentication
when:
authUseCases.getCurrentUser()
then:
def ex = thrown(IllegalStateException)
ex.message == "No authenticated user found"
}
}
Probálo
Ejemplo de autenticación
$ 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"
}
}
Ejemplo autorizado
Recuperar el usuario autenticado actual
$ 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"
}
}
}
Ejemplo no autorizado
Falta el header Authorization
$ 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"
}
Token de Authorization inválido
$ 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"
}