JWT Authentication trong Spring Boot – Bảo Mật Dựa Trên Token Từng Bước | Java Spring Roadmap

Chào mừng trở lại với series “Java Spring Roadmap”! Trên hành trình khám phá Spring Framework, chúng ta đã đi qua nhiều khái niệm cốt lõi như IoC và Dependency Injection, kiến trúc cốt lõi của Spring, làm chủ Annotations, và gần đây nhất là thế giới của bảo mật với Spring Security 101, cấu hình xác thực cơ bản, và phân quyền dựa trên vai trò (RBAC). Hôm nay, chúng ta sẽ lặn sâu vào một phương pháp xác thực hiện đại và phổ biến cho các ứng dụng stateless (không trạng thái), đặc biệt là API và microservices: JWT Authentication.

Nếu bạn đang xây dựng các hệ thống phân tán hoặc muốn ứng dụng web của mình có khả năng mở rộng cao mà không phụ thuộc vào trạng thái phiên (session) trên máy chủ, JWT là một lựa chọn tuyệt vời. Spring Boot, kết hợp với Spring Security, cung cấp một nền tảng mạnh mẽ để triển khai cơ chế này. Hãy cùng tìm hiểu cách thực hiện điều đó!

JWT là gì? (JSON Web Token)

Trước khi đi vào kỹ thuật triển khai, chúng ta cần hiểu JWT là gì và cấu trúc của nó. JWT, viết tắt của JSON Web Token, là một chuẩn mở (RFC 7519) định nghĩa một cách an toàn để truyền thông tin giữa các bên dưới dạng một đối tượng JSON nhỏ gọn và tự mô tả. Thông tin này có thể được xác minh và tin cậy bởi vì nó được ký số.

Một JWT về cơ bản là một chuỗi ký tự dài, thường được truyền qua header HTTP Authorization dưới dạng Bearer <token>. Chuỗi này bao gồm ba phần được phân tách bằng dấu chấm (.):

  1. Header: Chứa metadata về loại token (thường là JWT) và thuật toán mã hóa được sử dụng (ví dụ: HMAC SHA256 hoặc RSA).
  2. Payload: Chứa các “claims” (các thông tin, tuyên bố) về thực thể (thường là người dùng) và metadata bổ sung. Các claims có thể là:
    • Registered claims: Các claims được định nghĩa trước (nhưng không bắt buộc), như iss (issuer – tổ chức phát hành), exp (expiration time – thời gian hết hạn), sub (subject – chủ thể), aud (audience – đối tượng).
    • Public claims: Các claims được định nghĩa bởi những người sử dụng JWT, nhưng được đăng ký trong IANA JSON Web Token Registry hoặc được định nghĩa như URI.
    • Private claims: Các claims tùy chỉnh được tạo ra để chia sẻ thông tin giữa các bên thống nhất với nhau. Đây là nơi bạn thường đặt ID người dùng, vai trò (roles), hoặc các thông tin nhận dạng khác.
  3. Signature: Được tạo ra bằng cách lấy phần Header và Payload đã được mã hóa (thường là Base64Url), một secret key (hoặc khóa riêng) và thuật toán được chỉ định trong Header, sau đó áp dụng thuật toán mã hóa. Chữ ký này dùng để xác minh rằng token không bị thay đổi trên đường truyền và được phát hành bởi nguồn đáng tin cậy.

Ba phần này được Base64Url-encoded và nối với nhau bằng dấu chấm. Ví dụ, một JWT có thể trông giống như thế này:


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXX0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Phần đầu tiên là Header, phần thứ hai là Payload, và phần thứ ba là Signature.

Tại sao lại chọn JWT cho Xác thực?

