Command Palette

Search for a command to run...

[Spring Boot Basics] Tổng quan Spring Security: authentication và authorization, filter chain và cấu hình SecurityFilterChain

Chương 3 và 4 đã xây dựng một API catalogue sản phẩm mà ai cũng gọi được: DELETE /api/products/1 chỉ cần mỗi URL. Bài 15 đã chốt một API được bảo vệ phải trả lời gì: 401 kèm header WWW-Authenticate khi server không biết ai đang gọi, 403 khi đã biết nhưng từ chối; bài 20 ghi chú rằng cả hai đến từ các filter của Spring Security, nơi @ExceptionHandler không nhìn thấy. Bài này mở đầu Chương 5 bằng việc thêm Spring Security vào catalogue.

Bài bắt đầu từ những gì riêng starter đã làm với ứng dụng đang chạy, lần theo một request qua filter chain tạo ra các response đó, rồi thay chain mặc định của Boot bằng một SecurityFilterChain tự viết và lần lượt gặp từng cái bẫy trên đường đi: một POST thất bại dù credentials đúng, một thứ tự rule âm thầm mở một endpoint, một chain thứ hai biến 401 của API thành redirect tới trang login. Các ví dụ dùng Spring Boot 4.1.1, kéo theo Spring Security 7.1.1, và Java 21, trên một project Initializr có các dependency web, validationsecurity. App chạy ở port 8133 thay vì 8080 mặc định, nên bạn sẽ thấy port này trong các lệnh curl.

Một request đi qua một dãy security filter và đi ra với một trong ba kết quả: 200, 401 hoặc 403

Các security header mà Spring Security thêm vào mọi response chỉ hiện đầy đủ một lần rồi được lược khỏi các output curl -i phía sau; dòng log giữ pattern mặc định của Boot, trừ khi đoạn trích ghi rõ là đã rút gọn.

API catalogue dùng trong bài

API là catalogue của Chương 3 với store trong bộ nhớ, vì bài này không cần database. Tạo project:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,security" -o demo.zip
Bash
unzip demo.zip -d demo

So với các project của Chương 3, file build.gradle được sinh ra có thêm một starter và starter test đi kèm:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Các class sản phẩm nằm trong com.example.demo.product:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String sku, String name, BigDecimal price) {
}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductResponse(Long id, String sku, String name, BigDecimal price) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.id(), product.sku(), product.name(), product.price());
    }
}
src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
 
public record CreateProductRequest(
        @NotBlank String sku,
        @NotBlank String name,
        @NotNull @Positive BigDecimal price) {
}
src/main/java/com/example/demo/product/ProductNotFoundException.java
package com.example.demo.product;
 
public class ProductNotFoundException extends RuntimeException {
 
    public ProductNotFoundException(long id) {
        super("Product " + id + " not found");
    }
}
src/main/java/com/example/demo/product/ProductStore.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
 
@Component
public class ProductStore {
 
    private final Map<Long, Product> products = new ConcurrentSkipListMap<>();
    private final AtomicLong nextId = new AtomicLong(1);
 
    public ProductStore() {
        save(new CreateProductRequest("KB-001", "Mechanical keyboard", new BigDecimal("1290000")));
        save(new CreateProductRequest("MS-001", "Wireless mouse", new BigDecimal("490000")));
    }
 
    public List<Product> findAll() {
        return List.copyOf(products.values());
    }
 
    public Product findById(long id) {
        Product product = products.get(id);
        if (product == null) {
            throw new ProductNotFoundException(id);
        }
        return product;
    }
 
    public Product save(CreateProductRequest request) {
        long id = nextId.getAndIncrement();
        Product product = new Product(id, request.sku(), request.name(), request.price());
        products.put(id, product);
        return product;
    }
 
    public Product replace(long id, CreateProductRequest request) {
        findById(id);
        Product product = new Product(id, request.sku(), request.name(), request.price());
        products.put(id, product);
        return product;
    }
 
    public void delete(long id) {
        if (products.remove(id) == null) {
            throw new ProductNotFoundException(id);
        }
    }
}
src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @GetMapping
    public List<ProductResponse> findAll() {
        return store.findAll().stream().map(ProductResponse::from).toList();
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable long id) {
        return ProductResponse.from(store.findById(id));
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = store.save(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(product.id()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(product));
    }
 
    @PutMapping("/{id}")
    public ProductResponse replace(@PathVariable long id, @Valid @RequestBody CreateProductRequest request) {
        return ProductResponse.from(store.replace(id, request));
    }
 
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable long id) {
        store.delete(id);
    }
}

Advice của bài 20, nằm trong package common từ bài 21, được rút lại còn đúng handler mà bài này gặp:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
package com.example.demo.common;
 
import com.example.demo.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
 
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler(ProductNotFoundException.class)
    public ProblemDetail handleNotFound(ProductNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Product not found");
        return problem;
    }
}

Để thấy API trước khi có security, hai dòng security trong build.gradle được comment lại cho một lần build:

Bash
./gradlew bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8133
Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:21 GMT
 
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]

spring-boot-starter-security thay đổi những gì

Với một project có sẵn, thay đổi chỉ là một starter và starter test của nó:

build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'

BOM của Boot 4.1.1 resolve nó thành spring-security-core, spring-security-web, spring-security-configspring-security-crypto 7.1.1, cộng với module spring-boot-security của Boot. Bỏ comment hai dòng đó, build lại và chạy jar. Không dòng code nào thay đổi, vậy mà log khởi động có thêm một cảnh báo:

Text
2026-09-14T11:16:23.091+07:00  WARN 51467 --- [demo] [           main] .s.a.UserDetailsServiceAutoConfiguration :
 
Using generated security password: 8c715543-af93-4f23-be49-d6e09de09d3d
 
This generated password is for development use only. Your security configuration must be updated before running your application in production.

Boot đã tạo một user tên user với password ngẫu nhiên, đổi sau mỗi lần khởi động. Mọi endpoint, kể cả GET /api/products trông như public, giờ đều đòi password này.

401 cho curl, redirect tới /login cho trình duyệt

Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=0CDD2FF2F5E062EB813E0B45ECC19E9B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMT

Một response 401 có challenge WWW-Authenticate: Basic và body rỗng, đúng status mà bài 15 yêu cầu. Cùng request đó nhưng gửi như trình duyệt:

Bash
curl -i -H 'Accept: text/html' http://localhost:8133/api/products
Text
HTTP/1.1 302
Set-Cookie: JSESSIONID=41C69D81FC08D53C4386A1DFA763AB3C; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Location: http://localhost:8133/login
Content-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMT

Với logging.level.org.springframework.security=DEBUG, log tự giải thích lần redirect này:

Text
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.security.web.FilterChainProxy        : Securing GET /api/products
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8133/api/products?continue to session
...
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@16da1abc
2026-09-14T11:16:23.805+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login

Chain mặc định của Boot bật cả form login lẫn HTTP Basic, và một DelegatingAuthenticationEntryPoint chọn một trong hai dựa trên header Accept của request. Với request có Accept: application/json, nó ghi No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@3bcf9488. Set-Cookie ở đây không phải là login: HttpSessionRequestCache tạo một session để nhớ request, nhằm phát lại nó sau khi login thành công.

