JWT authentication
In the previous documents we covered what Spring Security handles, what it does not, and how the filter chain architecture works under the hood. Now let's put that theory into practice by adding stateless JWT authentication so the API can tell the difference between anonymous readers and authenticated staff members.
File overview
- 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
This document will not go deep into how the Staff slice (entity, repository,
mapper, and domain model) is built. It follows the same pattern used for the
Film slice in the persistence
integration documents.
Dependencies
We need Spring Security for the filter chain and JJWT for token generation and validation. The dependencies are added to the build file:
- 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-securitybrings in Spring Security and its filter chain.jjwt-api,jjwt-impl, andjjwt-jacksonhandle token creation and parsing.spring-security-testgives us helpers for testing secured endpoints.
Password storage compatibility
The original Sakila sample database stores staff passwords using MD5/SHA1 hashes. Spring Security 6.x does not support these algorithms out of the box, so we migrated them to BCrypt using a Flyway migration:
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;
If you maintain separate data and schema scripts for development and testing
(for example, sakila-data.sql and sakila-schema.sql), make sure you update
them with BCrypt-encoded passwords as well. Otherwise, integration tests and
local runs will fail during authentication.
JWT configuration
We externalize the secret and expiration into application.yaml so we can rotate keys without recompiling:
- Java
- Kotlin
- Groovy
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
jwt:
secret: ${JWT_SECRET}
expiration-ms: 3600000
A typed properties object binds those values at runtime:
- 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
}
In production, your JWT_SECRET should be a cryptographically secure random string of at least 256 bits (32 bytes).
Best practices
| Aspect | Recommendation |
|---|---|
| Length | At least 32 bytes (256 bits). For HS256/HS512, longer is fine but 32+ is the minimum. |
| Generation | Use a cryptographically secure random generator. Examples: openssl rand -hex 32 or openssl rand -base64 32 |
| Storage | Store it as an environment variable (which your project already does via ${JWT_SECRET}) — never commit it to Git. |
| Rotation | Have a strategy to rotate it periodically without invalidating all active sessions immediately (e.g., support key IDs / kid headers). |
| Uniqueness | Use a different secret per environment (dev, staging, prod). |
Quick generation commands
# 32 bytes as hex (64 hex chars)
openssl rand -hex 32
# 32 bytes as base64
openssl rand -base64 32
Security configuration
SecurityConfig is the heart of the setup. It disables CSRF (we are stateless), sets session management to STATELESS, and defines authorization rules:
GET /api/films/**is public.POST /api/auth/loginis public.requestMatchers("/actuator/**")is public.requestMatchers("/h2-console/**")is public.requestMatchers("/error")is public.- Everything else requires authentication.
It also wires the JwtAuthenticationFilter before Spring's UsernamePasswordAuthenticationFilter so every incoming request gets its token inspected first.
- 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()
}
}
JWT service
JwtService handles token creation, username extraction, and validation. It uses the JJWT builder API to sign tokens with an HMAC key derived from the configured secret:
- 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))
}
}
Authentication filter
JwtAuthenticationFilter is a servlet filter that runs once per request. It looks for an Authorization header starting with Bearer , extracts the token, validates it against the user details service, and populates the security context if everything checks out:
- 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 needs a UserDetailsService to look up users by username. We implemented SakilaUserDetailsService backed by a StaffRepository, and SakilaUserDetails adapts the Staff domain model to Spring's UserDetails contract:
- 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) }
}
}
Auth endpoints
The AuthRestController exposes two endpoints: POST /api/auth/login to authenticate and receive a token, and GET /api/auth/me to retrieve the current user's details. It implements the AuthApi interface generated from the OpenAPI spec.
We also updated openapi.yaml with a bearerAuth security scheme and applied it to the mutating film endpoints (POST, PUT, DELETE). The GET endpoints remain open:
- 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)
)
}
}
Error handling for auth failures
To keep error responses consistent with the rest of the API, we added an @ExceptionHandler(AuthenticationException.class) to the existing ControllerAdvice and wired custom AuthenticationEntryPoint and AccessDeniedHandler implementations. They reuse the same Problem Details JSON format that error handling introduced.
- 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
Every component introduced in this document has a corresponding test class. The suite covers unit tests for the JWT service, the authentication filter, the user-details loader, and the security rules, plus MockMvc-based integration tests for the REST controller and the security configuration.
Pre-existing @WebMvcTest classes that do not exercise the security filter
chain should be annotated with @AutoConfigureMockMvc(addFilters = false) so
that JwtAuthenticationFilter does not interfere with the test setup.
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"
}
}
Try it out
Authentication example
$ 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"
}
}
Authorized example
Retrieve current authenticated user
$ 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"
}
}
}
Unauthorized example
Missing Authorization header
$ 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"
}
Invalid Authorization token
$ 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"
}