Backend

99 Ngày Spring — Ngày 45: JWT — filter xác thực

SSite Admin
10 tháng 09, 2026 7 phút đọc 1 lượt xem
99 Ngày Spring — Ngày 45: JWT — filter xác thực

Ngày 44 ta phát token; hôm nay là nửa còn lại và cũng là bài khép lại giai đoạn Security: ai kiểm token ở mỗi request. Câu trả lời quay về đúng mô hình của Ngày 41 — một filter đứng trước controller. Ba thứ quyết định bài này chạy đúng hay không: vì sao phải là OncePerRequestFilter, vì sao không có token thì đi tiếp chứ không ném 401, và vì sao thiếu một dòng STATELESS là mất sạch ý nghĩa của JWT.

Sketchnote Ngày 45: Filter xác thực JWT — OncePerRequestFilter đọc header Bearer và đặt SecurityContext, lắp filter trước UsernamePasswordAuthenticationFilter, cấu hình STATELESS và tắt CSRF hợp lệ, refresh token lưu DB để thu hồi được

Filter đọc token và đặt SecurityContext

Nhiệm vụ của filter này rất hẹp và cần hiểu đúng: nó không quyết định ai được vào. Nó chỉ đọc token, xác minh chữ ký, rồi nói cho Spring biết người gọi là ai bằng cách đặt Authentication vào SecurityContext. Việc chặn là của filter phân quyền phía sau.

// Ngày 44 phát token. Hôm nay là nửa còn lại: MỖI request tới, ai kiểm token?
// Câu trả lời quay lại đúng mô hình Ngày 41 — một FILTER, đứng trước controller.

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(req, res);           // ❗ KHÔNG có token → ĐI TIẾP,
            return;                             //   đừng tự ném lỗi ở đây
        }
        try {
            String token = header.substring(7);
            String email = jwtService.laySubject(token);       // ném nếu chữ ký sai/hết hạn

            if (SecurityContextHolder.getContext().getAuthentication() == null) {
                var nd = userDetailsService.loadUserByUsername(email);
                var auth = new UsernamePasswordAuthenticationToken(
                        nd, null, nd.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
                SecurityContextHolder.getContext().setAuthentication(auth);   // ⭐ điểm mấu chốt
            }
        } catch (JwtException e) {
            SecurityContextHolder.clearContext();       // token hỏng → coi như ẩn danh
        }
        chain.doFilter(req, res);
    }
}

// ❗ VÌ SAO "OncePerRequest"? Vì một request có thể đi qua chuỗi filter nhiều lần
// (forward, error dispatch). Kế thừa class này đảm bảo logic chỉ chạy ĐÚNG MỘT LẦN.

// ❗ VÌ SAO KHÔNG có token thì đi tiếp thay vì ném 401?
// Vì filter này KHÔNG phải nơi quyết định ai được vào — nó chỉ NÓI CHO Spring biết
// người gọi là ai. Việc chặn là của AuthorizationFilter phía sau (Ngày 41).
// Endpoint permitAll vẫn phải chạy được cho khách ẩn danh.
  • OncePerRequestFilter đảm bảo logic chạy đúng một lần — một request có thể đi qua chuỗi filter nhiều lần khi có forward hoặc error dispatch.

  • Không có token thì gọi tiếp chain, đừng ném lỗi: endpoint permitAll vẫn phải phục vụ được khách ẩn danh.

  • Token hỏng thì xóa context và đi tiếp — request sẽ bị chặn ở tầng phân quyền với đúng mã lỗi, thay vì nổ 500 ở đây.

Lắp vào chuỗi và cấu hình stateless

Filter phải đặt trước UsernamePasswordAuthenticationFilter, và có một dòng cấu hình mà thiếu nó thì cả kiến trúc mất ý nghĩa: SessionCreationPolicy.STATELESS.

