Command Palette

Search for a command to run...

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

Bài 33 đặt catalogue sau một SecurityFilterChain, bài 34 chuyển user vào bảng users kèm một role, và bài 35 thay HTTP Basic bằng JWT: POST /api/auth/login trả về một token đã ký và mọi call API gửi Authorization: Bearer …. Tất cả những điều đó trả lời câu hỏi ai đang gọi. Bài này trả lời caller được làm gì: các rule dựa trên role trong authorizeHttpRequests, @PreAuthorize@PostAuthorize trên method của service, và hai cơ chế phía browser quyết định một request có được phép chạm tới các rule đó hay không, CORS và CSRF.

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, validation, security, oauth2-resource-server, data-jpa, h2thymeleaf. Ứng dụng chạy ở port 8136, một origin thứ hai phục vụ một page trên port 8146, và output phía browser lấy từ headless Chrome được điều khiển qua DevTools protocol.

Hai caller, alice với ROLE_USER và admin với ROLE_ADMIN, đến một ổ khóa và đi ra với 200 hoặc 403

Hai user từ bài 34 làm việc này: alice với role USERadmin với role ADMIN, mỗi người đã login để lấy một token thật. Các security header mà bài 33 hiện đầy đủ được lược khỏi các output curl -i ở đây.

Role và authority

Spring Security không hề biết tới "role" tại điểm nó kiểm tra một rule. Một Authentication mang theo một collection các GrantedAuthority, mỗi cái là một chuỗi thuần, và mọi rule là một phép kiểm tra trên các chuỗi đó. Một role là một quy ước: một authority có tên bắt đầu bằng ROLE_. hasRole("ADMIN") là cách viết gọn cho "có authority ROLE_ADMIN".

Bài 35 ký mỗi JWT với một claim roles chứa tên role trần, và resource server của Boot ánh xạ nó thành một authority bằng hai property:

src/main/resources/application.properties
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_

authorities-claim-name=roles đọc claim, authority-prefix=ROLE_ gắn lại tiền tố, và không cần một JwtAuthenticationConverter bean nào. alice login và payload của token mang tên trần:

Bash
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8136/api/auth/login
JSON
{"accessToken":"eyJ…","tokenType":"Bearer","expiresIn":900}

Segment giữa của token đó, sau khi decode Base64url, là:

JSON
{"sub":"alice","exp":1789379998,"iat":1789379098,"roles":["USER"]}

Token trong lab này không có iss; TokenService của bài 35 thêm claim đó và kiểm tra nó, điều này không làm thay đổi phần bên dưới. Một endpoint /api/me nhỏ trả về các authority mà Spring Security dựng từ token:

Bash
curl -s -H "Authorization: Bearer $ALICE" http://localhost:8136/api/me
JSON
{"name":"alice","authorities":["ROLE_USER","FACTOR_BEARER"],"authenticationType":"JwtAuthenticationToken"}

USER trần đã thành authority ROLE_USER. FACTOR_BEARER là bản tương ứng của FACTOR_PASSWORD ở bài 33: Spring Security 7 ghi lại rằng request này xác thực bằng một bearer token. Token của admin mang "roles":["ADMIN"]/api/me của nó liệt kê ROLE_ADMIN.

hasRole("ADMIN") so với hasAuthority("ROLE_ADMIN")

Hai cái nói cùng một điều về ROLE_ADMIN; chúng chỉ khác nhau ở chỗ có thêm tiền tố hay không. Một probe bean mang năm biểu thức @PreAuthorize và một controller báo cái nào qua được cho caller:

src/main/java/com/example/demo/lab/RoleProbe.java
@Component
public class RoleProbe {
 
    @PreAuthorize("hasRole('ADMIN')")
    public void hasRoleAdmin() {
    }
 
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public void hasAuthorityRoleAdmin() {
    }
 
    @PreAuthorize("hasAuthority('ADMIN')")
    public void hasAuthorityAdmin() {
    }
}

Gọi với tư cách admin:

JSON
{"hasRole('ADMIN')":"allowed","hasAuthority('ROLE_ADMIN')":"allowed","hasAuthority('ADMIN')":"denied (AuthorizationDeniedException)"}
  • hasRole('ADMIN') gắn thêm ROLE_ và kiểm tra ROLE_ADMIN: allowed.
  • hasAuthority('ROLE_ADMIN') kiểm tra đúng chuỗi ROLE_ADMIN: allowed.
  • hasAuthority('ADMIN') kiểm tra đúng chuỗi ADMIN, thứ không ai có: denied. Đây là lỗi âm thầm khóa mọi admin, vì authority được lưu là ROLE_ADMIN.