JWT ngày càng phổ biến trong thế giới phát triển web hiện đại, đặc biệt là với sự phát triển của các ứng dụng Single Page Applications (SPA) và Microservices. Dưới đây là một số lý do chính:

  1. Stateless (Không trạng thái): Đây là lợi ích lớn nhất. Máy chủ không cần lưu trữ trạng thái phiên của người dùng sau khi token được phát hành. Mỗi yêu cầu từ client mang theo JWT, và máy chủ có thể xác minh token độc lập mà không cần truy vấn cơ sở dữ liệu hoặc bộ nhớ đệm phân tán để kiểm tra phiên. Điều này giúp cải thiện khả năng mở rộng (scalability) của ứng dụng.
  2. Hiệu quả: Thông tin xác thực được đóng gói ngay trong token. Khi token được xác minh, máy chủ có thể truy cập ngay các claims (như ID người dùng, vai trò) mà không cần thực hiện thêm các truy vấn tới hệ thống quản lý phiên.
  3. Cross-domain/Cross-service Compatibility: JWT có thể được sử dụng dễ dàng giữa các dịch vụ khác nhau, miễn là chúng chia sẻ cùng một secret key (hoặc có thể xác minh chữ ký bằng khóa công khai). Điều này rất hữu ích trong kiến trúc microservices.
  4. Bảo mật: Mặc dù Payload của JWT chỉ được mã hóa chứ không phải mã hóa theo nghĩa bảo mật (encrypted – chỉ là Base64Url encoding, dễ dàng giải mã), chữ ký (Signature) đảm bảo tính toàn vẹn và xác thực nguồn gốc của token. Thông tin nhạy cảm không nên đặt trong Payload, chỉ nên là các thông tin nhận dạng hoặc phân quyền cần thiết.

Để làm rõ hơn, hãy so sánh nhanh JWT với cơ chế Session-Based Authentication truyền thống:

Đặc điểm Session-Based Authentication JWT Authentication
Trạng thái (State) Stateful (Máy chủ lưu trữ trạng thái phiên) Stateless (Máy chủ không lưu trữ trạng thái phiên)
Scalability Cần cơ chế chia sẻ/đồng bộ phiên (sticky sessions, bộ nhớ đệm phân tán) Dễ dàng mở rộng theo chiều ngang (Horizontal scaling)
Cách thức hoạt động Sau khi đăng nhập, máy chủ tạo Session ID, gửi về client (qua cookie). Client gửi Session ID ở mỗi yêu cầu. Máy chủ tìm Session ID và tải dữ liệu phiên. Sau khi đăng nhập, máy chủ tạo JWT, gửi về client. Client gửi JWT ở mỗi yêu cầu (thường qua header Authorization). Máy chủ xác minh JWT độc lập.
Phân tán Khó khăn hơn giữa các dịch vụ/domain khác nhau Dễ dàng sử dụng giữa các dịch vụ/domain
Kích thước Cookie Session ID thường nhỏ Token có thể lớn hơn nếu chứa nhiều claims
Bảo mật (với cấu hình đúng) Dễ bị tấn công CSRF (nếu không cấu hình đúng), Session hijacking Dễ bị tấn công XSS (nếu lưu trữ token không an toàn ở client), cần chú ý bảo mật secret key, revocation

Tích hợp JWT với Spring Security: Quy trình chung