Accept gửi điResponseEntry pointSet-Cookie: JSESSIONID
*/* (mặc định của curl)401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8"BasicAuthenticationEntryPointcó, request đã được lưu
text/html302, Location: http://localhost:8133/loginLoginUrlAuthenticationEntryPoint
application/json401, cùng challengeBasicAuthenticationEntryPointkhông, không có gì được lưu

Trang login mặc định

/login do chính Spring Security phục vụ. Headless Chrome, điều khiển qua DevTools protocol, đã mở /api/products, submit form một lần với password sai và một lần với password được sinh ra. Các request cấp trang mà nó ghi lại:

Text
1. open http://localhost:8133/api/products
  -> GET http://localhost:8133/api/products
  <- 302 Location: http://localhost:8133/login
  -> GET http://localhost:8133/login
  <- 200 text/html
2. submit a wrong password
  -> POST http://localhost:8133/login
  <- 302 Location: http://localhost:8133/login?error
  -> GET http://localhost:8133/login?error
  <- 200 text/html
3. submit the right password
  -> POST http://localhost:8133/login
  <- 302 Location: http://localhost:8133/api/products?continue
  -> GET http://localhost:8133/api/products?continue
  <- 200 application/json

Trang có tiêu đề "Please sign in", chứa một form POST tới /login gồm field username, field password, một input ẩn _csrf mang token dài 96 ký tự và nút "Sign in", được style bởi /default-ui.css. Sau password sai, trang hiện "Invalid credentials". Sau password đúng, request đã lưu quay lại với ?continue, trình duyệt nhận JSON sản phẩm, và một cookie JSESSIONID (HttpOnly) giữ phiên login cho các request tiếp theo.

HTTP Basic với password được sinh tự động

Client của API thì gửi credentials kèm theo mỗi request:

Bash
curl -i -u user:8c715543-af93-4f23-be49-d6e09de09d3d http://localhost:8133/api/products
Text
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:24 GMT
 
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]

Body giống hệt lúc chưa có starter; sáu header từ X-Content-Type-Options tới X-Frame-Options là mới, có mặt ở mọi response và được lược bỏ từ đây. Request thành công không tạo session nào. Với -u user:wrong, response là cùng challenge 401 như khi không gửi credentials.

spring.security.user.name, password và roles

Một password đổi sau mỗi lần khởi động thì hết dùng được sau phút đầu tiên. User của Boot có thể cố định trong config:

src/main/resources/application.properties
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USER

Với các property này, log khởi động không in password sinh tự động nữa, alice:alice-secret xác thực được, còn user cũ biến mất: curl -i -u user:alice-secret http://localhost:8133/api/me trả 401 với cùng challenge. /api/me là một endpoint nhỏ được thêm ở phần authentication bên dưới. Phần còn lại của bài dùng alice cho tới khi một user store thật thay cho các property.

Một request đi qua filter chain của Spring Security như thế nào

Spring Security không thuộc Spring MVC. Nó là một servlet filter mà Tomcat chạy trước DispatcherServlet, và bên trong là một danh sách filter có thứ tự của riêng nó.

DelegatingFilterProxy và FilterChainProxy

Với logging.level.org.springframework.boot.web.servlet=DEBUG, Boot liệt kê các filter nó đăng ký với Tomcat:

Text
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: springSecurityFilterChain urls=[/*] order=-100, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping servlets: dispatcherServlet urls=[/]
2026-09-14T11:16:29.607+07:00 DEBUG 51539 --- [demo] [           main] .DelegatingFilterProxyRegistrationBean$1 : Filter 'springSecurityFilterChain' configured for use

springSecurityFilterChain được map vào mọi URL ở order -100, giá trị mặc định của spring.security.filter.order, nên nó chạy sau các filter character encoding, form content và request context. Thứ Tomcat giữ là một DelegatingFilterProxy: Tomcat chỉ biết servlet filter, và proxy này chuyển từng request cho Spring bean mang tên đó. Một dòng tạm new Exception("call path").printStackTrace(System.out) trong ProductController.findAll() cho thấy toàn bộ đường đi của một GET /api/products đã xác thực. Đọc từ dưới lên:

Text
java.lang.Exception: call path
	at com.example.demo.product.ProductController.findAll(ProductController.java:32)
	...
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
	...
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:59)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108)
	at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376)
	at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101)
	...
	at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
	...
	at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:247)
	...
	at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118)
	...
	at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
	...
	at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42)
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
	at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385)
	at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237)
	at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
	at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52)
	at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
	at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74)
	at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317)
	at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355)
	at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
	...
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199)
	...
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165)
  • ApplicationFilterChain của Tomcat gọi CharacterEncodingFilter, RequestContextFilter, rồi tới DelegatingFilterProxy.
  • DelegatingFilterProxy gọi bean springSecurityFilterChain. Trong Spring Security 7.1.1, bean đó là WebSecurityConfiguration$CompositeFilterChainProxy, chạy ServletRequestPathFilter rồi tới FilterChainProxy.
  • FilterChainProxy chọn một SecurityFilterChain và chạy các filter của nó qua một VirtualFilterChain, filter sau lồng bên trong filter trước: DisableEncodeUrlFilter ở dưới cùng, AuthorizationFilter ở trên cùng.
  • Sau filter cuối cùng, FilterChainProxy.lambda$doFilterInternal$2 trả request về chain của Tomcat, chain này đi tới HttpServlet.service, DispatcherServlet và controller.

Mọi quyết định security đều được đưa ra trước khi DispatcherServlet được gọi. Đó là lý do advice của bài 20 không bao giờ thấy chúng, điều mà một phần sau sẽ chứng minh bằng thực tế.

Các filter Spring Security in ra khi khởi động

FilterChainProxy có thể giữ nhiều SecurityFilterChain. Khi chỉ có starter, nó giữ chain mặc định của Boot, và mức DEBUG đặt ở trên in chain đó ra một lần lúc khởi động:

src/main/resources/application.properties
logging.level.org.springframework.security=DEBUG
Text
2026-09-14T11:16:23.137+07:00 DEBUG 51467 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Mười sáu filter, áp dụng cho "any request". Thứ tự của chúng do Spring Security cố định, không phụ thuộc thứ tự bạn nhắc tới chúng trong config.

Một request đi qua filter chain của Spring Security: filter chain của Tomcat gọi DelegatingFilterProxy, được đăng ký là springSecurityFilterChain ở order -100, rồi tới FilterChainProxy; FilterChainProxy chạy defaultSecurityFilterChain cho mọi request, với 16 filter đánh số theo thứ tự lúc khởi động, trong đó SecurityContextHolderFilter, CsrfFilter, UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter và AuthorizationFilter được làm nổi bật; chỉ sau AuthorizationFilter request mới tới DispatcherServlet và ProductController.findAll()

Từng filter của một request

Ở mức TRACE, FilterChainProxy ghi log từng filter nó gọi. Đây là GET /api/products đã xác thực của stack trace trên, mỗi dòng đã cắt timestamp, PID và thread, một số dòng được bỏ qua (...):

Text
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'defaultSecurityFilterChain' in [class path resource [org/springframework/boot/security/autoconfigure/web/servlet/ServletWebSecurityAutoConfiguration$SecurityFilterChainConfiguration.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/products
TRACE o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/16)
TRACE s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE o.s.security.web.csrf.CsrfFilter         : Did not protect against CSRF since request did not match IsNotHttpMethod [TRACE, HEAD, GET, OPTIONS]
TRACE o.s.security.web.FilterChainProxy        : Invoking LogoutFilter (6/16)
TRACE o.s.s.w.a.logout.LogoutFilter            : Did not match request to PathPattern [POST /logout]
TRACE o.s.security.web.FilterChainProxy        : Invoking UsernamePasswordAuthenticationFilter (7/16)
TRACE w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultResourcesFilter (8/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultLoginPageGeneratingFilter (9/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking DefaultLogoutPageGeneratingFilter (10/16)
TRACE .w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [PathPattern [GET /logout]]
TRACE o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (11/16)
TRACE o.s.s.w.a.www.BasicAuthenticationFilter  : Found username 'user' in Basic Authorization header
TRACE w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE .s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
...
TRACE o.s.s.authentication.ProviderManager     : Authenticating request with DaoAuthenticationProvider (1/1)
DEBUG o.s.s.a.dao.DaoAuthenticationProvider    : Authenticated user
DEBUG o.s.s.w.a.www.BasicAuthenticationFilter  : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:16:30.063207Z]]]
TRACE o.s.security.web.FilterChainProxy        : Invoking RequestCacheAwareFilter (12/16)
...
TRACE o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderAwareRequestFilter (13/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking AnonymousAuthenticationFilter (14/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (15/16)
TRACE o.s.security.web.FilterChainProxy        : Invoking AuthorizationFilter (16/16)
TRACE estMatcherDelegatingAuthorizationManager : Authorizing GET /api/products
TRACE estMatcherDelegatingAuthorizationManager : Checking authorization on GET /api/products using org.springframework.security.authorization.AuthenticatedAuthorizationManager@decffa3
...
DEBUG o.s.security.web.FilterChainProxy        : Secured GET /api/products

Những filter cần nắm

Phần lớn filter trong danh sách không làm gì với request này. Sáu filter sau quyết định mọi kết quả trong bài:

  • SecurityContextHolderFilter (3) đưa SecurityContext vào SecurityContextHolder cho phần còn lại của request. Việc nạp được trì hoãn: repository chỉ được hỏi khi BasicAuthenticationFilter lần đầu cần tới context (No HttpSession currently exists).
  • CsrfFilter (5) bỏ qua GET, HEAD, TRACEOPTIONS, còn mọi method khác phải có CSRF token. Với request GET này, nó chỉ đặt một token vào request attribute.
  • UsernamePasswordAuthenticationFilter (7) và BasicAuthenticationFilter (11) là hai cách login. Cái đầu chỉ xử lý POST /login từ form, cái sau xử lý header Authorization: Basic. Khi thành công, chúng đặt một Authentication vào SecurityContextHolder.
  • AnonymousAuthenticationFilter (14) đặt một AnonymousAuthenticationToken cho anonymousUser với ROLE_ANONYMOUS khi chưa có gì xác thực request, để các filter phía sau không bao giờ gặp Authentication rỗng.
  • ExceptionTranslationFilter (15) không làm gì trên đường vào. Nó bọc filter đứng sau và biến một lần bị từ chối thành 401 hoặc 403, như phần authentication sẽ cho thấy.
  • AuthorizationFilter (16) kiểm tra các rule authorization cho request. Rule mặc định của Boot là "authenticated"; chỉ khi rule đạt, request mới đi tiếp tới DispatcherServlet.

Số còn lại đóng vai phụ: HeaderWriterFilter ghi sáu security header, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilterDefaultResourcesFilter phục vụ trang login, trang logout và /default-ui.css, RequestCacheAwareFilter phát lại request đã lưu trước khi login (?continue), SecurityContextHolderAwareRequestFilter làm cho request.getUserPrincipal()isUserInRole() trả lời theo Spring Security, còn DisableEncodeUrlFilter giữ session id khỏi lọt vào URL.

Để ý Granted Authorities=[] trên principal và FACTOR_PASSWORD trên token: user sinh tự động của Boot không có role nào, và Spring Security 7 ghi lại loại credential đã xác thực request. Phần authentication sẽ quay lại chi tiết này.

Tự viết SecurityFilterChain

Chain của Boot bảo vệ mọi URL theo cùng một cách và đưa ra một trang login mà không client API nào dùng. Catalogue cần các rule khác: ai cũng được đọc sản phẩm, chỉ admin được xóa, mọi request khác phải có credentials, và API dùng HTTP Basic, không có form login. Trong Spring Security 7.1.1, đó là một bean SecurityFilterChain:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .formLogin(form -> form.disable());
        return http.build();
    }
}
  • HttpSecurity là builder cho một chain, do Spring Security inject vào. Mỗi method nhận một lambda cấu hình một tính năng, và http.build() tạo ra chain.
  • authorizeHttpRequests liệt kê các rule: requestMatchers(HttpMethod.GET, "/api/products/**") khớp danh sách lẫn từng sản phẩm, permitAll() cho mọi người đi qua, hasRole("ADMIN") đòi authority ROLE_ADMIN, còn anyRequest().authenticated() bao phủ mọi thứ mà các rule phía trên chưa nhắc tới.
  • httpBasic(Customizer.withDefaults()) giữ lại BasicAuthenticationFilter; formLogin(form -> form.disable()) nói rõ là không có form login.

Kiểu lambda này là kiểu duy nhất Spring Security 7.1.1 cung cấp. WebSecurityConfigurerAdapter đã bị gỡ từ Spring Security 6.0, và các jar 7.1.1 không còn antMatchers, mvcMatchers, authorizeRequests() hay and(). HttpSecurity.build() được khai báo là public final O build(), không có throws Exception, nên bean method cũng không cần mệnh đề throws.

Chain sau khi khởi động lại, theo dòng DEBUG:

Text
2026-09-14T11:21:13.878+07:00 DEBUG 52518 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Còn mười hai filter: filter form login và ba filter đứng sau các trang mặc định đã biến mất. Các rule khi chạy:

Bash
curl -i http://localhost:8133/api/products/99
Text
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
 
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Product not found"}

Một request public không có credentials đã tới được controller, và advice vẫn trả ProblemDetail của nó. GET /api/products cũng trả 200 theo cách đó. Một endpoint được bảo vệ, không có credentials:

Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=7B9D2ED025F81D54173650EEFFD81FC0; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0

Với -H 'Accept: text/html', response vẫn là 401 thay vì redirect, và GET /login cũng trả 401: không còn trang login nào để redirect tới.

Spring Boot 4 có cần @EnableWebSecurity không?

Không. SecurityConfig ở trên chỉ có @Configuration, và chain của nó chính là chain đã chạy. Boot tự thêm annotation đó: ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration trong spring-boot-security 4.1.1 được gắn @EnableWebSecurity và có hiệu lực khi chưa có bean springSecurityFilterChain nào. Conditions report của một lần chạy với --debug:

Text
   ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration matched:
      - @ConditionalOnClass found required class 'org.springframework.security.config.annotation.web.configuration.EnableWebSecurity' (OnClassCondition)
      - @ConditionalOnMissingBean (names: springSecurityFilterChain; SearchStrategy: all) did not find any beans (OnBeanCondition)

Chain mặc định của Spring Boot tự lui ra

Cũng report đó giải thích vì sao chain mười sáu filter không còn:

Text
   ServletWebSecurityAutoConfiguration.SecurityFilterChainConfiguration:
      Did not match:
         - AllNestedConditions 1 matched 1 did not; NestedCondition on DefaultWebSecurityCondition.Beans @ConditionalOnMissingBean (types: org.springframework.security.web.SecurityFilterChain; SearchStrategy: all) found beans of type 'org.springframework.security.web.SecurityFilterChain' securityFilterChain; NestedCondition on DefaultWebSecurityCondition.Classes @ConditionalOnClass found required classes 'org.springframework.security.web.SecurityFilterChain', 'org.springframework.security.config.annotation.web.builders.HttpSecurity' (DefaultWebSecurityCondition)

Boot chỉ định nghĩa defaultSecurityFilterChain khi không có bean nào kiểu SecurityFilterChain. Ở lần chạy chỉ có starter, cũng condition này ghi did not find any beans và configuration được áp dụng. Ngay khi bạn có một chain, chain của Boot không được tạo: không rule nào của nó còn lại, và không có chuyện gộp rule.

Vì sao POST thất bại dù credentials đúng: CSRF

Chain đã sẵn sàng, alice tạo một sản phẩm:

Bash
curl -i -X POST -u alice:alice-secret -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=0645DFDFC5BB35281D24E97344AC1D7B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:17 GMT

Một response 401 cho chính credentials vừa dùng được một giây trước với GET /api/me. DELETE /api/products/2 với cùng credentials cũng nhận 401 y như vậy.

Lỗi 403 đến tay client thành 401

Log DEBUG của lần POST đó:

Text
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy        : Securing POST /api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for http://localhost:8133/api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy        : Securing GET /error
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8133/error?continue to session
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@28a59257
  1. CsrfFilter từ chối request bằng 403. Nó là filter thứ 5; BasicAuthenticationFilter là filter thứ 7 trong chain này, nên credentials của alice chưa hề được kiểm tra. AccessDeniedHandlerImpl trả lời bằng cách gọi sendError(403).
  2. Tomcat chuyển lỗi tới /error, và lần ERROR dispatch đó lại đi qua security chain thêm một lần nữa.
  3. /error là anonymous. BasicAuthenticationFilter kế thừa OncePerRequestFilter, mà shouldNotFilterErrorDispatch() của class này trả true trong spring-web 7.0.9 và không bị override, nên nó cũng không chạy trong ERROR dispatch.
  4. anyRequest().authenticated() từ chối /error, và Basic entry point ghi đè 403 bằng 401.

Chain mặc định của Boot cũng làm y hệt: cùng POST đó với password sinh tự động, gửi tới ứng dụng chỉ có starter, cũng nhận về 401. Cho ERROR dispatch đi qua sẽ lộ ra status thật:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/error").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
Text
HTTP/1.1 403
Set-Cookie: JSESSIONID=DDC0ED96C8FDFF9FDBC9C9FF5C0F3CB5; Path=/; HttpOnly
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:20 GMT
 
{"timestamp":"2026-09-14T04:21:20.178Z","status":403,"error":"Forbidden","path":"/api/products"}

Log giờ kết thúc bằng Secured GET /error, và error controller của Boot ghi body JSON. Rule này chỉ để chẩn đoán và được gỡ đi lại; cấu hình cuối cùng tự ghi response 401 và 403 mà không cần ERROR dispatch.

CSRF, cross-site request forgery, là kiểu tấn công mà một website khác khiến trình duyệt của user đang đăng nhập gửi đi một request làm thay đổi dữ liệu, dựa vào việc trình duyệt tự đính kèm session cookie của user đó. Cách phòng thủ của Spring Security là một token mà site kia không đọc được, bắt buộc với mọi request trừ GET, HEAD, TRACEOPTIONS, và curl không gửi token nào.

Tắt CSRF cho API stateless

src/main/java/com/example/demo/common/SecurityConfig.java
                .httpBasic(Customizer.withDefaults())
                .formLogin(form -> form.disable()); 
                .formLogin(form -> form.disable()) 
                .csrf(csrf -> csrf.disable()); 

⚠️ Tắt CSRF chỉ đúng với API này vì không có gì trong nó tự động xác thực một request: mọi lời gọi mang credentials trong header Authorization, không có form login, và phần tiếp theo còn gỡ luôn session cookie. Một chain giữ phiên login bằng cookie, như chain form login ở phần sau của bài, phải giữ CSRF bật. Bài 36 sẽ nói rõ khi nào áp dụng trường hợp nào và cách dùng token.

Cùng POST đó sau khi khởi động lại:

Text
HTTP/1.1 201
Location: http://localhost:8133/api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:26 GMT
 
{"id":3,"sku":"HB-001","name":"USB-C hub","price":350000}

Authentication và authorization: 401 và 403

Authentication trả lời câu hỏi ai đang gọi; authorization trả lời người gọi này có được làm việc này không. Spring Security giải quyết câu thứ nhất trước khi hỏi câu thứ hai, và mỗi lần thất bại có status riêng. Với chain ở trên, một POST không có credentials:

Bash
curl -i -X POST -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/products
Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:26 GMT

Cùng POST đó với -u alice:wrong nhận cùng 401, và log cho thấy nó dừng ở đâu:

Text
2026-09-14T11:21:27.131+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.a.dao.DaoAuthenticationProvider    : Failed to authenticate since password does not match stored value
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager     : Authentication failed with provider DaoAuthenticationProvider since Bad credentials
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager     : Denying authentication since all attempted providers failed
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.w.a.www.BasicAuthenticationFilter  : Failed to process authentication request
 
org.springframework.security.authentication.BadCredentialsException: Bad credentials

Credentials hợp lệ, nhưng alice không thỏa một rule: cô có ROLE_USER còn DELETE đòi ROLE_ADMIN.

Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:27 GMT
 
{"timestamp":"2026-09-14T04:21:27.646Z","status":403,"error":"Forbidden","path":"/api/products/2"}
Text
2026-09-14T11:21:27.571+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Securing DELETE /api/products/2
2026-09-14T11:21:27.643+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.a.dao.DaoAuthenticationProvider    : Authenticated user
...
2026-09-14T11:21:27.644+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Securing GET /error
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy        : Secured GET /error

Response 403 có body còn 401 thì không, dù cả hai đều đi qua sendError và một ERROR dispatch. Với alice, lần dispatch tới /error đã được xác thực (Secured GET /error), nên error controller của Boot ghi JSON; với request anonymous, /error lại bị từ chối và body vẫn rỗng.

Đặt cạnh nhau: POST /api/products không có header Authorization đi qua AnonymousAuthenticationFilter với anonymousUser, không đạt anyRequest().authenticated() ở AuthorizationFilter, ExceptionTranslationFilter đưa caller anonymous tới AuthenticationEntryPoint và kết quả là HTTP/1.1 401 với WWW-Authenticate: Basic realm="Realm"; DELETE /api/products/2 của alice với ROLE_USER được BasicAuthenticationFilter xác thực, không đạt hasRole("ADMIN"), ExceptionTranslationFilter đưa caller đã xác thực tới AccessDeniedHandler và kết quả là HTTP/1.1 403

ExceptionTranslationFilter, AuthenticationEntryPoint và AccessDeniedHandler

AuthorizationFilter không ghi response. Khi một rule không đạt, nó ném AuthorizationDeniedException, và ExceptionTranslationFilter, lớp bọc bên ngoài nó, quyết định ý nghĩa của lần từ chối dựa trên caller trong SecurityContextHolder. Với logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=TRACE, nó ghi rõ nhánh đã chọn. Với GET /api/products anonymous ở lần chạy chỉ có starter:

Text
2026-09-14T11:16:30.552+07:00 TRACE 51539 --- [demo] [nio-8133-exec-2] o.s.s.w.a.ExceptionTranslationFilter     : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
 
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied

Với lệnh DELETE của alice trong cấu hình cuối cùng ở cuối bài, đã cắt phần đầu dòng và rút gọn chi tiết token thành […]:

Text
TRACE o.s.s.w.a.ExceptionTranslationFilter     : Sending UsernamePasswordAuthenticationToken […] to access denied handler since access is denied
 
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
  • Caller anonymous được đưa tới AuthenticationEntryPoint. Việc của nó là bắt đầu authentication: BasicAuthenticationEntryPoint trả 401 kèm challenge, LoginUrlAuthenticationEntryPoint redirect tới trang login.
  • Caller đã xác thực được đưa tới AccessDeniedHandler. Login lại cũng không đổi được kết quả, nên AccessDeniedHandlerImpl trả 403.
  • Password sai không bao giờ tới được AuthorizationFilter. BasicAuthenticationFilter tự bắt BadCredentialsException và gọi entry point của chính nó, vì vậy Failed to process authentication request là dòng Spring Security cuối cùng trước response 401.

SecurityContextHolder chứa gì sau khi login

Cách dễ nhất để xem một lần login thành công để lại gì là nhìn từ controller. GET /api/me nhận Authentication và principal làm parameter rồi so sánh chúng với SecurityContextHolder:

src/main/java/com/example/demo/common/CurrentUserController.java
package com.example.demo.common;
 
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class CurrentUserController {
 
    private static final Logger log = LoggerFactory.getLogger(CurrentUserController.class);
 
    public record CurrentUserResponse(String name, List<String> authorities, String authenticationType,
                                      String principalType, boolean credentialsPresent,
                                      boolean sameAsSecurityContextHolder) {
    }
 
    @GetMapping("/api/me")
    public CurrentUserResponse me(Authentication authentication, @AuthenticationPrincipal UserDetails principal) {
        Authentication fromHolder = SecurityContextHolder.getContext().getAuthentication();
        log.info("{}", fromHolder);
        return new CurrentUserResponse(
                authentication.getName(),
                authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
                authentication.getClass().getName(),
                principal.getClass().getName(),
                authentication.getCredentials() != null,
                fromHolder == authentication);
    }
}
Bash
curl -i -u alice:alice-secret http://localhost:8133/api/me
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 296
Date: Mon, 14 Sep 2026 04:21:29 GMT
 
{"name":"alice","authorities":["ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}
Text
2026-09-14T11:21:29.099+07:00  INFO 52717 --- [demo] [nio-8133-exec-2] c.e.demo.common.CurrentUserController    : UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:21:29.097929Z]]]
  • SecurityContextHolder giữ một SecurityContextAuthentication bên trong là UsernamePasswordAuthenticationToken. Parameter của controller chính là object đó (sameAsSecurityContextHolder: true).
  • Principal là một UserDetails, ở đây là class User của Spring Security, mang các role lúc tạo: ROLE_USER, từ spring.security.user.roles=USER.
  • Credentials đã biến mất. getCredentials() trả null sau khi xác thực, nên password không đi sâu thêm vào ứng dụng.
  • Authorities của token có thêm FACTOR_PASSWORD. Spring Security 7 thêm một FactorGrantedAuthority ghi loại credential đã xác thực request, cùng thời điểm cấp. spring-security-config 7.1.1 cũng có @EnableMultiFactorAuthentication; multi-factor authentication thuộc khóa Advanced.
  • Details ghi địa chỉ remote và session id, ở đây là null vì HTTP Basic không tạo session.

API stateless với SessionCreationPolicy.STATELESS

Nhiều response 401 ở trên đặt cookie JSESSIONID cho một client không bao giờ login qua session. Một setting chặn được việc đó:

src/main/java/com/example/demo/common/SecurityConfig.java
import org.springframework.security.config.http.SessionCreationPolicy; 
 
// ...
                .formLogin(form -> form.disable())
                .csrf(csrf -> csrf.disable()); 
                .csrf(csrf -> csrf.disable()) 
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); 

POST không có credentials, trước và sau:

Text
HTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0

Dòng log Saved request http://localhost:8133/api/products?continue to session biến mất cùng với cookie: một chain stateless không lưu request cho một trang login mà nó không có. SessionManagementFilter xuất hiện thêm trong danh sách filter lúc khởi động. Điều không đổi: một request Basic thành công không tạo session ở cả hai cấu hình. curl -c ghi ra một cookie jar rỗng sau GET /api/me với alice ở cả hai, và khi chưa có STATELESS, một request tiếp theo dùng jar đó mà không có credentials nhận về 401 bình thường. HTTP Basic gửi credentials ở mọi request, nên không có session nào cần giữ, và STATELESS bảo đảm không có session nào vô tình xuất hiện.

Thứ tự requestMatchers: rule khớp đầu tiên thắng

AuthorizationFilter kiểm tra các rule từ trên xuống và dùng rule đầu tiên có matcher khớp với request. Nó không đi tìm rule cụ thể nhất. Ba lần chạy tiếp theo chỉ đổi phần rule của chain stateless.

Thứ tự rule vô tình khóa một endpoint

Một rule cho toàn bộ API được thêm lên trên rule public:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/**").authenticated() 
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
Bash
curl -i http://localhost:8133/api/products
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:35:52 GMT

GET /api/products khớp /api/** trước, nên permitAll() bên dưới không bao giờ được áp dụng, và danh sách sản phẩm giờ đòi credentials (-u alice:alice-secret nhận 200). Ứng dụng khởi động mà không có một dòng WARN nào.

Thứ tự rule vô tình mở một endpoint

Lỗi ngược lại còn tệ hơn. Rule public viết thiếu method, đặt trên rule admin:

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() 
                        .requestMatchers("/api/products/**").permitAll() 
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
Bash
curl -i -X DELETE http://localhost:8133/api/products/2
Text
HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:21:38 GMT
Text
2026-09-14T11:21:38.090+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy        : Securing DELETE /api/products/2
2026-09-14T11:21:38.092+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:38.093+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy        : Secured DELETE /api/products/2

Một caller anonymous đã xóa sản phẩm 2; lệnh GET /api/products tiếp theo chỉ còn liệt kê bàn phím. hasRole("ADMIN") vẫn nằm trong config nhưng không bao giờ chạy. Hãy đặt rule hẹp trên rule rộng, giữ HTTP method trong những rule chỉ dành cho một method, và kết thúc bằng anyRequest(). Vị trí cuối cùng đó là điều duy nhất configurer bắt buộc: AbstractRequestMatcherRegistry trong jar 7.1.1 chứa message Can't configure requestMatchers after anyRequest.

permitAll() và anonymous()

Rule public dùng anonymous() thay cho permitAll():

src/main/java/com/example/demo/common/SecurityConfig.java
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").anonymous()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())

Không có credentials, GET /api/products trả 200. Với credentials của alice:

Bash
curl -i -u alice:alice-secret http://localhost:8133/api/products
Text
HTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:41 GMT
 
{"timestamp":"2026-09-14T04:21:41.338Z","status":403,"error":"Forbidden","path":"/api/products"}

permitAll() cho mọi caller đi qua. anonymous() chỉ cho qua request mà AnonymousAuthenticationFilter đã đánh dấu là anonymousUser, nên user đã login bị từ chối; nó hợp với trang đăng ký, không hợp với một catalogue public. Không rule nào bỏ qua credentials được gửi kèm: với permitAll(), curl -i -u alice:wrong http://localhost:8133/api/products trả 401, vì BasicAuthenticationFilter từ chối header Authorization sai trước khi rule nào được kiểm tra.

Hai SecurityFilterChain với securityMatcher và @Order

Phần lớn ứng dụng còn phục vụ những thứ khác ngoài API: một trang admin, một site render phía server như ở bài 24. Những phần đó cần form login, session và CSRF, tức là đúng những thứ chain API đã tắt. Thay vì một chain thỏa hiệp, hãy định nghĩa hai chain:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
 
@Configuration
public class SecurityConfig {
 
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
}
src/main/java/com/example/demo/common/HomeController.java
package com.example.demo.common;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class HomeController {
 
    @GetMapping("/")
    public String home() {
        return "Catalogue home";
    }
}
  • securityMatcher("/api/**") quyết định chain có xử lý một request hay không. requestMatchers bên trong authorizeHttpRequests chỉ quyết định rule trong phạm vi chain đó.
  • @Order quyết định thứ tự FilterChainProxy hỏi các chain. Chain web không có securityMatcher, nên nó khớp mọi request.

Log khởi động in cả hai chain:

Text
2026-09-14T11:29:06.004+07:00 DEBUG 56717 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure Or [PathPattern [/api/**]] with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
2026-09-14T11:29:06.018+07:00 DEBUG 56717 --- [demo] [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter

Chain API không có CsrfFilter cũng không có UsernamePasswordAuthenticationFilter, dù nó không hề gọi formLogin(form -> form.disable()): một chain dựng từ HttpSecurity chỉ có form login khi nó gọi formLogin(). Dòng đó trong phiên bản một chain chỉ ghi lại một quyết định chứ không làm thay đổi danh sách filter. Chain web có các filter login và CsrfFilter, không có BasicAuthenticationFilter.

Chain nào đã xử lý request

Với logging.level.org.springframework.security.web.FilterChainProxy=TRACE, mỗi request ghi lại các chain mà nó được so khớp. Phần đầu dòng đã được cắt và danh sách filter thay bằng […]:

Text
### GET /api/products -> 200
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/products
DEBUG o.s.security.web.FilterChainProxy        : Secured GET /api/products
 
### GET / -> 302 Location: http://localhost:8133/login
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /
DEBUG o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login

/api/products khớp chain thứ nhất và chain thứ hai không bao giờ được hỏi. / không khớp /api/**, nên FilterChainProxy chuyển sang chain thứ hai, chain khớp mọi thứ, và entry point form login của nó redirect tới /login, kể cả khi có -u alice:alice-secret, vì chain này không đọc Basic credentials.

FilterChainProxy chọn chain như thế nào: GET /api/products được thử với apiSecurityFilterChain có @Order(1) và matcher Or [PathPattern [/api/**]], khớp và nhận HTTP/1.1 200 mà webSecurityFilterChain không được hỏi tới; GET / không khớp chain thứ nhất, khớp webSecurityFilterChain có @Order(2) cho mọi request và nhận HTTP/1.1 302 tới http://localhost:8133/login; khi đổi giá trị @Order cho nhau, webSecurityFilterChain khớp mọi request trước, apiSecurityFilterChain không bao giờ được gọi tới, và ứng dụng thất bại ngay khi khởi động với UnreachableFilterChainException

Lỗi 401 biến thành redirect tới /login

Chain API không đổi gì, vậy mà một lời gọi API chưa xác thực không còn nhận được 401:

Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=AE729E73CC623615A92322DA476669D5; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=AE729E73CC623615A92322DA476669D5
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:06 GMT
Text
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /api/me
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy        : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy        : Securing GET /error
DEBUG o.s.s.web.DefaultRedirectStrategy        : Redirecting to /login;jsessionid=AE729E73CC623615A92322DA476669D5

Chain API thực ra đã trả 401: header WWW-Authenticate của nó vẫn nằm trong response. Nhưng Basic entry point dùng sendError, ERROR dispatch đi tới /error, và /error không nằm dưới /api/**. Chain web nhận nó, thấy caller anonymous, và entry point form login thay 401 bằng một redirect. Session id trong Location cũng đến từ lần dispatch đó: DisableEncodeUrlFilter, filter giữ session id khỏi URL, cũng là một OncePerRequestFilter không override shouldNotFilterErrorDispatch(), nên nó cũng không chạy cho /error. Chuyện tương tự xảy ra với -H 'Accept: text/html' và với password sai. Một lần từ chối caller đã xác thực thì vẫn đúng: lệnh DELETE /api/products/2 của alice trả 403 với body JSON của Boot, vì ERROR dispatch của cô đã được xác thực. Phần tiếp theo loại hẳn ERROR dispatch khỏi các response của API.

Đảo giá trị @Order

Với @Order(1) trên chain web và @Order(2) trên chain API, ứng dụng không khởi động được:

Text
2026-09-14T11:29:10.892+07:00 ERROR 58644 --- [demo] [           main] o.s.boot.SpringApplication               : Application run failed
...
Caused by: org.springframework.security.web.UnreachableFilterChainException: A filter chain that matches any request [DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]] has already been configured, which means that this filter chain [DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.

Khác với rule trong một chain, thứ tự chain có lưới an toàn cho trường hợp này: một chain khớp mọi request đứng trước một chain khác sẽ bị từ chối ngay lúc khởi động. Message nói về "any request"; với các chain có matcher hẹp hơn nhưng chồng lên nhau, như /api/**/api/products/**, hãy cho chain hẹp hơn giá trị @Order nhỏ hơn.

Trả 401 và 403 dưới dạng ProblemDetail

API giờ trả 401 với body rỗng hoặc một redirect, và 403 với JSON mặc định của Boot. Bài 20 đã cho mọi lỗi khác một ProblemDetail theo RFC 9457 với Content-Type: application/problem+json.

Vì sao @RestControllerAdvice không bao giờ thấy chúng

Cách thử đầu tiên ai cũng nghĩ tới là thêm hai handler vào advice:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); 
 
    @ExceptionHandler(AuthenticationException.class) 
    public ProblemDetail handleAuthentication(AuthenticationException ex) { 
        log.warn("handleAuthentication called: {}", ex.toString()); 
        return ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); 
    } 
 
    @ExceptionHandler(AccessDeniedException.class) 
    public ProblemDetail handleAccessDenied(AccessDeniedException ex) { 
        log.warn("handleAccessDenied called: {}", ex.toString()); 
        return ProblemDetail.forStatus(HttpStatus.FORBIDDEN); 
    } 

Các lần chạy hai chain ở phần trước đã có sẵn hai handler này trong class: redirect 302 cho /api/me, redirect 302 cho password sai và 403 với JSON của Boot chính là kết quả của chúng, và cả handleAuthentication called lẫn handleAccessDenied called đều không xuất hiện trong log. AuthenticationExceptionAuthorizationDeniedException được ném ra và xử lý ngay bên trong các security filter, trước khi DispatcherServlet được gọi, trong khi method @ExceptionHandler chỉ thấy exception từ các handler method mà DispatcherServlet đã gọi. Hãy gỡ hai handler này; chỗ để định hình các response đó là entry point và access denied handler.

Entry point và access denied handler ghi ProblemDetail

Một component implement cả hai interface và tự ghi response, dùng JsonMapper mà Boot cấu hình cho Jackson 3:

src/main/java/com/example/demo/common/ProblemDetailSecurityHandler.java
package com.example.demo.common;
 
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
 
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
 
    private final JsonMapper jsonMapper;
 
    public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"");
        write(request, response, HttpStatus.UNAUTHORIZED, "Valid credentials are required to access this resource.");
    }
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
    }
 
    private void write(HttpServletRequest request, HttpServletResponse response,
                       HttpStatus status, String detail) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
        problem.setInstance(URI.create(request.getRequestURI()));
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        jsonMapper.writeValue(response.getOutputStream(), problem);
    }
}
  • commence là method của AuthenticationEntryPoint. Nó tự đặt WWW-Authenticate, vì một response 401 thiếu header này không cho client biết phải xác thực bằng cách nào.
  • handle là method của AccessDeniedHandler.
  • Không method nào gọi sendError. Response được ghi và commit ngay tại chỗ, nên không có ERROR dispatch nào để một chain khác chen vào.
  • detail là chuỗi cố định. Message của exception có thể cho caller biết user name có tồn tại hay password bị sai.

AccessDeniedException là kiểu parameter của interface; AuthorizationDeniedException kế thừa nó. Gắn component vào chain API qua exceptionHandling:

src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) { 
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) { 
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                .exceptionHandling(exceptions -> exceptions 
                        .authenticationEntryPoint(problemHandler) 
                        .accessDeniedHandler(problemHandler)) 
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
Bash
curl -i http://localhost:8133/api/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:12 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}
Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:14 GMT
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}

Cả hai body có cùng dạng với các handler của bài 20: các member chuẩn theo thứ tự alphabet, đường dẫn request làm instance, reason phrase làm title. Password sai thì không:

Bash
curl -i -u alice:wrong http://localhost:8133/api/me
Text
HTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=48EEB4984DDE4081E158FE00F5812246; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=48EEB4984DDE4081E158FE00F5812246
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:13 GMT

Entry point mà BasicAuthenticationFilter dùng

realm="Realm" đã tố cáo nó: response 401 đó đến từ BasicAuthenticationEntryPoint mặc định chứ không phải component mới, và lệnh sendError của nó lại đẩy request sang chain web. exceptionHandling cấu hình ExceptionTranslationFilter, còn password sai thì không bao giờ đi tới đó; BasicAuthenticationFilter tự gọi entry point của httpBasic. Nó cần cùng component đó:

src/main/java/com/example/demo/common/SecurityConfig.java
                .httpBasic(Customizer.withDefaults()) 
                .httpBasic(basic -> basic.authenticationEntryPoint(problemHandler)) 
Bash
curl -i -u alice:wrong http://localhost:8133/api/me
Text
HTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:16 GMT
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}

Trong cùng lần chạy, curl -i -u alice:wrong http://localhost:8133/api/products nhận cùng body 401 với "instance":"/api/products", -H 'Accept: text/html' trên /api/me nhận cùng 401 thay vì redirect, còn lệnh DELETE của alice vẫn nhận 403 như trên. Mọi response security của API giờ đều là ProblemDetail, và 401 vẫn giữ header WWW-Authenticate.

User trong bộ nhớ với InMemoryUserDetailsManager

spring.security.user.* chỉ cấu hình được một user. Hai user với role khác nhau cần một UserDetailsService, và lúc này bản trong bộ nhớ là đủ. SecurityConfig hoàn chỉnh:

src/main/java/com/example/demo/common/SecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.core.userdetails.User; 
import org.springframework.security.core.userdetails.UserDetails; 
import org.springframework.security.provisioning.InMemoryUserDetailsManager; 
 
@Configuration
public class SecurityConfig {
 
    @Bean
    @Order(1)
    SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
        http
                .securityMatcher("/api/**")
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                        .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .httpBasic(basic -> basic.authenticationEntryPoint(problemHandler))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint(problemHandler)
                        .accessDeniedHandler(problemHandler))
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        return http.build();
    }
 
    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.build();
    }
 
    @Bean
    InMemoryUserDetailsManager userDetailsService() { 
        UserDetails alice = User.withUsername("alice") 
                .password("{noop}alice-secret") 
                .roles("USER") 
                .build(); 
        UserDetails bob = User.withUsername("bob") 
                .password("{noop}bob-secret") 
                .roles("USER", "ADMIN") 
                .build(); 
        return new InMemoryUserDetailsManager(alice, bob); 
    } 
}

Các dòng spring.security.user.* được gỡ khỏi application.properties:

src/main/resources/application.properties
spring.application.name=demo
spring.security.user.name=alice 
spring.security.user.password=alice-secret 
spring.security.user.roles=USER 

roles("ADMIN") lưu authority ROLE_ADMIN, đúng thứ mà hasRole("ADMIN") kiểm tra. Hai user với rule admin:

Bash
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:20 GMT
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}
Bash
curl -i -X DELETE -u bob:bob-secret http://localhost:8133/api/products/2
Text
HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:29:20 GMT
Bash
curl -i -u bob:bob-secret http://localhost:8133/api/me
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 307
Date: Mon, 14 Sep 2026 04:29:21 GMT
 
{"name":"bob","authorities":["ROLE_ADMIN","ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}

Password sinh tự động đi đâu

Nó biến mất, và không phải vì các property đã bị gỡ: log khởi động của lần chạy này không có dòng Using generated security password nào, và conditions report, với --debug, cho biết lý do:

Text
   UserDetailsServiceAutoConfiguration:
      Did not match:
         - @ConditionalOnMissingBean (types: org.springframework.security.authentication.AuthenticationManager,org.springframework.security.authentication.AuthenticationProvider,org.springframework.security.core.userdetails.UserDetailsService,org.springframework.security.authentication.AuthenticationManagerResolver,org.springframework.security.oauth2.jwt.JwtDecoder; SearchStrategy: all) found beans of type 'org.springframework.security.core.userdetails.UserDetailsService' userDetailsService (OnBeanCondition)

Boot chỉ tạo user của nó, sinh tự động hay lấy từ spring.security.user.*, khi ứng dụng chưa định nghĩa UserDetailsService, AuthenticationProvider, AuthenticationManager, AuthenticationManagerResolver hay JwtDecoder nào. Với bean ở trên, các property kia sẽ bị bỏ qua dù có còn nằm trong file.

Tiền tố password và withDefaultPasswordEncoder()

Password lưu cho DelegatingPasswordEncoder mặc định của Spring Security bắt đầu bằng id của encoder đã tạo ra nó: {noop} nghĩa là phần còn lại là plain text được so sánh nguyên văn, {bcrypt} nghĩa là phần còn lại là một BCrypt hash. Boot dựa vào cùng cơ chế đó cho user của mình: trong UserDetailsServiceAutoConfiguration 4.1.1, một password lấy từ property mà chưa có tiền tố {id} sẽ được gắn hằng NOOP_PASSWORD_PREFIX = "{noop}" vào đầu khi ứng dụng không có bean PasswordEncoder. Một đoạn probe chạy lúc khởi động trong cùng ứng dụng in ra kết quả của từng dạng:

Text
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : withDefaultPasswordEncoder stores: {bcrypt}$2a$10$Kf8hsJdaA/IZKGvT28CM2u9QF1NZd2OOG4DJZQ5eS/qtJRq72dXr2
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : matches("bob-secret", "{noop}bob-secret") = true
2026-09-14T11:29:20.283+07:00  INFO 59512 --- [demo] [           main] com.example.demo.common.PasswordProbe    : matches without a prefix threw java.lang.IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`.

User.withDefaultPasswordEncoder() hash password bằng BCrypt ngay lúc tạo user, nhưng nó bị đánh dấu deprecated trong spring-security-core 7.1.1 (javap -v hiện Deprecated: true), và bytecode của nó mang cảnh báo "User.withDefaultPasswordEncoder() is considered unsafe for production and is only intended for sample applications": password dạng plain text vẫn nằm trong source code. {noop} thẳng thắn về đúng điều đó và ổn cho một bản demo. Bài 34 chuyển user vào database với BCrypt hash.

Tổng hợp các thành phần của Spring Security

Mỗi dòng dưới đây là thứ bài này đã gặp khi chạy.

Thành phầnTrách nhiệmChỗ bạn đụng tới
DelegatingFilterProxyservlet filter mà Tomcat chạy với tên springSecurityFilterChain, order -100; chuyển từng request cho Spring beanspring.security.filter.order; thường không cần đụng
FilterChainProxychạy SecurityFilterChain đầu tiên có matcher khớp requestlogging.level.org.springframework.security.web.FilterChainProxy=TRACE
SecurityFilterChainmột danh sách filter có thứ tự cho các request mà securityMatcher của nó chọnmột @Bean dựng từ HttpSecurity, với @OrdersecurityMatcher
SecurityContextHolderFilterđưa SecurityContext vào cho requestsessionManagement(...) quyết định có dùng session hay không
CsrfFiltertừ chối request không có CSRF token, trừ GET, HEAD, TRACE và OPTIONScsrf(...)
BasicAuthenticationFilterxác thực header Authorization: Basic; khi thất bại gọi entry point của chính nóhttpBasic(...)
UsernamePasswordAuthenticationFilterxác thực POST /login từ form loginformLogin(...)
AnonymousAuthenticationFilterđặt anonymousUser với ROLE_ANONYMOUS khi chưa ai được xác thựccác rule anonymous() khớp với nó
ExceptionTranslationFilterđưa caller anonymous bị từ chối tới entry point, caller đã xác thực bị từ chối tới access denied handlerexceptionHandling(...)
AuthorizationFilterkiểm tra các rule của authorizeHttpRequests, rule khớp đầu tiên thắngrequestMatchers, permitAll(), anonymous(), hasRole(), authenticated(), anyRequest()
AuthenticationEntryPointtrả lời "bạn là ai?" bằng challenge 401 hoặc redirect tới trang loginexceptionHandling(...)httpBasic(...)
AccessDeniedHandlertrả lời "không được phép" bằng 403exceptionHandling(...)
SecurityContextHolder, Authenticationcaller hiện tại: principal, authorities, detailsparameter Authentication@AuthenticationPrincipal trong controller
UserDetailsServicenạp user theo tên; khi có bean này, user của Boot bị tắtbean InMemoryUserDetailsManager ở bài này, database ở bài 34
DelegatingPasswordEncoderso sánh password dựa trên tiền tố {id}{noop} ở bài này, BCrypt ở bài 34

FAQ

Vì sao Spring Security trả 401 thay vì hiện trang login?

Vì request không yêu cầu HTML. Chain mặc định của Boot có cả form login lẫn HTTP Basic, và entry point của nó chọn dựa trên header Accept: curl với Accept: */* hoặc application/json nhận 401 kèm WWW-Authenticate: Basic realm="Realm", charset="UTF-8", còn Accept: text/html nhận 302 tới /login. Một chain tự viết chỉ có httpBasic() trả 401 cho cả hai.

