Spring Security & Testing/Authentication flow & SecurityContext — từ request đến principal trong ThreadLocal
3/20
Bài 3 / 20~12 phútSecurity Architecture & Filter ChainMiễn phí lượt xem

Authentication flow & SecurityContext — từ request đến principal trong ThreadLocal

Bóc đúng một mảnh của Spring Security: request đi qua filter, AuthenticationManager điều phối AuthenticationProvider để trả Authentication hoặc ném exception; kết quả lưu vào SecurityContextHolder ThreadLocal per-request. Giải thích tại sao ThreadLocal, tại sao tách authentication/authorization, và cơ chế exception mapping 401/403.

TL;DR: Khi request mang Authorization: Bearer <token> vào Spring Security, BearerTokenAuthenticationFilter trích token, xây object Authentication chưa xác thực rồi giao cho AuthenticationManager.authenticate(). ProviderManager lần lượt hỏi từng AuthenticationProvider — provider nào khai báo supports() đúng loại token sẽ thực sự validate. Kết quả trả về là Authentication đã xác thực, được lưu vào SecurityContextHolder bằng ThreadLocal — mỗi thread (mỗi request) giữ context riêng, không lẫn. Cuối chain, AuthorizationFilter đọc context đó để quyết định cho qua hay ném AccessDeniedException. ExceptionTranslationFilter map: AuthenticationException → 401, AccessDeniedException → 403.

Bài 01 — Filter chain architecture đã bóc cấu trúc ba tầng DelegatingFilterProxyFilterChainProxySecurityFilterChain. Bài này đào sâu vào cơ chế bên trong một lần xử lý request: authentication flow, SecurityContext, và exception path.

1. Scenario — request JWT vào API

TaskFlow v2 có endpoint GET /api/projects. Request từ frontend mang header:

Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.sig

Chuỗi câu hỏi cần trả lời:

  1. Filter nào trong chain chịu trách nhiệm đọc header đó?
  2. Token được validate ở đâu, bằng cơ chế gì?
  3. "Alice đã login" được lưu ở đâu để controller sau đó đọc được?
  4. Nếu token hết hạn, response 401 được sinh ra ở đâu trong chain?

Bốn câu hỏi đó chính xác là bốn thứ bài này giải thích.

2. Authentication flow — cơ chế bên dưới

Đường đi của một request JWT, kèm hai chỗ nó có thể rơi ra ngoài:

Đường đi thẳng của một request JWT: từ header Authorization Bearer tới BearerTokenAuthenticationFilter, ProviderManager, JwtAuthenticationProvider, rồi SecurityContextHolder trong ThreadLocal, tiếp đến AuthorizationFilter và Controller; hai lối thoát rẽ ngang là AuthenticationException thành 401 qua AuthenticationEntryPoint khi token sai, và AccessDeniedException thành 403 qua AccessDeniedHandler khi thiếu quyền

Ba actor quan trọng là AuthenticationManager, AuthenticationProvider, và SecurityContextHolder — mỗi actor có trách nhiệm tách biệt. SecurityContextHolder ở giữa hình chính là chỗ nối: nửa trên ghi danh tính vào đó, nửa dưới đọc ra để phân quyền. Hai lối thoát 401 và 403 sẽ được bóc kỹ ở mục 8.

3. Authentication object — hai trạng thái

Authentication (xác thực) là interface trung tâm, đại diện danh tính của một request. Nó tồn tại ở hai trạng thái khác nhau trong một lần xử lý:

public interface Authentication {
    String getName();
    Object getPrincipal();       // user object (UserDetails, JWT subject)
    Object getCredentials();     // password / token — cleared sau auth
    Collection<? extends GrantedAuthority> getAuthorities();
    boolean isAuthenticated();
    Object getDetails();         // request metadata (IP, session id)
}
Trạng tháiisAuthenticated()getPrincipal()getCredentials()getAuthorities()
Trước xác thực (input)falseusername / raw tokenpassword / token rawrỗng
Sau xác thực (output)trueUserDetails objectnull (đã xoá)roles / permissions đầy đủ