Việc tích hợp JWT vào ứng dụng Spring Boot sử dụng Spring Security tuân theo một quy trình khá chuẩn:

  1. Người dùng gửi yêu cầu đăng nhập (ví dụ: username và password) đến một endpoint công khai (public endpoint).
  2. Máy chủ sử dụng Spring Security để xác thực thông tin đăng nhập.
  3. Nếu xác thực thành công, máy chủ tạo một JWT chứa các thông tin cần thiết (ví dụ: ID người dùng, vai trò) và ký số bằng secret key.
  4. Máy chủ trả về JWT cho client trong phản hồi đăng nhập.
  5. Client (thường là trình duyệt hoặc ứng dụng di động) lưu trữ JWT (ví dụ: trong localStorage, sessionStorage, hoặc cookie an toàn).
  6. Đối với các yêu cầu tiếp theo đến các tài nguyên cần bảo vệ, client đính kèm JWT vào header Authorization dưới dạng Bearer <token>.
  7. Tại máy chủ, một bộ lọc (filter) tùy chỉnh của Spring Security sẽ chặn mọi yêu cầu đến các tài nguyên bảo vệ.
  8. Bộ lọc này trích xuất JWT từ header.
  9. Bộ lọc sử dụng secret key để xác minh chữ ký và kiểm tra tính hợp lệ của token (hết hạn chưa, định dạng đúng không).
  10. Nếu token hợp lệ, bộ lọc trích xuất thông tin người dùng từ Payload của token và tạo một đối tượng Authentication.
  11. Đối tượng Authentication này được đặt vào SecurityContextHolder, cho phép Spring Security và các logic ứng dụng tiếp theo nhận biết người dùng hiện tại và các quyền của họ.
  12. Yêu cầu được chuyển tiếp đến endpoint đích. Spring Security tiếp tục thực hiện các kiểm tra phân quyền (authorisation) dựa trên thông tin trong SecurityContextHolder (liên quan đến bài RBAC mà chúng ta đã học).
  13. Nếu phân quyền thành công, yêu cầu được xử lý bình thường. Nếu không, Spring Security trả về lỗi (thường là 403 Forbidden).

Thiết lập Project Spring Boot cho JWT

Để bắt đầu, bạn cần có một project Spring Boot. Bạn có thể tạo một project mới hoặc sử dụng lại project từ các bài trước trong series. Đảm bảo bạn đã thêm các dependencies cần thiết:

  • spring-boot-starter-security: Thư viện cốt lõi của Spring Security.
  • spring-boot-starter-web: Để xây dựng ứng dụng web/RESTful API.
  • Thư viện JWT: Các thư viện phổ biến là jjwt (Java JWT) hoặc auth0/java-jwt. Trong ví dụ này, chúng ta sẽ sử dụng jjwt.
  • Nếu bạn làm việc với cơ sở dữ liệu để tải thông tin người dùng, bạn sẽ cần thêm các dependency về dữ liệu (ví dụ: spring-boot-starter-data-jpa, driver cơ sở dữ liệu).

Ví dụ pom.xml (sử dụng jjwt phiên bản 0.11.5):


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT library -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

Triển khai JWT Authentication từng bước

Bây giờ, hãy đi vào chi tiết triển khai các thành phần cốt lõi.

Bước 1: Tạo Utility Class xử lý JWT

Class này sẽ chịu trách nhiệm tạo, phân tích (parsing) và xác thực JWT.