WebSecurityConfigurerAdapter còn dùng được trong Spring Security 7 không?

Không. Nó đã bị gỡ từ Spring Security 6.0, và jar spring-security-config 7.1.1 không có class nào tên như vậy, không có antMatchers hay mvcMatchers, không có authorizeRequests() và không có and(). Security được cấu hình bằng các bean SecurityFilterChain dựng từ HttpSecurity với lambda, và requestMatchers thay cho các method matcher cũ.

Vì sao POST trả 403 hoặc 401 dù credentials đúng?

CSRF protection bật mặc định và từ chối mọi POST, PUT, PATCH và DELETE không có CSRF token, trước cả khi credentials được kiểm tra. CsrfFilter trả 403, nhưng với HTTP Basic, ERROR dispatch tới /error chạy dưới dạng anonymous, nên một chain không permit /error biến nó thành 401. Dòng DEBUG Invalid CSRF token found chỉ ra nguyên nhân. Với API stateless gửi credentials trong header, hãy tắt CSRF; với bất cứ thứ gì login bằng cookie, hãy gửi token.

@RestControllerAdvice có xử lý được AccessDeniedException của Spring Security không?

Không, với các rule do security filter kiểm tra. Lần từ chối được ném ra và xử lý bên trong filter chain trước khi DispatcherServlet chạy; các handler cho AuthenticationExceptionAccessDeniedException trong advice không bao giờ được gọi trong các lần chạy của bài này. Hãy định hình các response đó bằng một AuthenticationEntryPoint và một AccessDeniedHandler, và đặt entry point cho cả httpBasic(...) lẫn exceptionHandling(...).