Chuyển trạng thái chỉ xảy ra qua đúng một cửa — provider.authenticate():

Bốn trường của cùng một object đặt cạnh nhau trước và sau provider.authenticate: trước thì isAuthenticated false, principal là username hoặc token thô, credentials là mật khẩu hoặc token thô, authorities rỗng; sau thì isAuthenticated true, principal là UserDetails, credentials bằng null vì đã bị xoá, authorities có ROLE_USER

Lý do credentials bị xoá sau khi xác thực thành công: giảm rủi ro password hoặc raw token nằm trong heap quá lâu. Nếu memory dump bị lấy, token không còn ở đó. ProviderManager mặc định bật eraseCredentialsAfterAuthentication = true.

4. AuthenticationManager — entry point duy nhất

AuthenticationManager là interface entry point cho mọi yêu cầu xác thực trong Spring Security:

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication)
        throws AuthenticationException;
}

Interface chỉ có một method duy nhất. Filter gọi manager.authenticate(unauthenticated) rồi nhận lại Authentication đã xác thực, hoặc bắt exception nếu thất bại.

Implementation mặc định là ProviderManager — bên trong nó giữ một danh sách AuthenticationProvider và thử lần lượt:

// Simplified logic in ProviderManager.authenticate()
for (AuthenticationProvider provider : this.providers) {
    if (!provider.supports(authentication.getClass())) {
        continue;   // skip — not this provider's job
    }
    try {
        result = provider.authenticate(authentication);
        if (result != null) {
            copyDetails(authentication, result);
            break;
        }
    } catch (AccountStatusException | InternalAuthenticationServiceException ex) {
        throw ex;   // non-recoverable — stop immediately
    } catch (AuthenticationException ex) {
        lastException = ex;   // try next provider
    }
}
if (result == null) throw lastException;

Điểm quan trọng: ProviderManager không biết gì về JWT hay username/password — nó chỉ điều phối. Kiến thức về từng loại token nằm trong AuthenticationProvider. Provider đầu tiên vừa supports() vừa authenticate thành công sẽ thắng — các provider sau không được hỏi:

ProviderManager hỏi từ trên xuống: DaoAuthenticationProvider trả supports false nên bị bỏ qua ngay, JwtAuthenticationProvider trả supports true nên nhận việc và gọi authenticate để verify token, còn provider phía dưới không có mũi tên nào đi vào vì vòng lặp đã dừng

5. AuthenticationProvider — pluggable strategy

AuthenticationProvider là strategy cho từng loại token. Mỗi provider khai báo supports() để nói "tôi handle loại token nào":

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication)
        throws AuthenticationException;
    boolean supports(Class<?> authentication);
}

Bảng provider phổ biến trong Spring Security:

ProviderToken typeDùng khi
DaoAuthenticationProviderUsernamePasswordAuthenticationTokenForm login, Basic Auth
JwtAuthenticationProviderBearerTokenAuthenticationTokenJWT Bearer token
Saml2AuthenticationProviderSaml2AuthenticationTokenSAML SSO
CustomCustom tokenOAuth2 client credential, API key

Tách authentication thành nhiều provider độc lập cho phép app hỗ trợ đồng thời nhiều phương thức xác thực — cùng một chain có thể chấp nhận cả form login và JWT Bearer mà không có code rẽ nhánh if/else.

5.1 DaoAuthenticationProviderUserDetailsService

Khi người dùng submit form login (username + password), DaoAuthenticationProvider cần tra cứu user từ database. Nó làm việc này qua UserDetailsService — interface một method:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

App triển khai UserDetailsService để kết nối với DB của mình. Xem chi tiết tại UserDetailsService & BCrypt.

6. SecurityContext + ThreadLocal — tại sao mỗi request có context riêng

Sau khi provider trả về Authentication đã xác thực, filter lưu nó vào SecurityContextHolder:

// In BearerTokenAuthenticationFilter (simplified)
Authentication authResult = authManager.authenticate(token);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);

chain.doFilter(request, response);   // pass to next filter / controller

SecurityContextHolder.clearContext(); // cleanup — CRITICAL