import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

    // Secret key - ideally loaded from environment variables or a secure location
    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

    @Value("${application.security.jwt.expiration}")
    private long jwtExpiration;

    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    public String generateToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails
    ) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    private String buildToken(
            Map<String, Object> extraClaims,
            UserDetails userDetails,
            long expiration
    ) {
        return Jwts
                .builder()
                .setClaims(extraClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        try {
            return Jwts
                    .parserBuilder()
                    .setSigningKey(getSignInKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException ex) {
             // Token expired
             throw ex; // Handle appropriately in filter
        } catch (MalformedJwtException | SignatureException | UnsupportedJwtException | IllegalArgumentException ex) {
             // Other invalid token issues
             throw ex; // Handle appropriately in filter
        }
    }

    private Key getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Bạn cần thêm secret key và thời gian hết hạn vào file application.properties hoặc application.yml:


application.security.jwt.secret-key=<YOUR_SUPER_SECRET_KEY>
application.security.jwt.expiration=86400000 # 24 hours in milliseconds

Lưu ý quan trọng: Secret key phải đủ mạnh và không được chia sẻ công khai. Đối với môi trường production, hãy lưu trữ nó một cách an toàn (ví dụ: sử dụng AWS Secrets Manager, HashiCorp Vault).

Bước 2: Cấu hình Custom UserDetailsService (Nếu cần)

Spring Security cần biết cách tải thông tin chi tiết của người dùng (username, password, roles, enabled status) dựa trên username. Bạn có thể đã làm điều này trong các bài trước (Hướng Dẫn Thực Hành Cấu Hình Xác Thực Trong Spring). Nếu chưa, bạn cần tạo một class implement interface UserDetailsService:


import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class MyUserDetailsService implements UserDetailsService {

    // Inject UserRepository or similar for database lookup
    // private final UserRepository userRepository;

    // @Autowired
    // public MyUserDetailsService(UserRepository userRepository) {
    //     this.userRepository = userRepository;
    // }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // In a real application, load user from database
        // User user = userRepository.findByUsername(username)
        //     .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
        // return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), user.getAuthorities());

        // --- Example with hardcoded user (for demo purposes) ---
        if ("testuser".equals(username)) {
            // Use BCryptPasswordEncoder for real applications!
            return org.springframework.security.core.userdetails.User
                    .withUsername("testuser")
                    .password("$2a$10$slYQmyGlfND PurcellxytOPjhG6tv/7wQvKGbYvPVJ3L6p/nLFF9T") // {noop}password in older versions, use BCrypt or similar!
                    .roles("USER")
                    .build();
        } else if ("admin".equals(username)) {
             return org.springframework.security.core.userdetails.User
                    .withUsername("admin")
                    .password("$2a$10$slYQmyGlfND PurcellxytOPjhG6tv/7wQvKGbYvPVJ3L6p/nLFF9T") // {noop}password
                    .roles("ADMIN", "USER")
                    .build();
        }
        // -------------------------------------------------------

        throw new UsernameNotFoundException("User not found with username: " + username);
    }
}

Bước 3: Tạo JWT Authentication Filter

Bộ lọc này sẽ chặn mọi yêu cầu HTTP, trích xuất JWT và xác thực nó trước khi yêu cầu được xử lý bởi các controller.


import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    @Autowired
    public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return; // Do not proceed if no Bearer token
        }

        jwt = authHeader.substring(7); // Extract token after "Bearer "
        try {
            username = jwtUtil.extractUsername(jwt);
        } catch (ExpiredJwtException ex) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
            response.getWriter().write("Token expired");
            return;
        } catch (Exception ex) { // Catch other JWT validation errors
             response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
             response.getWriter().write("Invalid Token");
             return;
        }


        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            // If username is found and no authentication context is set yet
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.isTokenValid(jwt, userDetails)) {
                // Create authentication object
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null, // Credentials are null in token-based auth after validation
                        userDetails.getAuthorities() // Roles/Authorities from UserDetails
                );
                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );

                // Set authentication object in SecurityContextHolder
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } else {
                // Token invalid even after username extraction (e.g., signature mismatch)
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
                response.getWriter().write("Token validation failed");
                return;
            }
        }

        filterChain.doFilter(request, response); // Continue the filter chain
    }
}

Bước 4: Cấu hình Spring Security

Bây giờ, chúng ta cần cập nhật lớp cấu hình bảo mật (thường là một lớp extend WebSecurityConfigurerAdapter hoặc sử dụng SecurityFilterChain Bean trong các phiên bản mới hơn của Spring Security 6+). Liên kết đến bài Cấu hình Xác thựcXác thực vs Phân quyền để xem lại các kiến thức cơ bản về cấu hình bảo mật.