permitAll() và anonymous() khác nhau thế nào?

permitAll() cho mọi caller đi qua. anonymous() chỉ cho qua caller mà AnonymousAuthenticationFilter đã đánh dấu là anonymousUser: GET /api/products với anonymous() trả 200 khi không có credentials và 403 với credentials hợp lệ của alice. Với cả hai rule, request mang password sai bị BasicAuthenticationFilter từ chối bằng 401 trước khi rule được kiểm tra.

FACTOR_PASSWORD trong authorities là gì?

Là một FactorGrantedAuthority mà Spring Security 7 thêm vào Authentication để ghi lại rằng caller đã xác thực bằng password, cùng thời điểm cấp. Response /api/me của alice liệt kê ["ROLE_USER","FACTOR_PASSWORD"] dù cô chỉ được tạo với ROLE_USER. Nó không làm thay đổi các rule hasRole: alice vẫn nhận 403 còn bob nhận 204 với lệnh DELETE khi authority này có trong danh sách. Multi-factor authentication, tức @EnableMultiFactorAuthentication trong 7.1.1, thuộc khóa Advanced.

Vì sao SecurityFilterChain thứ hai không bao giờ chạy?

Vì một chain đứng trước đã khớp các request của nó. FilterChainProxy chỉ dùng chain đầu tiên có securityMatcher khớp, theo @Order. Nếu chain đứng trước không có securityMatcher, nó khớp mọi request, và Spring Security 7.1.1 từ chối khởi động với UnreachableFilterChainException. Hãy cho chain hẹp hơn giá trị @Order nhỏ hơn và để chain không có securityMatcher ở cuối cùng.