hasRole("ROLE_ADMIN") làm gì

Đưa tiền tố vào hasRole là lỗi phổ biến còn lại, và Spring Security 7.1.1 xử lý nó khác nhau ở hai nơi nó xuất hiện. Trong một URL rule nó fail lúc khởi động:

src/main/java/com/example/demo/common/SecurityConfig.java
.requestMatchers("/api/admin/**").hasRole("ROLE_ADMIN")
Text
Caused by: java.lang.IllegalArgumentException: ROLE_ADMIN should not start with ROLE_ since ROLE_ is automatically prepended when using hasAnyRole. Consider using hasAnyAuthority instead.

Trong một biểu thức SpEL của @PreAuthorizekhông ném exception và không nhân đôi tiền tố: hasRole('ROLE_ADMIN') kiểm tra ROLE_ADMIN, y hệt hasRole('ADMIN'). Probe với tư cách admin trả về "hasRole('ROLE_ADMIN')":"allowed", còn với tư cách alice trả về "denied". Vậy URL rule bắt lỗi này một cách ồn ào còn method rule âm thầm làm đúng dù sao đi nữa; ở cả hai nơi, hãy viết hasRole('ADMIN').

Các rule URL trong authorizeHttpRequests

Các rule của catalogue là chính block authorizeHttpRequests từ bài 33, mở rộng cho toàn bộ bề mặt ghi. AuthorizationFilter đọc chúng từ trên xuống và matcher đầu tiên khớp sẽ thắng, nên các rule theo method đứng trước anyRequest():