SecurityContextHolder dùng ThreadLocal làm storage:

public class ThreadLocalSecurityContextHolderStrategy
        implements SecurityContextHolderStrategy {

    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder =
        new ThreadLocal<>();

    @Override
    public SecurityContext getContext() {
        return getDeferredContext().get();
    }

    @Override
    public void clearContext() {
        contextHolder.remove();   // remove, not set null
    }
}

6.1 Tại sao ThreadLocal?

Câu hỏi cốt lõi: tại sao Spring Security chọn ThreadLocal thay vì truyền Authentication qua method parameter từ filter xuống controller?

Lý do kiến trúc:

  • Không sửa API hiện có: Servlet Filter.doFilter()HttpServlet.service() có signature cố định — không thể thêm parameter Authentication vào mà không phá vỡ toàn bộ Servlet API. ThreadLocal là cách inject context mà không đụng đến signature.
  • Mỗi request chạy trên một thread riêng (mô hình thread-per-request của Tomcat): mỗi thread có vùng ThreadLocal độc lập → context của request Alice không bao giờ lẫn sang request Bob dù cùng chạy song song.
  • Transparent cho business code: controller không cần nhận Authentication qua parameter — framework tự inject qua @AuthenticationPrincipal hoặc SecurityContextHolder.getContext().

Hình dung:

Thread-1 (Alice's request)        Thread-2 (Bob's request)
  ThreadLocal["context"] = Alice    ThreadLocal["context"] = Bob
  ...                               ...
  getContext() → Alice              getContext() → Bob   ← riêng biệt
  clearContext()                    clearContext()

6.2 Cleanup bắt buộc

clearContext() được gọi trong finally block sau khi chain hoàn thành. Nếu không clear, thread trả về pool vẫn còn SecurityContext cũ — request tiếp theo lấy thread đó ra sẽ đọc context của request trước. Đây là lỗ hổng bảo mật nghiêm trọng.

// Pattern đúng — cleanup trong finally
try {
    SecurityContextHolder.setContext(context);
    chain.doFilter(request, response);
} finally {
    SecurityContextHolder.clearContext();   // ALWAYS, even if exception
}

6.3 Pitfall — ThreadLocal không propagate sang thread khác

ThreadLocal chỉ có hiệu lực trong thread đang chạy. Khi dùng @Async hoặc CompletableFuture, code chạy trên thread pool khác — SecurityContext biến mất:

// SAI — context null trong async thread
@Async
public void processOrder() {
    // SecurityContextHolder.getContext().getAuthentication() == null ← NPE
}

// DUNG — capture truoc khi switch thread
@PostMapping("/orders")
public CompletableFuture<Void> placeOrder(
    @AuthenticationPrincipal UserDetails user,
    @RequestBody OrderRequest req
) {
    String username = user.getUsername();   // capture tren request thread
    return CompletableFuture.runAsync(() -> orderService.process(username, req));
}

Bài SecurityFilterChain DSL sẽ demo cách cấu hình DelegatingSecurityContextExecutor cho use case async cần truyền context.

7. Authorization — sau khi có Authentication

AuthorizationFilter là filter cuối cùng trong chain. Sau khi Authentication đã được lưu vào SecurityContextHolder, filter này đọc ra và kiểm tra:

// Simplified logic in AuthorizationFilter
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
AuthorizationDecision decision = authorizationManager.check(() -> authentication, request);

if (!decision.isGranted()) {
    throw new AccessDeniedException("Access Denied");
}
// else: call chain.doFilter() — pass through