Kết luận

Spring Security là một servlet filter đứng trước Spring MVC: Tomcat gọi DelegatingFilterProxy, proxy gọi FilterChainProxy, và FilterChainProxy chạy SecurityFilterChain đầu tiên khớp request cùng các filter có thứ tự của nó, trước khi DispatcherServlet thấy bất cứ điều gì. Riêng starter đã khóa mọi endpoint sau một user sinh tự động, trả 401 cho client API và redirect trình duyệt tới trang login. Một bean SecurityFilterChain tự viết thay hẳn chain của Boot, không cần @EnableWebSecurity, và trong Spring Security 7.1.1 chỉ viết được bằng lambda DSL. Authentication thất bại tới AuthenticationEntryPoint thành 401, authorization thất bại tới AccessDeniedHandler thành 403, và ExceptionTranslationFilter chọn giữa hai nhánh dựa trên caller trong SecurityContextHolder.

Các cái bẫy đều xoay quanh thứ tự và dispatch. Rule và chain đều theo nguyên tắc khớp đầu tiên thắng, và một thứ tự rule sai có thể âm thầm mở lệnh DELETE cho bất kỳ ai. CSRF từ chối một POST trước khi credentials được kiểm tra, rồi ERROR dispatch có thể biến 403 đó thành 401, hoặc một chain thứ hai có thể biến 401 của API thành redirect. Một entry point và một access denied handler tự ghi ProblemDetail, đặt cho cả httpBasic lẫn exceptionHandling, cho API những response 401 và 403 nhất quán mà vẫn giữ WWW-Authenticate. STATELESS giữ session tránh xa API, và một bean UserDetailsService thay cho user sinh tự động của Boot.