src/main/java/com/example/demo/common/SecurityConfig.java
@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
    http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                    .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
                    .requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN") 
                    .requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN") 
                    .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
                    .requestMatchers("/api/admin/**").hasRole("ADMIN") 
                    .requestMatchers("/api/orders/**").authenticated() 
                    .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                    .jwt(Customizer.withDefaults())
                    .authenticationEntryPoint(problemHandler))
            .exceptionHandling(exceptions -> exceptions
                    .authenticationEntryPoint(problemHandler)
                    .accessDeniedHandler(problemHandler))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
}
  • GET /api/products/**permitAll(): ai cũng đọc được catalogue.
  • POST, PUT, DELETE /api/products/** cần ROLE_ADMIN: chỉ admin thay đổi được.
  • /api/admin/** chỉ dành cho admin, cho các endpoint như danh sách user.
  • /api/orders/** cần authenticated(): bất kỳ user đã login nào cũng đặt được order, còn các rule chi tiết hơn trên order thuộc về method security bên dưới.

ProblemDetailSecurityHandler là component ở bài 33, được gắn ở đây làm cả authenticationEntryPoint lẫn accessDeniedHandler để một từ chối ở mức filter được ghi thành một ProblemDetail; từ bài 35, commence của nó để BearerTokenAuthenticationEntryPoint ghi challenge Bearer trước khi ghi body.

Anonymous, alice và admin trên cùng một lệnh ghi

Cùng một POST /api/products từ ba caller cho thấy authentication và authorization là hai câu trả lời tách biệt. Không token:

Bash
curl -i -s -X POST -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8136/api/products
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:8136/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/products","status":401,"title":"Unauthorized"}

Token của alice, vốn chỉ mang ROLE_USER:

Bash
curl -i -s -X POST -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8136/api/products
Text
HTTP/1.1 403
Content-Type: application/problem+json
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/products","status":403,"title":"Forbidden"}

Token của admin:

Text
HTTP/1.1 201
Location: http://localhost:8136/api/products/3
Content-Type: application/json
 
{"id":3,"sku":"HB-001","name":"USB-C hub","price":350000}

Không token là 401 kèm challenge Bearer; một token hợp lệ nhưng thiếu role là 403; đúng role là 201. GET /api/admin/users hành xử y hệt: 403 cho alice, và cho admin là danh sách user dạng JSON. AuthorizationFilter quyết định tất cả những điều này trước khi DispatcherServlet chạy, đó là lý do các body đến từ access denied handler chứ không phải từ một controller.

Method security với @PreAuthorize và @PostAuthorize

URL rule khớp trên request: một method và một path. Chúng không thấy được argument hay return value, nên "một user chỉ đọc được order của chính mình" không thể là một URL rule. Method security thì được: nó bọc một bean trong một proxy để kiểm tra một biểu thức quanh mỗi call có annotation, dựa trên cùng một Authentication.

Nó tắt cho tới khi được bật. Một annotation trên một @Configuration class là đủ:

src/main/java/com/example/demo/common/MethodSecurityConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
 
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

Order service dùng nó trên ba method:

src/main/java/com/example/demo/order/OrderService.java
@Service
public class OrderService {
 
    @PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
    public List<Order> findByCustomer(String username) {
        return orders.values().stream()
                .filter(order -> order.customer().equals(username))
                .toList();
    }
 
    @PostAuthorize("returnObject.customer == authentication.name or hasRole('ADMIN')")
    public Order findById(long id) {
        Order order = orders.get(id);
        if (order == null) {
            throw new OrderNotFoundException(id);
        }
        return order;
    }
 
    @PreAuthorize("hasRole('ADMIN')")
    public void cancel(long id) {
        if (orders.remove(id) == null) {
            throw new OrderNotFoundException(id);
        }
    }
}
  • @PreAuthorize chạy trước method. hasRole('ADMIN') trên cancel là cùng một phép kiểm tra role như một URL rule.
  • #username trong biểu thức là argument cùng tên của method; authentication.name là user hiện tại. findByCustomer cho một user đọc order của chính mình và cho admin đọc của bất kỳ ai.
  • @PostAuthorize chạy sau method và đọc được returnObject. findById load order, rồi kiểm tra nó có thuộc về caller không. Chỉ dùng nó khi phải fetch object mới đánh giá được, vì method đã chạy rồi.

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

Có. Spring Boot 4.1.1 không tự bật method security. Khi bỏ @EnableMethodSecurity và giữ nguyên mọi thứ khác, các annotation trở nên vô hiệu: alice xóa được một order qua cancel, dù @PreAuthorize("hasRole('ADMIN')") của nó lẽ ra phải chặn cô.

Bash
curl -i -s -X DELETE -H "Authorization: Bearer $ALICE" http://localhost:8136/api/orders/1
Text
HTTP/1.1 204

Role probe ở trên xác nhận điều đó: không có annotation, mọi biểu thức, kể cả hasRole('ADMIN'), đều trả allowed cho alice. @EnableGlobalMethodSecurity, annotation của Spring Security 5, đã bị gỡ; @EnableMethodSecurity là bản thay thế và mặc định prePostEnabled = true.

Một rule ownership với @PreAuthorize

Khi method security bật, findByCustomer áp dụng ownership. alice đọc order của chính mình:

Bash
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders?customer=alice"
Text
HTTP/1.1 200
Content-Type: application/json
 
[{"id":1,"customer":"alice","sku":"KB-001","quantity":1}]

alice hỏi order của admin thì bị từ chối, trong khi admin được hỏi của bất kỳ ai:

Bash
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders?customer=admin"
Text
HTTP/1.1 403
Content-Type: application/problem+json
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders","status":403,"title":"Forbidden"}

@PostAuthorize trên return value

findById fetch trước rồi kiểm tra sau. alice đọc được order 1 của mình, nhưng không đọc được order 2 của admin:

Bash
curl -i -s -H "Authorization: Bearer $ALICE" http://localhost:8136/api/orders/2
Text
HTTP/1.1 403
Content-Type: application/problem+json
 
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders/2","status":403,"title":"Forbidden"}

Order đã được load từ store rồi bị giữ lại. @PostAuthorize không thể hoàn tác method, nên đừng bao giờ đặt nó trên một method có ghi dữ liệu: lệnh ghi sẽ diễn ra và chỉ có response bị chặn.

Cơ chế: một bearer token thành ROLE_USER qua resource server, đi qua các URL rule của AuthorizationFilter và controller, rồi qua @PreAuthorize proxy trên service; một 403 từ URL rule do access denied handler ghi ra từ ExceptionTranslationFilter, một 403 từ method security bị ném bên trong Spring MVC nơi một catch-all advice có thể biến nó thành 500 cho tới khi một handler rethrow nó

Self-invocation bỏ qua phép kiểm tra

Method security là một proxy, y hệt @Transactional ở bài 30, nên nó có cùng điểm mù: một call từ trong cùng một bean không đi qua proxy. OrderService có một method summary không annotation, gọi findByCustomer trên this:

src/main/java/com/example/demo/order/OrderService.java
public OrderSummary summary(String username) {
    List<Order> customerOrders = findByCustomer(username);
    return new OrderSummary(username, customerOrders.size(),
            customerOrders.stream().mapToInt(Order::quantity).sum());
}

findByCustomer được bảo vệ, nhưng alice vẫn đọc được summary order của admin:

Bash
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders/summary?customer=admin"
Text
HTTP/1.1 200
Content-Type: application/json
 
{"customer":"admin","orders":1,"items":3}

Call this.findByCustomer(username) là một call Java thuần trên target object; nó không đi ngược ra qua proxy, nên @PreAuthorize không hề chạy. Chạm tới findByCustomer qua controller, vốn có đi qua proxy, cho ra 403 ở trên. Cách sửa là của bài 30: annotate method vào từ ngoài, hoặc chuyển call được bảo vệ sang một bean khác.

Khi một catch-all advice biến một 403 thành 500

Một từ chối ở URL rule bị ném bên trong AuthorizationFilter, nơi ExceptionTranslationFilter bắt nó và gọi access denied handler. Một từ chối ở method security thì khác: proxy chạy bên trong DispatcherServlet, nên AuthorizationDeniedException của nó đi ra qua Spring MVC như bất kỳ exception nào của controller, và catch-all của bài 20 thấy nó trước:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
        log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
        problem.setTitle("Internal Server Error");
        return problem;
    }

Với advice đó có mặt và không có gì khác, alice hỏi order của admin nhận về một 500, không phải 403:

Text
HTTP/1.1 500
Content-Type: application/problem+json
 
{"detail":"An unexpected error occurred.","instance":"/api/orders","status":500,"title":"Internal Server Error"}

AuthorizationDeniedException là một AccessDeniedException, và catch-all khớp nó như một Exception, log nó như một bug và che đi 403 thật. Cách sửa là một handler trả exception về lại cho security chain, nơi access denied handler ghi ra chính 403 mà các URL rule đã tạo:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
import org.springframework.security.access.AccessDeniedException; 
 
    @ExceptionHandler(AccessDeniedException.class) 
    public void rethrowAccessDenied(AccessDeniedException ex) { 
        throw ex; 
    } 

Khi được rethrow, exception rời DispatcherServlet, ExceptionTranslationFilter bắt nó, và cùng ProblemDetailSecurityHandler ghi ra 403. Sau thay đổi này mọi từ chối ở method security đều trả về 403 ProblemDetail như trên, và log không ghi nhận exception nào chưa được xử lý.

Role hierarchy: ADMIN kéo theo USER

adminROLE_ADMIN và không gì khác, nên một rule viết cho USER sẽ từ chối nó. Nếu đặt order cần hasRole('USER'), admin sẽ không đặt được. Một RoleHierarchy bean nói một role kéo theo một role khác:

src/main/java/com/example/demo/common/MethodSecurityConfig.java
import org.springframework.security.access.hierarchicalroles.RoleHierarchy; 
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; 
 
    @Bean
    static RoleHierarchy roleHierarchy() { 
        return RoleHierarchyImpl.withDefaultRolePrefix() 
                .role("ADMIN").implies("USER") 
                .build(); 
    } 

Với một rule /api/orders/**hasRole('USER'), admin đặt được order và nhận 201, dù token vẫn chỉ mang ROLE_ADMIN. Hierarchy được áp dụng khi một rule được kiểm tra, chứ không phải bằng cách thêm authority: /api/me của admin vẫn liệt kê ["ROLE_ADMIN","FACTOR_BEARER"].

Hierarchy có tới được @PreAuthorize không?

Nó tới được cả hai. Role probe, chạy cùng hierarchy bean, trả "hasRole('USER')":"allowed" cho admin; không có bean thì cùng call đó là "denied". Vậy một bean chi phối cả URL rule lẫn @PreAuthorize, và không cần liệt kê mọi role được kéo theo trong một rule. alice, người không phải admin, chẳng được thêm gì: ROLE_USER không kéo theo ROLE_ADMIN, và các call chỉ-admin của cô vẫn 403.

URL rule so với method security

Cả hai đều kiểm tra authorization, nhưng ở các điểm khác nhau và với thông tin khác nhau. Bảng dưới quyết định nên chọn cái nào.

URL rule trong authorizeHttpRequests@PreAuthorize / @PostAuthorize
Được kiểm tra bởiAuthorizationFilter, trước DispatcherServletmột method proxy, bên trong Spring MVC
Chạy khimọi request khớp pathmọi call tới method có annotation (trừ self-invocation)
Thấy đượcHTTP method và pathargument của method, và với @PostAuthorize là return value
Không thấyargument hay return valuerequest không bao giờ chạm tới method
Từ chối được ghi bởiaccessDeniedHandler trên ExceptionTranslationFilterném vào Spring MVC; cần tới được access denied handler, không phải một catch-all
RoleHierarchyáp dụngáp dụng
Hợp nhất chorule thô theo hình dạng URL: đọc công khai, ghi cho adminrule về dữ liệu: ownership, kiểm tra từng bản ghi

Dùng URL rule cho hình dạng tổng thể của API và method security cho các rule phụ thuộc vào dữ liệu. Chúng chồng lên nhau: một request qua URL rule trước, rồi tới method rule.

CORS: same-origin policy và preflight request

Một browser chỉ cho một page đọc response từ một origin khác nếu origin đó cho phép. Origin là scheme, host và port cộng lại, nên một page phục vụ từ http://localhost:8146 gọi API trên http://localhost:8136 là cross-origin. Với một request có thể thay đổi dữ liệu hoặc mang header tùy chỉnh, browser gửi trước một preflight: một OPTIONS kèm Origin, Access-Control-Request-MethodAccess-Control-Request-Headers, hỏi xem request thật có được phép không. Server trả lời bằng các header Access-Control-Allow-* hoặc browser chặn request thật.

Preflight khi chưa cấu hình CORS

Preflight là một request thật đi qua security chain. Khi chưa cấu hình CORS, OPTIONS /api/products của catalogue là chưa xác thực và API chain trả 401 trước khi thêm bất kỳ header CORS nào:

Bash
curl -i -s -X OPTIONS -H 'Origin: http://localhost:8146' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type' http://localhost:8136/api/products
Text
HTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:8136/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
 
{"detail":"Valid credentials are required to access this resource.","instance":"/api/products","status":401,"title":"Unauthorized"}

Một page cho thấy browser hiểu điều đó ra sao. Phục vụ trên port 8146, nó fetch API bằng token của admin:

page phục vụ trên http://localhost:8146
<script>
  const token = new URLSearchParams(location.hash.slice(1)).get('token');
 
  fetch('http://localhost:8136/api/products', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + token,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ sku: 'HB-001', name: 'USB-C hub', price: 350000 })
  })
    .then(response => response.json().then(body => console.log(response.status, JSON.stringify(body))))
    .catch(error => console.log('fetch failed:', error.message));
</script>

Headless Chrome gửi preflight, nhận 401 không kèm Access-Control-Allow-Origin, không bao giờ gửi POST, và log:

Text
Access to fetch at 'http://localhost:8136/api/products' from origin 'http://localhost:8146' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
fetch failed: Failed to fetch

Cấu hình CORS chỉ trên Spring MVC, bằng @CrossOrigin hay addCorsMappings, không sửa được điều này. Spring Security chạy trước DispatcherServlet, và preflight bị từ chối trong filter chain trước khi tới được phần xử lý CORS của MVC: với chỉ addCorsMappings("/api/**"), cùng preflight đó vẫn trả 401, dù một GET đơn giản có chạm tới controller thì trả về kèm Access-Control-Allow-Origin. CORS phải được xử lý bên trong security chain.

Cấu hình CORS trong Spring Security

http.cors(...) thêm một CorsFilter vào chain, trước authentication, và nó đọc một CorsConfigurationSource bean:

src/main/java/com/example/demo/common/SecurityConfig.java
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(Customizer.withDefaults())
                        .authenticationEntryPoint(problemHandler))
                .cors(Customizer.withDefaults()) 
                .exceptionHandling(exceptions -> exceptions
src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:8146"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }

Preflight giờ được trả lời sớm kèm các header allow, trước authentication:

Text
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:8146
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600

Browser sau đó gửi POST thật kèm bearer token của admin, và console của nó log 201 cùng sản phẩm được tạo; response mang Access-Control-Allow-Origin: http://localhost:8146. Một preflight từ http://evil.example, một origin không có trong danh sách, bị trả 403 với body Invalid CORS request. Hai chi tiết đáng biết: CorsFilter chỉ quyết định browser có được đọc response hay không, nó không phải authorization, nên request thật vẫn phải qua các JWT rule; và một UrlBasedCorsConfigurationSource bean được nhận diện ngay cả khi không có dòng http.cors(...) tường minh, vì HttpSecurityConfiguration gọi applyCorsIfAvailable khi một bean như vậy tồn tại. Viết http.cors(Customizer.withDefaults()) nêu rõ ý định và chạy được với mọi loại CorsConfigurationSource.

allowedOrigins("*") kèm allowCredentials(true)

Wildcard * cho origin trông tiện và vỡ ngay khi có credentials. Với setAllowedOrigins(List.of("*"))setAllowCredentials(true), preflight fail lúc chạy:

Text
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true bị CORS spec cấm dùng chung, và Spring từ chối phát ra chúng. Khi origin phải được khớp theo pattern, allowedOriginPatterns là bản thay thế có credentials:

src/main/java/com/example/demo/common/SecurityConfig.java
        config.setAllowedOrigins(List.of("*")); 
        config.setAllowedOriginPatterns(List.of("http://localhost:[*]")); 
        config.setAllowCredentials(true); 

Với một pattern, preflight lặp lại chính origin của caller và thêm header credentials:

Text
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:8146
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600

Bearer API không cần allowCredentials, vì token đi trong một header chứ không phải cookie. Nó quan trọng với session chain ở phần CSRF, nơi browser phải được phép gửi cookie.

CSRF: cross-site request forgery

Một browser đính kèm cookie của một site vào mọi request tới site đó, bất kể ai gây ra request. Nên nếu một user đã login bằng một session cookie, một site khác có thể đặt một form hoặc một fetch trên page của nó để post tới ứng dụng của bạn, và browser gửi cookie của user kèm theo: request được xác thực dù user không hề có ý làm. Đó là CSRF. Cách phòng thủ là một token mà site kia không đọc được, bắt buộc trên mọi request thay đổi trạng thái và được đối chiếu với một token đã lưu cho session; page của kẻ tấn công có cookie nhưng không có token.

Ba làn: một cross-site POST từ page của kẻ tấn công mang session cookie nhưng không có token và bị từ chối 403; một client hợp lệ đọc cookie XSRF-TOKEN và gửi X-XSRF-TOKEN nên thành công; một bearer token trong header Authorization không bao giờ được browser đính kèm, nên đòn tấn công không áp dụng

Đòn tấn công trên một session chain

Chain thứ hai của catalogue, web chain từ bài 33, phục vụ một trang account nhỏ bằng Thymeleaf sau form login, với một session cookie và CSRF protection bật mặc định. Đã login với tư cách alice trong browser, một page của kẻ tấn công trên port 8146 tự submit một form tới endpoint email của account:

page của kẻ tấn công phục vụ trên http://localhost:8146
<form id="prize" action="http://localhost:8136/account/email" method="post">
  <input type="hidden" name="email" value="mallory@evil.example">
</form>
<script>
  document.getElementById('prize').submit();
</script>

Browser đính kèm JSESSIONID của alice vào POST cross-site, nhưng form không có CSRF token, và Spring Security từ chối nó. Server log Invalid CSRF token found for http://localhost:8136/account/email và trả 401 hay 403? Nó trả 403; email sau đó không đổi. Status thật đáng chốt lại, vì bài 33 đã chỉ ra ERROR dispatch có thể viết lại một security status: ở đây thì không. AccessDeniedHandlerImpl gọi sendError(403), ERROR dispatch tới /error mang theo session cookie của alice, nên /error render như một request đã xác thực và giữ nguyên 403. Một lần đi qua bằng curl cho luồng hợp lệ cho thấy token đã thiếu: GET /login rồi GET /account mỗi trang mang một field ẩn _csrf dài 96 ký tự, và một POST /account/email thiếu nó là 403 còn cùng post kèm nó là 302 tới /account?updated.

⚠️ Tắt CSRF trên một chain xác thực bằng cookie sẽ mở lại đúng lỗ hổng này. Với csrf.disable() trên web chain, page tấn công ở trên đổi email của alice thành mallory@evil.example mà không cần token nào. Giữ CSRF bật cho bất cứ thứ gì login bằng cookie.

Một JavaScript client với csrf.spa()

Một field form ẩn hợp với các page render phía server; một JavaScript client trên một session cần token ở một nơi nó đọc và gửi được. Spring Security 7.1.1 đóng gói điều này thành csrf.spa():

src/main/java/com/example/demo/common/SecurityConfig.java
    @Bean
    @Order(2)
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .csrf(csrf -> csrf.spa()); 
        return http.build();
    }

csrf.spa() lưu token trong một cookie tên XSRF-TOKEN mà JavaScript đọc được (nó không phải HttpOnly) và mong nhận lại trong header X-XSRF-TOKEN. Sau form login, cookie được set, và một PUT từ script lặp lại nó vào header thì thành công:

Bash
curl -i -s -b cookies.txt -X PUT -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $XSRF" -d '{"email":"alice@wonderland.example"}' http://localhost:8136/account/email
Text
HTTP/1.1 200
Content-Type: application/json
 
{"id":1,"username":"alice","email":"alice@wonderland.example","role":"USER"}

Cùng PUT mà thiếu header X-XSRF-TOKEN thì là 403. Page của kẻ tấn công không đọc được cookie XSRF-TOKEN, vì same-origin policy chặn nó đọc một response hay một cookie từ origin khác, nên nó không cung cấp được header.

Vì sao JWT API có thể tắt CSRF

API chain đặt csrf.disable(), và điều đó an toàn chính vì không gì về API được đính kèm tự động. JWT đi trong header Authorization: Bearer …, và một browser không bao giờ tự thêm header đó vào một request cross-site; page của kẻ tấn công sẽ phải biết token và tự set, mà nếu nó biết token thì cuộc chơi đã kết thúc. Page tấn công post tới POST /api/products/1 từ một origin khác đến nơi không có header Authorization và nhận 401 của API. Không có credential ngầm nào để giả mạo, nên không có gì để một CSRF token phải bảo vệ.

Điều này thôi đúng ngay khi token được chuyển vào một cookie mà browser tự gửi. Một JWT lưu trong cookie được đính kèm vào mọi request cross-site y hệt một session id, và API quay lại cột đầu tiên của sơ đồ: CSRF lại áp dụng, và chain phải phòng thủ. Giữ token trong header Authorization, hoặc bật lại CSRF protection.

API giờ áp đặt những gì

Chương 5 biến một catalogue mở thành một API đã xác thực và đã phân quyền. Mỗi cơ chế nằm ở đâu:

  • Ai đang gọi — một JWT được resource server validate; token thiếu hoặc sai là 401 kèm challenge Bearer, ghi thành một ProblemDetail.
  • Rule thô theo URLauthorizeHttpRequests: đọc sản phẩm công khai, ROLE_ADMIN cho ghi sản phẩm và /api/admin/**, xác thực cho order. Kiểm tra trong AuthorizationFilter.
  • Rule về dữ liệu@PreAuthorize@PostAuthorize trên service, sau @EnableMethodSecurity: ownership trên order, hủy order chỉ cho admin. Từ chối phải tới được access denied handler, không phải một catch-all advice.
  • Quan hệ giữa các role — một RoleHierarchy bean, áp dụng cho cả URL rule lẫn method security.
  • Truy cập cross-originhttp.cors(...) cùng một CorsConfigurationSource, để preflight của browser được trả lời bên trong security chain.
  • CSRF — tắt trên bearer API stateless vì không có credential ngầm; giữ bật, với csrf.spa(), cho web chain dùng cookie-session.

FAQ

Khác biệt giữa hasRole và hasAuthority trong Spring Security là gì?

hasRole("ADMIN") gắn thêm ROLE_ và kiểm tra authority ROLE_ADMIN; hasAuthority("ROLE_ADMIN") kiểm tra đúng chuỗi đó. Chúng tương đương nhau cho một role. hasAuthority("ADMIN") kiểm tra ADMIN, thứ không người giữ role nào có, nên nó âm thầm từ chối mọi admin. Vì Spring Security lưu role dưới dạng authority có tiền tố ROLE_, hãy dùng hasRole cho role và để dành hasAuthority cho các authority không phải role.

Tôi có cần @EnableMethodSecurity trong Spring Boot 4 không?

Có. Cả Spring Boot 4.1.1 lẫn Spring Security 7.1.1 đều không bật method security mặc định, nên @PreAuthorize@PostAuthorize bị bỏ qua cho tới khi một @Configuration class mang @EnableMethodSecurity. Trong một lần chạy không có nó, một method @PreAuthorize("hasRole('ADMIN')") cho một caller ROLE_USER đi qua. Annotation của Spring Security 5 là @EnableGlobalMethodSecurity đã bị gỡ.

Vì sao method @PreAuthorize của tôi không chặn gì khi được gọi từ cùng class?

Method security là một proxy, giống @Transactional. Một call tới một method có annotation từ một method khác của cùng bean là this.method(...), một call Java thuần không đi qua proxy, nên phép kiểm tra không chạy. Một method summary gọi this.findByCustomer(...) trả về dữ liệu của người khác. Hãy annotate method vào từ ngoài, hoặc chuyển call được bảo vệ sang một bean riêng.

Vì sao một từ chối @PreAuthorize trả 500 thay vì 403?

Vì một catch-all @ExceptionHandler(Exception.class) bắt nó. Một từ chối ở method security ném AuthorizationDeniedException, một AccessDeniedException, bên trong Spring MVC, và một catch-all khớp nó như một Exception rồi trả 500. Thêm một @ExceptionHandler(AccessDeniedException.class) rethrow exception; nó khi đó rời DispatcherServlet, ExceptionTranslationFilter bắt nó, và access denied handler ghi ra 403.

Vì sao cấu hình CORS của tôi bị bỏ qua khi bật Spring Security?

Vì Spring Security chạy trước Spring MVC. Một preflight OPTIONS bị từ chối trong filter chain trước khi tới được @CrossOrigin hay addCorsMappings của MVC, nên một page thấy blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present. Thêm http.cors(...) và một CorsConfigurationSource bean để CorsFilter xử lý preflight bên trong chain.

Tắt CSRF cho một REST API có an toàn không?

Với một API stateless xác thực bằng một bearer token trong header Authorization, có: browser không bao giờ đính kèm header đó vào một request cross-site, nên không có credential ngầm để giả mạo. Nó thôi an toàn nếu token được lưu trong một cookie mà browser tự gửi, hoặc nếu cùng ứng dụng xác thực bất cứ thứ gì từ một session cookie; những cái đó cần CSRF protection, với csrf.spa() cho một JavaScript client.

Kết luận

Authorization trong Spring Security là các authority được kiểm tra ở hai nơi. URL rule trong authorizeHttpRequests chạy trong AuthorizationFilter và chỉ thấy method và path; @PreAuthorize@PostAuthorize, một khi @EnableMethodSecurity bật, chạy trong một proxy và thấy được argument cùng return value, đúng thứ một rule ownership cần. Role là các authority có tiền tố ROLE_, hasRole gắn tiền tố, và một RoleHierarchy bean cho một role kéo theo một role khác cho cả hai loại rule. Các bẫy lặp lại điều các chương trước đã dạy: một proxy bị bỏ qua bởi self-invocation, và một catch-all advice có thể biến một 403 hình-filter thành một 500 trừ khi nó trả exception về lại cho security chain.

CORS và CSRF là về browser, không phải logic riêng của API. Same-origin policy khiến một call cross-origin gửi một preflight, mà Spring Security phải trả lời bên trong chain của nó bằng http.cors(...) và một CorsConfigurationSource, vì một cấu hình chỉ-MVC bị từ chối trước khi MVC chạy. CSRF tồn tại vì browser tự đính kèm cookie, nên một cookie-session chain cần một token, đưa tới một JavaScript client qua cookie XSRF-TOKEN và header X-XSRF-TOKEN, trong khi một bearer-token API có thể tắt nó vì không gì được đính kèm tự động.

Điều đó khép lại Chương 5 và toàn bộ mạch security. Chương 6 mở màn với testing: bài tiếp theo viết unit test cho tầng service với JUnit 5, AssertJ và Mockito.

Bài viết liên quan

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

Spring Security trên Spring Boot 4.1.1: spring-boot-starter-security thay đổi gì, từ password sinh tự động tới 401 kèm WWW-Authenticate cho curl và redirect tới trang login mặc định cho trình duyệt, authentication và authorization qua các response 401 và 403, DelegatingFilterProxy, FilterChainProxy và 16 filter của chain mặc định, bean SecurityFilterChain viết bằng lambda DSL không cần @EnableWebSecurity, lỗi CSRF 403 đến tay curl thành 401, SessionCreationPolicy.STATELESS, thứ tự requestMatchers và permitAll so với anonymous, hai chain với securityMatcher và @Order, 401 và 403 dưới dạng ProblemDetail, và InMemoryUserDetailsManager với password {noop}.

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.

[Spring Boot Basics] Phân trang và sắp xếp trong Spring Boot: Pageable, Sort và trả kết quả phân trang qua API

Phân trang và sắp xếp trong Spring Boot 4.1.1 với Spring Data JPA, trên H2 và PostgreSQL: Sort với ignoreCase, nullsFirst, nullsLast và SQL mỗi cách sinh ra trên hai database, TypedSort đã deprecated, PageRequest đếm từ 0, Page, Slice và List cùng count query và row thừa phía sau từng loại, khi nào Spring Data bỏ count, @Query và native query với Pageable, totalElements bị thổi phồng khi phân trang JOIN FETCH, controller nhận Pageable với @PageableDefault, max-page-size và one-indexed parameter, warning khi serialize PageImpl, PagedModel so với record PageResponse, ProblemDetail 400 cho sort property không tồn tại, và OFFSET so với keyset scrolling bằng Window.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1: SLF4J là facade và Logback là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.