Cấu hình chính bao gồm:

  • Vô hiệu hóa CSRF (vì chúng ta đang dùng token, không dựa vào cookie phiên).
  • Cấu hình quản lý phiên là stateless.
  • Xác định các endpoint nào được phép truy cập công khai (ví dụ: /api/auth/** cho đăng nhập/đăng ký) và endpoint nào yêu cầu xác thực.
  • Thêm bộ lọc JwtAuthenticationFilter vào chuỗi các bộ lọc của Spring Security, đặt nó trước UsernamePasswordAuthenticationFilter.
  • Cấu hình AuthenticationProvider (thường là DaoAuthenticationProvider sử dụng UserDetailsServicePasswordEncoder của bạn).

Ví dụ cấu hình sử dụng SecurityFilterChain Bean (Spring Security 6+):


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Autowired
    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter, AuthenticationProvider authenticationProvider) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationProvider = authenticationProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf()
                .disable() // Disable CSRF for stateless API
            .authorizeHttpRequests()
                .requestMatchers("/api/auth/**") // Allow public access to auth endpoints
                    .permitAll()
                .anyRequest()
                    .authenticated() // All other requests require authentication
                .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Use stateless sessions
                .and()
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // Add JWT filter before default

        return http.build();
    }

    // You also need to configure AuthenticationProvider and AuthenticationManager bean.
    // Typically done in a separate ApplicationConfig class or directly in this class.
    // Example:
    // @Bean
    // public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
    //     return config.getAuthenticationManager();
    // }
    //
    // @Bean
    // public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
    //    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    //    authProvider.setUserDetailsService(userDetailsService);
    //    authProvider.setPasswordEncoder(passwordEncoder);
    //    return authProvider;
    // }
    //
    // @Bean
    // public PasswordEncoder passwordEncoder() {
    //     return new BCryptPasswordEncoder(); // Or other strong password encoder
    // }
    //
    // @Bean
    // public UserDetailsService userDetailsService() {
    //     // Return your MyUserDetailsService implementation
    //     return new MyUserDetailsService(); // Assuming MyUserDetailsService is @Service
    // }
}

Các Bean AuthenticationProvider, AuthenticationManager, UserDetailsServicePasswordEncoder thường được cấu hình ở một nơi tập trung (ví dụ: một lớp ApplicationConfig) và được Spring tự động inject vào SecurityConfig này. Hãy tham khảo lại các bài trước để xem cách cấu hình các Bean này.

Bước 5: Tạo Authentication Endpoint

Tạo một REST Controller để xử lý yêu cầu đăng nhập. Endpoint này sẽ sử dụng AuthenticationManager để xác thực credentials và trả về JWT nếu thành công.


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;

// Assuming you have a DTO for login request
class AuthenticationRequest {
    private String username;
    private String password;

    // getters and setters
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

// Assuming you have a DTO for authentication response
class AuthenticationResponse {
    private String token;

    public AuthenticationResponse(String token) { this.token = token; }

    // getter
    public String getToken() { return token; }
}


@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {

    private final AuthenticationManager authenticationManager;
    private final UserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;

    @Autowired
    public AuthenticationController(AuthenticationManager authenticationManager,
                                    UserDetailsService userDetailsService,
                                    JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.userDetailsService = userDetailsService;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> authenticate(
            @RequestBody AuthenticationRequest request
    ) {
        authenticationManager.authenticate( // This uses your configured AuthenticationProvider
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );
        // If authenticate() does not throw exception, authentication is successful

        final UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());

        if (userDetails != null) {
            String jwt = jwtUtil.generateToken(userDetails);
            return ResponseEntity.ok(new AuthenticationResponse(jwt));
        }
        // Should ideally not happen if authenticationManager succeeded, but good practice
        return ResponseEntity.status(400).body(null); // Or throw a specific exception
    }

    // You might also add registration endpoint here
}

Bước 6: Tạo Endpoint Được Bảo Vệ

Cuối cùng, tạo một controller hoặc endpoint bất kỳ yêu cầu xác thực để kiểm tra luồng JWT.


import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/hello")
public class HelloController {

    // This endpoint requires authentication
    @GetMapping
    public ResponseEntity<String> sayHelloAuthenticated() {
        return ResponseEntity.ok("Hello from authenticated endpoint!");
    }

    // This endpoint requires ADMIN role (linking to RBAC)
    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<String> sayHelloAdmin() {
        return ResponseEntity.ok("Hello from admin endpoint!");
    }
}

Để sử dụng @PreAuthorize, bạn cần bật Global Method Security trong cấu hình bảo mật của mình (ví dụ: thêm @EnableMethodSecurity vào lớp SecurityConfig).

Các cân nhắc về bảo mật với JWT

Mặc dù JWT mang lại nhiều lợi ích, bạn cần lưu ý một số điểm về bảo mật:

  • Lộ Secret Key: Nếu secret key bị lộ, kẻ tấn công có thể tạo ra các token giả mạo hợp lệ. Bảo vệ secret key là tối quan trọng.
  • Hết hạn (Expiration): Luôn đặt thời gian hết hạn cho token. Token hết hạn giảm thiểu rủi ro nếu bị đánh cắp.
  • Thu hồi (Revocation): JWT được thiết kế để stateless, nhưng điều này cũng có nghĩa là một token hợp lệ sẽ tiếp tục hoạt động cho đến khi hết hạn, ngay cả khi người dùng đã đăng xuất hoặc bị vô hiệu hóa tài khoản. Để xử lý việc thu hồi token tức thì (ví dụ: khi thay đổi mật khẩu, đăng xuất từ thiết bị khác, tài khoản bị khóa), bạn cần triển khai một cơ chế riêng, chẳng hạn như duy trì danh sách đen (blacklist) các JWT đã thu hồi (thường lưu trữ trong bộ nhớ cache như Redis).
  • Lưu trữ ở Client: Việc lưu trữ JWT ở client (trình duyệt) cần được thực hiện cẩn thận để tránh các cuộc tấn công XSS (Cross-Site Scripting) hoặc CSRF (mặc dù JWT tự thân chống CSRF tốt hơn Session ID). Lưu trữ trong localStorage/sessionStorage dễ bị XSS. Lưu trữ trong cookie với cờ HttpOnlySecure sẽ an toàn hơn chống XSS, nhưng lại có thể dễ bị CSRF nếu không có các biện pháp phòng chống khác (ví dụ: kiểm tra header Referer hoặc sử dụng double submit cookie technique). Lựa chọn phụ thuộc vào trường hợp sử dụng và mô hình đe dọa.
  • Luôn sử dụng HTTPS: Để ngăn chặn việc token bị chặn và đọc trên đường truyền, luôn sử dụng HTTPS cho tất cả các kết nối.

Kết luận

JWT Authentication là một phương pháp xác thực mạnh mẽ và linh hoạt, đặc biệt phù hợp với kiến trúc ứng dụng hiện đại như RESTful API và microservices nhờ tính stateless của nó. Bằng cách tích hợp thư viện JWT (như jjwt) với Spring Security, bạn có thể xây dựng hệ thống bảo mật dựa trên token một cách hiệu quả trong Spring Boot.

Chúng ta đã đi qua các bước cơ bản: hiểu JWT là gì, tại sao sử dụng nó, cấu hình dependencies, tạo utility class, filter xác thực, cấu hình Spring Security và xây dựng các endpoint đăng nhập/được bảo vệ. Mặc dù bài viết này cung cấp nền tảng, việc triển khai trong thực tế có thể phức tạp hơn, bao gồm xử lý refresh token, quản lý danh sách đen, và các biện pháp bảo mật nâng cao.

Hy vọng bài viết này đã cung cấp cho bạn cái nhìn rõ ràng và các bước cần thiết để bắt đầu triển khai JWT Authentication trong dự án Spring Boot của mình. Hãy thử nghiệm, và đừng ngần ngại tham khảo thêm tài liệu chính thức của Spring Security và thư viện JWT bạn sử dụng để tìm hiểu sâu hơn.

Hẹn gặp lại trong các bài viết tiếp theo của series “Java Spring Roadmap”!

Chỉ mục