User vẫn đang nằm trong code với password {noop}. Bài tiếp theo xác thực người dùng từ database: UserDetailsService nạp user từ database, PasswordEncoder với BCrypt, và đăng ký, đăng nhập.

Bài viết liên quan

[Spring Boot Basics] Phân quyền trong Spring Security: role, @PreAuthorize, cấu hình CORS và CSRF

Authorization trong Spring Security trên Spring Boot 4.1.1, cho một JWT API: authority và tiền tố ROLE_, hasRole so với hasAuthority, hasRole("ROLE_ADMIN") làm gì trong URL rule và trong SpEL, các rule authorizeHttpRequests cho catalogue với 401, 403 và 200, @EnableMethodSecurity, @PreAuthorize và @PostAuthorize với rule ownership, bẫy self-invocation và catch-all advice biến một 403 từ method security thành 500, một RoleHierarchy bean, CORS với preflight bị chặn trong headless Chrome và được http.cors cùng CorsConfigurationSource trả lời, allowedOrigins("*") kèm allowCredentials, và CSRF trên session chain với csrf.spa, cookie XSRF-TOKEN và header X-XSRF-TOKEN, và vì sao bearer API có thể tắt nó.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Cài đặt Spring Boot: JDK, IDE, Spring Initializr và ứng dụng đầu tiên