AuthorizationManager được config trong SecurityFilterChain:

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/projects/**").authenticated()
    .anyRequest().permitAll()
);

Tại sao tách authentication và authorization thành hai bước riêng?

Nếu gộp lại, một component vừa hỏi "bạn là ai?" vừa hỏi "bạn được làm gì?" — hai concern khác nhau, khó mở rộng độc lập. Tách rời cho phép:

  • Thay đổi cơ chế xác thực (từ password sang JWT, sang SAML) mà không động vào authorization rule.
  • Thêm authorization rule mới (từ RBAC sang ABAC) mà không cần biết token được validate ra sao.
  • Hai filter chạy ở hai vị trí khác nhau trong chain — filter trước cần reject sớm nếu token không hợp lệ, không cần chờ đến authorization.

8. Exception flow — AuthenticationEntryPoint vs AccessDeniedHandler

ExceptionTranslationFilter nằm ngay trước AuthorizationFilter trong chain. Nhiệm vụ của nó là bắt hai loại exception và map sang HTTP response tương ứng — đúng hai lối thoát đã vẽ ở sơ đồ mục 2. Vị trí trên đường đi quyết định mã trả về: rơi ra trước khi danh tính kịp vào SecurityContextHolder thì là 401, rơi ra sau đó thì là 403.

Hai handler chịu trách nhiệm sinh HTTP response:

ExceptionHandlerHTTP StatusKhi nào
AuthenticationExceptionAuthenticationEntryPoint401 UnauthorizedToken không hợp lệ, hết hạn, thiếu
AccessDeniedExceptionAccessDeniedHandler403 ForbiddenToken hợp lệ nhưng không có quyền

Với REST API dùng JWT, cần config handler trả JSON thay vì redirect về form login:

http.exceptionHandling(ex -> ex
    .authenticationEntryPoint((request, response, authException) -> {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\":\"Unauthorized\"}");
    })
    .accessDeniedHandler((request, response, accessDeniedException) -> {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\":\"Forbidden\"}");
    })
);

Nếu không config, Spring Security mặc định redirect đến /login khi gặp 401 — đúng cho web app form login, nhưng sai cho REST API (frontend nhận 302, không phải 401).

8.1 Anonymous user

Khi request không có token, AnonymousAuthenticationFilter tự động đặt vào SecurityContextHolder một AnonymousAuthenticationToken với authority ROLE_ANONYMOUS. Điều này giúp AuthorizationFilter xử lý nhất quán — luôn có Authentication trong context, không bao giờ null. Khi anonymous user truy cập endpoint protected, AuthorizationFilter ném AccessDeniedException; ExceptionTranslationFilter phát hiện đây là anonymous nên gọi AuthenticationEntryPoint (401) thay vì AccessDeniedHandler (403).

9. Cơ chế bên dưới — luồng đầy đủ một request JWT

Gộp tất cả lại, đây là trình tự chính xác từ byte đầu của request cho đến controller:

1. Tomcat nhận HTTP request, dispatch sang thread từ pool
2. DelegatingFilterProxy.doFilter() → lazy-fetch FilterChainProxy bean
3. FilterChainProxy match URL → chọn SecurityFilterChain
4. SecurityContextHolderFilter: load SecurityContext từ session hoặc tạo mới → lưu ThreadLocal
5. CsrfFilter: check CSRF token (nếu enabled)
6. BearerTokenAuthenticationFilter:
   a. Đọc header Authorization: Bearer <token>
   b. Tạo BearerTokenAuthenticationToken(token) — isAuthenticated() = false
   c. Gọi AuthenticationManager.authenticate(token)
   d. ProviderManager iterate providers:
      - JwtAuthenticationProvider.supports(BearerTokenAuthenticationToken) = true
      - JwtAuthenticationProvider.authenticate(): decode JWT, verify signature, check expiry
      - Trả về JwtAuthenticationToken — isAuthenticated() = true, authorities = [ROLE_USER]
   e. SecurityContextHolder.setContext(context với JwtAuthenticationToken)
   f. chain.doFilter() — tiếp tục
7. AnonymousAuthenticationFilter: context đã có auth → skip
8. ExceptionTranslationFilter: wrap chain trong try/catch
9. AuthorizationFilter:
   a. Đọc Authentication từ SecurityContextHolder
   b. AuthorizationManager.check(): Alice có ROLE_USER, endpoint yêu cầu authenticated() → granted
   c. chain.doFilter() — tiếp tục
10. DispatcherServlet → Controller nhận request
11. Controller có thể đọc auth qua @AuthenticationPrincipal hoặc SecurityContextHolder
12. Response trả về, SecurityContextHolder.clearContext() trong finally

10. Pitfall

Nhầm 1 — Không config AuthenticationEntryPoint cho REST API:

// SAI — default Spring Security redirect 302 /login khi token expired
// Frontend nhận 302 Not Modified thay vi 401 Unauthorized

// DUNG — override handler tra JSON
http.exceptionHandling(ex -> ex
    .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
    .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
);
// BearerTokenAuthenticationEntryPoint tra 401 + WWW-Authenticate header
// BearerTokenAccessDeniedHandler tra 403 + WWW-Authenticate header

Nhầm 2 — Tưởng SecurityContextHolder.getContext() trả null khi không có user:

// SAI assumption
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) { ... }   // dung — auth co the null truoc khi set

// DUNG — kiem tra ca null va isAuthenticated
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()
    && !(auth instanceof AnonymousAuthenticationToken)) {
    // da xac thuc that su
}

Nhầm 3 — Gọi SecurityContextHolder trong constructor của Spring bean:

Bean được khởi tạo lúc application startup — trước khi bất kỳ request nào đến. SecurityContextHolder chưa có context lúc đó. Đọc SecurityContext trong constructor luôn trả null.

Liên hệ các bài khác

  • 01 — Filter chain architecture: bài này đã mô tả cấu trúc ba tầng DelegatingFilterProxyFilterChainProxySecurityFilterChain và danh sách 15+ filter trong chain — nền để hiểu tại sao BearerTokenAuthenticationFilterExceptionTranslationFilter nằm ở vị trí đó.
  • UserDetailsService & BCrypt: DaoAuthenticationProvider (form login) dùng UserDetailsService để tra cứu user từ DB và PasswordEncoder để verify password hash — phần bổ sung cho authentication flow bài này khi dùng username/password thay JWT.
  • 03 — SecurityFilterChain DSL: DSL http.authorizeHttpRequests(), http.oauth2ResourceServer(), và http.exceptionHandling() là cách khai báo config cho filter chain và exception handler đã đề cập ở đây.

Tóm tắt

  • BearerTokenAuthenticationFilter đọc header, xây Authentication chưa xác thực (input), giao cho AuthenticationManager.
  • ProviderManager điều phối danh sách AuthenticationProvider — mỗi provider handle một loại token qua supports().
  • Provider trả về Authentication đã xác thực (output): isAuthenticated() = true, credentials = null, authorities đầy đủ.
  • SecurityContextHolder lưu Authentication vào ThreadLocal per-thread — mỗi request có context riêng, không lẫn nhau; clearContext() trong finally là bắt buộc.
  • ThreadLocal được chọn vì Servlet API không cho phép truyền Authentication qua parameter; thread-per-request của Tomcat đảm bảo isolation.
  • Authentication và authorization tách rời: filter authenticate trước, AuthorizationFilter check authority sau — hai concern độc lập, dễ mở rộng riêng.
  • ExceptionTranslationFilter map: AuthenticationException → 401 (AuthenticationEntryPoint), AccessDeniedException → 403 (AccessDeniedHandler). REST API cần override cả hai để trả JSON thay redirect.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao ProviderManager giữ danh sách AuthenticationProvider thay vì một provider duy nhất? Lợi ích thiết kế là gì?
  2. Q2
    Giải thích tại sao Spring Security dùng ThreadLocal để lưu SecurityContext thay vì truyền Authentication qua method parameter từ filter xuống controller.
  3. Q3
    Phân biệt AuthenticationExceptionAccessDeniedException. Mỗi loại exception được xử lý bởi component nào và sinh ra HTTP status code gì?
  4. Q4
    Tại sao authentication và authorization được thiết kế thành hai bước riêng biệt trong Spring Security chain, thay vì gộp vào một component?
  5. Q5
    Đoạn code sau có lỗi bảo mật tiềm ẩn liên quan đến ThreadLocal. Giải thích lỗi và cách sửa.

    SecurityContextHolder.setContext(context);
    chain.doFilter(request, response);
    // no clearContext()

Bài tiếp theo: SecurityFilterChain DSL

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

SecurityFilterChain DSL — khai báo security bằng lambda Spring Security 6