// Lắp filter vào chuỗi — phải đặt TRƯỚC UsernamePasswordAuthenticationFilter:
@Bean
SecurityFilterChain chain(HttpSecurity http, JwtAuthFilter jwtFilter) throws Exception {
    return http
        .csrf(c -> c.disable())                          // ✅ hợp lệ: đã stateless
        .sessionManagement(s -> s
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))   // ❗ không tạo session
        .authorizeHttpRequests(a -> a
            .requestMatchers("/api/dang-nhap", "/api/dang-ky").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling(e -> e
            .authenticationEntryPoint((req, res, ex) ->      // chưa xác thực → 401
                res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
            .accessDeniedHandler((req, res, ex) ->           // thiếu quyền → 403
                res.sendError(HttpServletResponse.SC_FORBIDDEN)))
        .build();
}

// ❗ STATELESS là điểm khiến JWT thực sự "không trạng thái":
// Spring sẽ KHÔNG tạo HttpSession, không gửi cookie JSESSIONID.
// Quên dòng này thì mỗi request vẫn sinh một session — và bạn có một hệ thống
// mang tiếng stateless mà vẫn ăn RAM y như session, tệ nhất của cả hai thế giới.

// ❗ Tắt CSRF ở đây là HỢP LỆ (khác hẳn Ngày 42): không có cookie phiên thì
// trình duyệt không tự gửi gì đi, nên không có bề mặt tấn công CSRF.
// Nhưng nếu bạn lưu token trong cookie thì CSRF QUAY LẠI — đừng tắt.
  • Quên STATELESS thì Spring vẫn tạo session cho mỗi request — bạn có một hệ thống mang tiếng stateless mà vẫn ăn RAM như session.

  • Tắt CSRF ở đây là hợp lệ, khác hẳn Ngày 42: không có cookie phiên thì trình duyệt không tự gửi gì, nên không có bề mặt tấn công.

  • Nhưng nếu bạn lưu token trong cookie thì CSRF quay lại — lúc đó tuyệt đối không được tắt.

  • Khai AuthenticationEntryPointAccessDeniedHandler để trả đúng 401403 — nhớ rằng @ExceptionHandler không với tới đây (Ngày 41).

Refresh token và bốn sai lầm thường gặp

Ngày 44 để lại một vấn đề chưa giải: token không thu hồi được. Lời giải thực dụng là refresh token lưu trong database — và điều thú vị là nó không nên là một JWT, vì mục đích của nó chính là thứ mà JWT không làm được.

// REFRESH TOKEN — lời giải thực dụng cho vấn đề "không thu hồi được" của Ngày 44.
@Entity
class RefreshToken {
    @Id String token;              // chuỗi ngẫu nhiên, KHÔNG phải JWT
    Long nguoiDungId;
    Instant hetHan;                // vd 7 ngày
    boolean daThuHoi;              // ⭐ cột này chính là khả năng thu hồi
}

// Luồng đầy đủ:
//   POST /api/dang-nhap  → access token (15 phút) + refresh token (7 ngày, lưu DB)
//   API thường            → gửi access token trong header Authorization
//   Access hết hạn        → POST /api/lam-moi với refresh token → cấp access mới
//   Đăng xuất             → đánh dấu daThuHoi = true → không làm mới được nữa

// Vì sao refresh token KHÔNG nên là JWT? Vì mục đích của nó là ĐỂ THU HỒI ĐƯỢC,
// mà JWT thì đặc tính là không thu hồi được. Một chuỗi ngẫu nhiên trong database
// đơn giản hơn và làm đúng việc cần làm.

// ROTATION — nâng cao thêm một bậc: mỗi lần làm mới thì HỦY refresh token cũ
// và cấp cái mới. Nếu một refresh token đã dùng lại xuất hiện lần nữa, gần như
// chắc chắn nó bị đánh cắp → thu hồi TOÀN BỘ token của người dùng đó.

// SAI LẦM THƯỜNG GẶP, gom lại:
// 1. Quên STATELESS → vẫn sinh session, mất luôn ý nghĩa của JWT
// 2. Access token hạn quá dài (7 ngày) → bị lộ là kẻ tấn công dùng cả tuần
// 3. Trả 500 thay vì 401 khi token hỏng → lộ chi tiết nội bộ ra ngoài
// 4. Không kiểm tra thuật toán khi verify → dính tấn công "alg: none" kinh điển
//    (thư viện hiện đại như jjwt đã chặn sẵn, nhưng đừng tự viết bộ verify)
  • Cột daThuHoi chính là khả năng thu hồi mà JWT không có — đó là lý do refresh token phải nằm trong database.

  • Rotation nâng an toàn thêm một bậc: refresh token đã dùng mà xuất hiện lại thì gần như chắc chắn bị đánh cắp.

  • Access token hạn ngắn (15 phút) là tuyến phòng thủ chính — bị lộ thì kẻ tấn công chỉ dùng được một lúc, không phải cả tuần.

  • Đừng tự viết bộ verify token: tấn công "alg: none" kinh điển đã hạ rất nhiều hệ thống tự chế.

Bài tập nhỏ

  • Gọi một endpoint permitAll mà không gửi token — xác nhận nó vẫn chạy, rồi thử ném 401 trong filter để thấy nó hỏng thế nào.

  • Bỏ dòng STATELESS rồi kiểm tra phản hồi: sẽ thấy cookie JSESSIONID xuất hiện.

  • Gửi token đã hết hạn và xác nhận nhận về 401 chứ không phải 500.

  • Gửi token của tài khoản thường tới endpoint /api/admin/** để thấy 403 — phân biệt rõ với 401.

  • Cài RefreshToken với cột thu hồi, làm chức năng đăng xuất, và xác nhận sau đó không làm mới token được nữa.

Kết lại

Bốn ý gói lại hôm nay: filter JWT dùng OncePerRequestFilter và nhiệm vụ của nó chỉ là nói cho Spring biết người gọi là ai, chứ không phải chặn — nên không có token thì đi tiếp; phải đặt trước UsernamePasswordAuthenticationFilter và bật STATELESS, thiếu dòng đó là mất sạch ý nghĩa của JWT; tắt CSRF chỉ hợp lệ khi thật sự không có cookie phiên; và refresh token lưu trong database là cách lấy lại khả năng thu hồi mà JWT không có. Vậy là khép lại năm ngày Spring Security. Ngày 46 ta đi sâu vào chính thứ vừa nhắc: refresh token và đăng xuất — rotation để phát hiện token bị trộm, và sự thật về việc "đăng xuất" của JWT. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 45: File I/O với java.io

Hai họ stream cho byte và reader cho ký tự, buffered biến một triệu syscall thành hơn trăm, bẫy charset mặc định làm vỡ tiếng Việt — và đọc file lớn bằng Files.lines thay vì nạp hết vào RAM.

10 thg 9, 20267 phút1
99 Ngày Spring — Ngày 44: JWT — phát hành token

Cấu trúc ba phần của JWT và vì sao payload không hề được mã hóa, ký token bằng khóa bí mật đủ dài, endpoint đăng nhập dùng lại AuthenticationManager — cùng đánh đổi lớn nhất: không thu hồi được.

9 thg 9, 20267 phút9
99 Ngày Java — Ngày 44: try-with-resources

Vì sao finally viết tay vừa dài vừa sai, cú pháp nhiều tài nguyên đóng theo thứ tự ngược, suppressed exception giữ lại lỗi gốc — và bẫy quên đóng stream của Files.walk.

9 thg 9, 20267 phút7