Cài JDK 21 trên macOS, Windows và Linux, sửa JAVA_HOME trỏ nhầm JDK, so sánh IntelliJ IDEA với VS Code, tạo project Spring Boot 4.1.1 bằng Spring Initializr hoặc một câu lệnh curl, chạy bằng Gradle wrapper, đọc log khởi động từng dòng, viết @RestController trả về JSON, đổi server.port và xử lý năm lỗi mà người mới nào cũng gặp.

[Spring Boot Basics] Auditing với Spring Data JPA: @CreatedDate, @LastModifiedDate và @CreatedBy

Auditing với Spring Data JPA trên Spring Boot 4.1.1 và PostgreSQL: @EnableJpaAuditing, AuditingEntityListener và một base class @MappedSuperclass, Flyway migration thêm column audit NOT NULL vào table đã có dữ liệu, giá trị null âm thầm khi thiếu annotation hoặc listener, Instant so với LocalDateTime và OffsetDateTime cùng giá trị timestamptz thực sự lưu, @LastModifiedDate đổi khi nào và modifyOnCreate thay đổi gì, save() một detached entity ghi null vào created_at và @Column(updatable = false), @CreatedBy lấy từ header X-User qua AuditorAware, DateTimeProvider dựa trên Clock, bulk update và native update bỏ qua auditing, và so sánh với @CreationTimestamp, @UpdateTimestamp của Hibernate.