Module 40 - Spring Security
Securing a Spring Boot REST API layer by layer: SecurityFilterChain for HTTP-level access control, JWT for stateless authentication (no server-side sessions), BCrypt for password hashing, @PreAuthorize for method-level role enforcement, and Spring Security Test for @WithMockUser slice tests.
SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // activates @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
return http
// CSRF protection is session-based; stateless JWT APIs don't use cookies
.csrf(AbstractHttpConfigurer::disable)
// No HttpSession - each request must prove identity via JWT
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Authorization rules - evaluated top-to-bottom, first match wins
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products").permitAll()
.anyRequest().authenticated()
)
// Disable HTTP Basic: prevents "WWW-Authenticate: Basic" on 401 responses.
// Without this, Apache HttpClient retries POST requests on 401 (non-repeatable body → error).
.httpBasic(AbstractHttpConfigurer::disable)
// Return plain 401 for unauthenticated requests (no redirect to /login)
.exceptionHandling(e -> e
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
// JWT filter runs before Spring's own UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Authorization rule evaluation:
/api/auth/** → permitAll - login endpoint, no token required
GET /api/products → permitAll - public product list
any other request → authenticated - must have valid JWT
First match wins: more specific rules must come before broader ones.
In-Memory Users + BCrypt
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails user = User.withUsername("user")
.password(encoder.encode("password"))
.roles("USER") // stored as ROLE_USER in GrantedAuthority
.build();
UserDetails admin = User.withUsername("admin")
.password(encoder.encode("admin123"))
.roles("ADMIN") // stored as ROLE_ADMIN
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
// BCrypt: adaptive one-way hash - deliberately slow to resist brute-force.
// Cost factor (default 10) means ~100ms per hash - fine for login, brutal for attackers.
// Never store plain or MD5/SHA-1 passwords.
}
JWT - Stateless Authentication
Why Stateless?
Session-based JWT (stateless)
───────────────────────── ──────────────────────────────────────────────────
Server stores session data No server state; token is self-contained
Single server or sticky Works across multiple servers / pods
Session invalidation easy Token revocation requires extra infrastructure
Cookie-based CSRF risk Bearer token in Authorization header - CSRF-safe
JwtUtil
@Component
public class JwtUtil {
private final String secret; // from application.properties - at least 32 bytes
private final long expirationMs;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration-ms}") long expirationMs) {
this.secret = secret;
this.expirationMs = expirationMs;
}
// Signed JWT: header.payload.signature (base64url encoded, dot-separated)
public String generateToken(UserDetails userDetails) {
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("roles", roles)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(signingKey()) // HMAC-SHA-256
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
Date expiration = parseClaims(token).getExpiration();
return username.equals(userDetails.getUsername()) && expiration.after(new Date());
}
private SecretKey signingKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
// HMAC-SHA-256 requires at least 256 bits (32 bytes).
// Keys.hmacShaKeyFor() throws WeakKeyException if the key is too short.
}
}
JwtAuthenticationFilter
// OncePerRequestFilter: guaranteed to run exactly once per HTTP request.
// Placed before UsernamePasswordAuthenticationFilter in the chain.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); // strip "Bearer "
try {
String username = jwtUtil.extractUsername(token);
// Don't overwrite an auth that's already in the context (e.g., from a test)
if (username != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.isTokenValid(token, userDetails)) {
// Build authentication and populate SecurityContext
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception ignored) {
// Invalid/expired/malformed token - no auth set → downstream returns 401
}
}
chain.doFilter(request, response); // always continue the chain
}
}
AuthController - Login Endpoint
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
// DaoAuthenticationProvider verifies credentials against UserDetailsService.
// Throws BadCredentialsException if username not found or password wrong.
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
String token = jwtUtil.generateToken((UserDetails) auth.getPrincipal());
return ResponseEntity.ok(new LoginResponse(token));
}
}
// GlobalExceptionHandler maps BadCredentialsException → 401 ProblemDetail:
// { "title": "Authentication Failed", "status": 401, "detail": "Invalid username or password" }
@PreAuthorize - Method-Level Security
// @EnableMethodSecurity on SecurityConfig activates method security.
// @PreAuthorize is checked AFTER authentication passes the filter chain.
// A ROLE_USER authenticated user hitting an @PreAuthorize("hasRole('ADMIN')") method → 403.
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping // no @PreAuthorize - public (permitAll in filter chain)
public ResponseEntity<List<Product>> getAll() { ... }
@GetMapping("/{id}") // no @PreAuthorize - protected by filter chain (anyRequest().authenticated())
public ResponseEntity<Product> getById(@PathVariable Long id) { ... }
@PostMapping
@PreAuthorize("hasRole('ADMIN')") // → 403 for ROLE_USER; → proceed for ROLE_ADMIN
public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) { ... }
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> delete(@PathVariable Long id) { ... }
}
hasRole('ADMIN') - true if SecurityContext has ROLE_ADMIN GrantedAuthority
hasAnyRole('A','B') - true if either role is present
isAuthenticated() - true if user is authenticated (not anonymous)
#id == principal.id - SpEL: compare method arg to authenticated user's ID
@beanName.method(#arg) - delegate to a Spring bean for complex logic
Spring Security Test
@WithMockUser - Bypassing JWT in Tests
// @WithMockUser injects a synthetic UsernamePasswordAuthenticationToken into the
// SecurityContext before the test runs. JwtAuthenticationFilter checks:
// if (SecurityContextHolder.getContext().getAuthentication() == null) { ... }
// Since the context is already populated, the JWT filter skips JWT processing entirely.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class AuthorizationTest {
@Autowired MockMvc mockMvc;
@MockBean ProductService productService;
@Test
void anonymous_user_cannot_get_product_by_id() throws Exception {
// No auth, no JWT → 401
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void user_role_cannot_create_product() throws Exception {
// @PreAuthorize("hasRole('ADMIN')") - USER has no ADMIN role → 403
mockMvc.perform(post("/api/products")
.contentType(APPLICATION_JSON)
.content("{\"name\":\"x\",\"price\":1,\"category\":\"c\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void admin_role_can_create_product() throws Exception {
given(productService.create(any())).willReturn(new Product(1L, "x", TEN, "c"));
mockMvc.perform(post("/api/products")
.contentType(APPLICATION_JSON)
.content("{\"name\":\"x\",\"price\":1,\"category\":\"c\"}"))
.andExpect(status().isCreated());
}
}
JWT Integration Test with MockMvc
// Full end-to-end test: login → extract token → use token in subsequent requests.
// Using MockMvc (not TestRestTemplate) avoids Apache HttpClient's automatic
// auth-retry on 401 responses (NonRepeatableRequestException on POST bodies).
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class JwtFlowTest {
private String loginAndGetToken(String username, String password) throws Exception {
String body = mockMvc.perform(post("/api/auth/login")
.contentType(APPLICATION_JSON)
.content("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
return objectMapper.readTree(body).get("token").asText();
}
@Test
void user_jwt_cannot_create_product_returns_403() throws Exception {
String userToken = loginAndGetToken("user", "password");
mockMvc.perform(post("/api/products")
.header("Authorization", "Bearer " + userToken)
.contentType(APPLICATION_JSON)
.content("{\"name\":\"Laptop\",\"price\":999,\"category\":\"Electronics\"}"))
.andExpect(status().isForbidden()); // ROLE_USER → @PreAuthorize fails
}
@Test
void admin_jwt_creates_product_and_user_jwt_reads_it() throws Exception {
String adminToken = loginAndGetToken("admin", "admin123");
// Admin creates
String body = mockMvc.perform(post("/api/products")
.header("Authorization", "Bearer " + adminToken)
.contentType(APPLICATION_JSON)
.content("{\"name\":\"Keyboard\",\"price\":149,\"category\":\"Accessories\"}"))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
Long id = objectMapper.readTree(body).get("id").asLong();
// User reads (only needs ROLE_USER which any authenticated user has)
String userToken = loginAndGetToken("user", "password");
mockMvc.perform(get("/api/products/" + id)
.header("Authorization", "Bearer " + userToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Keyboard"));
}
}
Module 40 - What Was Built
module-40-spring-security/
├── pom.xml (Spring Boot 3.3.5, spring-boot-starter-security,
│ jjwt-api/impl/jackson 0.12.6, lombok, spring-security-test)
└── src/
├── main/java/com/javatraining/springsecurity/
│ ├── SpringSecurityApplication.java
│ ├── model/Product.java - @Data @Builder
│ ├── dto/LoginRequest.java - record(username, password)
│ ├── dto/LoginResponse.java - record(token)
│ ├── dto/ProductRequest.java - record + @NotBlank @DecimalMin
│ ├── exception/
│ │ ├── ProductNotFoundException.java
│ │ └── GlobalExceptionHandler.java - BadCredentialsException → 401,
│ │ ProductNotFoundException → 404
│ ├── security/
│ │ ├── JwtUtil.java - generate/validate JWT, @Value injection
│ │ └── JwtAuthenticationFilter.java - OncePerRequestFilter, Bearer token
│ ├── config/
│ │ └── SecurityConfig.java - SecurityFilterChain, UserDetailsService,
│ │ BCryptPasswordEncoder, @EnableMethodSecurity
│ ├── service/ProductService.java - ConcurrentHashMap in-memory store
│ └── controller/
│ ├── ProductController.java - @PreAuthorize on POST and DELETE
│ └── AuthController.java - POST /api/auth/login → JWT
├── main/resources/application.properties
└── test/java/com/javatraining/springsecurity/
├── AuthorizationTest.java - 7 tests: @SpringBootTest(MOCK) + @AutoConfigureMockMvc
│ @WithMockUser, anonymous 401, user 403, admin OK
└── JwtFlowTest.java - 7 tests: @SpringBootTest(MOCK) + @AutoConfigureMockMvc
valid login (token), wrong password (401),
no token (401), invalid token (401),
valid user JWT (accesses protected endpoint),
user JWT cannot POST (403),
admin JWT creates + user JWT reads
All tests: 14 passing.
Key Takeaways
SecurityFilterChain Replaces the auto-configured default; define access rules top-to-bottom
permitAll() No authentication required - public endpoints
anyRequest().authenticated() All other endpoints need a valid principal
SessionCreationPolicy.STATELESS No HttpSession - each request carries its own token
csrf(disable) CSRF attacks rely on cookies; Bearer tokens are CSRF-safe
httpBasic(disable) Removes "WWW-Authenticate: Basic" from 401 responses;
prevents HTTP client auth-retry on POST requests
HttpStatusEntryPoint Returns plain 401 for unauthenticated access (no redirect)
BCryptPasswordEncoder Adaptive hash - deliberately slow; always encode passwords
InMemoryUserDetailsManager Development/test only; production uses JdbcUserDetailsManager
or a custom UserDetailsService backed by a database
JwtUtil Stateless token utility: generate (sign) + validate (verify + expiry)
Keys.hmacShaKeyFor() Rejects keys shorter than 256 bits - enforced at construction time
OncePerRequestFilter Guaranteed single execution per request; base class for JWT filter
SecurityContextHolder Thread-local holder; set authentication here to mark request as authed
@EnableMethodSecurity Required for @PreAuthorize to work; add to SecurityConfig
@PreAuthorize SpEL checked before method runs; 403 on failure
hasRole('ADMIN') Checks for ROLE_ADMIN in GrantedAuthority (Spring prepends "ROLE_")
@WithMockUser Test annotation: injects synthetic auth into SecurityContext;
JWT filter skips processing when context is already populated
@AutoConfigureMockMvc Combines with @SpringBootTest to provide MockMvc without real server
MockMvc vs TestRestTemplate Use MockMvc for security tests - no HTTP client auth-retry issues