Spring Security 6: Xác Thực JWT Toàn Diện

Hướng dẫn thực tế triển khai xác thực JWT với Spring Security 6: cấu hình, sinh token, kiểm tra hợp lệ và các thực hành bảo mật tốt nhất.

Xác thực JWT với Spring Security 6

Xác thực bằng JWT (JSON Web Token) đã trở thành chuẩn để bảo vệ các REST API hiện đại. Spring Security 6 mang đến cách tiếp cận cấu hình theo phong cách hàm, vừa giúp triển khai đơn giản hơn vừa tăng cường bảo mật. Hướng dẫn này dẫn dắt toàn bộ quá trình, từ cấu hình ban đầu đến bảo vệ các endpoint.

Yêu cầu tiên quyết

Bài viết sử dụng Spring Boot 3.2+ và Spring Security 6.2+. Các khái niệm vẫn còn giá trị ở các phiên bản mới hơn với một số chỉnh sửa nhỏ về cú pháp.

Kiến trúc JWT trong Spring Security

Xác thực JWT dựa trên nguyên tắc stateless: máy chủ không lưu giữ phiên nào. Mỗi yêu cầu mang một token đã ký giúp chứng minh danh tính người dùng. Kiến trúc này cho phép mở rộng theo chiều ngang mà không cần chia sẻ phiên giữa các instance.

Luồng xác thực JWT chia thành nhiều bước. Người dùng đăng nhập bằng thông tin của mình, nhận một token JWT đã ký, sau đó đính kèm token này vào mọi yêu cầu tiếp theo. Máy chủ kiểm tra chữ ký và trích xuất thông tin người dùng từ token.

JwtAuthenticationFlow.javajava
// Conceptual representation of the authentication flow
public class JwtAuthenticationFlow {

    // 1. Initial authentication: POST /api/auth/login
    // → Verify credentials against database
    // → Generate signed JWT token
    // → Return token to client

    // 2. Authenticated requests: GET /api/protected
    // → Header: Authorization: Bearer <token>
    // → Extract and validate token
    // → Create SecurityContext
    // → Access protected resource
}

Cách tiếp cận này loại bỏ vấn đề sticky session và đơn giản hóa việc triển khai trong môi trường phân tán.

Cấu hình các phụ thuộc Maven

Dự án cần các phụ thuộc của Spring Security cùng một thư viện JWT. JJWT (Java JWT) cung cấp API mượt và được duy trì tốt để thao tác với token.

xml
<!-- pom.xml -->
<dependencies>
    <!-- Spring Security for authentication management -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- Spring Web for REST endpoints -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- JJWT: token generation and validation -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <!-- Spring Data JPA for user persistence -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
</dependencies>

Việc tách JJWT thành ba module (api, impl, jackson) tuân theo nguyên tắc đóng gói: chỉ API hiện diện ở giai đoạn biên dịch, còn phần triển khai vẫn là chi tiết runtime.

Dịch vụ sinh và kiểm tra JWT

Dịch vụ JWT tập trung mọi thao tác liên quan đến token: sinh token, trích xuất claim và kiểm tra hợp lệ. Một khóa bí mật đủ mạnh ký vào từng token và đảm bảo tính toàn vẹn của nó.

JwtService.javajava
@Service
public class JwtService {

    // Secret key injected from application.yml
    @Value("${app.jwt.secret}")
    private String secretKey;

    // Token validity duration (24 hours by default)
    @Value("${app.jwt.expiration:86400000}")
    private long jwtExpiration;

    // Generates a JWT token for an authenticated user
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    // Generates a token with custom claims
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
            .claims(extraClaims)                           // Additional claims (roles, permissions)
            .subject(userDetails.getUsername())            // Principal identifier
            .issuedAt(new Date())                          // Creation date
            .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
            .signWith(getSigningKey(), Jwts.SIG.HS256)     // HMAC-SHA256 signature
            .compact();
    }

    // Extracts the username (subject) from the token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Extracts a specific claim via an extraction function
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // Validates the token: correct signature and not expired
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    // Checks if the token has expired
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // Extracts the expiration date
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // Parses the token and extracts all claims
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())    // Verifies the signature
            .build()
            .parseSignedClaims(token)       // Parses the signed token
            .getPayload();                  // Returns the claims
    }

    // Generates the signing key from the Base64-encoded secret
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Khóa bí mật phải đủ dài (tối thiểu 256 bit cho HS256) và được lưu trữ an toàn, không bao giờ nằm trong mã nguồn.

Bảo mật khóa bí mật

Nên dùng biến môi trường hoặc một secrets manager để lưu khóa JWT. Khóa bị lộ sẽ cho phép giả mạo token hợp lệ cho bất kỳ người dùng nào.

Cấu hình thực thể User

Thực thể người dùng triển khai UserDetails của Spring Security, nhờ đó tích hợp trực tiếp với hệ thống xác thực.

User.javajava
@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String firstName;

    @Column(nullable = false)
    private String lastName;

    // Role stored as enum for type safety
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    // UserDetails implementation: returns user authorities
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    // Username corresponds to email in this implementation
    @Override
    public String getUsername() {
        return email;
    }

    // Account always active (adapt as needed)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    // Getters and setters omitted for brevity
}
Role.javajava
public enum Role {
    USER,      // Standard user
    ADMIN      // Administrator with extended privileges
}

Enum Role giới hạn tập giá trị có thể có và đơn giản hóa việc kiểm tra quyền trong các biểu thức SpEL.

Bộ lọc xác thực JWT

Bộ lọc JWT chặn từng yêu cầu để trích xuất và kiểm tra token. Nó được chèn vào chuỗi bộ lọc của Spring Security, đặt trước bộ lọc xác thực mặc định.

JwtAuthenticationFilter.javajava
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        // Retrieve the Authorization header
        final String authHeader = request.getHeader("Authorization");

        // Check for Bearer prefix
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract the token (without the "Bearer " prefix)
        final String jwt = authHeader.substring(7);

        try {
            // Extract username from token
            final String userEmail = jwtService.extractUsername(jwt);

            // Check that user is not already authenticated
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {

                // Load user details from database
                UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);

                // Validate token (signature + expiration + user match)
                if (jwtService.isTokenValid(jwt, userDetails)) {

                    // Create authentication object
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );

                    // Add request details
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );

                    // Set authentication in security context
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (ExpiredJwtException e) {
            // Token expired: user must re-authenticate
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Token expired");
            return;
        } catch (JwtException e) {
            // Invalid token: incorrect signature or malformed format
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
            return;
        }

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

OncePerRequestFilter đảm bảo bộ lọc chỉ chạy đúng một lần cho mỗi yêu cầu, kể cả khi có forward hay include.

Sẵn sàng chinh phục phỏng vấn Spring Boot?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Cấu hình Spring Security 6

Spring Security 6 sử dụng cách tiếp cận hàm với lambda để cấu hình chuỗi bảo mật. Cấu hình này định nghĩa các quy tắc truy cập và tích hợp bộ lọc JWT.

SecurityConfig.javajava
@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Enables @PreAuthorize and @PostAuthorize
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            // Disable CSRF since API is stateless (no session cookies)
            .csrf(csrf -> csrf.disable())

            // Configure authorization rules
            .authorizeHttpRequests(auth -> auth
                // Public endpoints: authentication and registration
                .requestMatchers("/api/auth/**").permitAll()
                // API documentation accessible without authentication
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                // Admin endpoints reserved for administrators
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                // All other requests require authentication
                .anyRequest().authenticated()
            )

            // Stateless mode: no server-side HTTP session
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // Custom authentication provider
            .authenticationProvider(authenticationProvider)

            // Insert JWT filter before standard authentication filter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

            .build();
    }
}
ApplicationConfig.javajava
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository userRepository;

    // User loading service for Spring Security
    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
    }

    // Authentication provider with password encoder
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    // Authentication manager exposed as bean
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
        throws Exception {
        return config.getAuthenticationManager();
    }

    // BCrypt encoder for secure password hashing
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Việc tắt CSRF là an toàn cho một API stateless vì xác thực dựa vào header tường minh chứ không phải cookie do trình duyệt tự động gửi.

Controller xác thực

Controller phơi bày các endpoint đăng ký và đăng nhập. Các endpoint này công khai và trả về token JWT sau khi xác thực thành công.

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

    private final AuthenticationService authService;

    // POST /api/auth/register - Register a new user
    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
        @Valid @RequestBody RegisterRequest request
    ) {
        return ResponseEntity.ok(authService.register(request));
    }

    // POST /api/auth/login - Login existing user
    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(
        @Valid @RequestBody AuthenticationRequest request
    ) {
        return ResponseEntity.ok(authService.authenticate(request));
    }

    // POST /api/auth/refresh - Token refresh (optional)
    @PostMapping("/refresh")
    public ResponseEntity<AuthenticationResponse> refresh(
        @RequestHeader("Authorization") String authHeader
    ) {
        return ResponseEntity.ok(authService.refreshToken(authHeader));
    }
}
AuthenticationRequest.javajava
public record AuthenticationRequest(
    @NotBlank(message = "Email required")
    @Email(message = "Invalid email format")
    String email,

    @NotBlank(message = "Password required")
    String password
) {}
RegisterRequest.javajava
public record RegisterRequest(
    @NotBlank(message = "First name required")
    String firstName,

    @NotBlank(message = "Last name required")
    String lastName,

    @NotBlank(message = "Email required")
    @Email(message = "Invalid email format")
    String email,

    @NotBlank(message = "Password required")
    @Size(min = 8, message = "Password must contain at least 8 characters")
    String password
) {}
AuthenticationResponse.javajava
public record AuthenticationResponse(
    String token,
    String type,
    long expiresIn
) {
    public AuthenticationResponse(String token, long expiresIn) {
        this(token, "Bearer", expiresIn);
    }
}

Các record của Java giúp khai báo DTO ngắn gọn mà vẫn đảm bảo tính bất biến.

Dịch vụ xác thực

Dịch vụ này điều phối logic đăng ký và xác thực, ủy quyền việc sinh token cho JwtService.

AuthenticationService.javajava
@Service
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;

    @Value("${app.jwt.expiration:86400000}")
    private long jwtExpiration;

    // Register a new user
    @Transactional
    public AuthenticationResponse register(RegisterRequest request) {
        // Check that email is not already in use
        if (userRepository.existsByEmail(request.email())) {
            throw new EmailAlreadyExistsException("Email already registered");
        }

        // Create user entity with hashed password
        User user = new User();
        user.setFirstName(request.firstName());
        user.setLastName(request.lastName());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));
        user.setRole(Role.USER);

        // Persist the user
        userRepository.save(user);

        // Generate and return JWT token
        String jwtToken = jwtService.generateToken(user);
        return new AuthenticationResponse(jwtToken, jwtExpiration);
    }

    // Authenticate an existing user
    public AuthenticationResponse authenticate(AuthenticationRequest request) {
        // Delegate verification to AuthenticationManager
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.email(),
                request.password()
            )
        );

        // Load user (authentication succeeded)
        User user = userRepository.findByEmail(request.email())
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Generate token with additional claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", user.getRole().name());
        claims.put("userId", user.getId());

        String jwtToken = jwtService.generateToken(claims, user);
        return new AuthenticationResponse(jwtToken, jwtExpiration);
    }

    // Refresh token (extend session)
    public AuthenticationResponse refreshToken(String authHeader) {
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new InvalidTokenException("Invalid token");
        }

        String oldToken = authHeader.substring(7);
        String userEmail = jwtService.extractUsername(oldToken);

        User user = userRepository.findByEmail(userEmail)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Generate new token
        String newToken = jwtService.generateToken(user);
        return new AuthenticationResponse(newToken, jwtExpiration);
    }
}

AuthenticationManager tập trung việc kiểm tra thông tin đăng nhập và cho phép thay đổi chiến lược xác thực một cách dễ dàng.

Refresh Token

Để tăng cường bảo mật, nên triển khai cơ chế refresh token với thời gian sống dài hơn, được lưu trong cơ sở dữ liệu và có thể thu hồi.

Bảo vệ endpoint bằng annotation

Method-level security cho phép kiểm soát quyền chi tiết ngay trong mã nghiệp vụ.

UserController.javajava
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // Accessible to all authenticated users
    @GetMapping("/me")
    public ResponseEntity<UserDTO> getCurrentUser(
        @AuthenticationPrincipal User currentUser
    ) {
        return ResponseEntity.ok(UserDTO.from(currentUser));
    }

    // Only the concerned user or an admin can modify the profile
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    @PutMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(
        @PathVariable Long id,
        @Valid @RequestBody UpdateUserRequest request
    ) {
        return ResponseEntity.ok(userService.updateUser(id, request));
    }

    // Reserved for administrators
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping
    public ResponseEntity<List<UserDTO>> getAllUsers() {
        return ResponseEntity.ok(userService.findAll());
    }

    // Deletion with post-execution verification
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
AdminController.javajava
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")  // All methods require ADMIN
public class AdminController {

    private final AdminService adminService;

    @GetMapping("/dashboard")
    public ResponseEntity<DashboardDTO> getDashboard() {
        return ResponseEntity.ok(adminService.getDashboardStats());
    }

    @PostMapping("/users/{id}/role")
    public ResponseEntity<UserDTO> changeUserRole(
        @PathVariable Long id,
        @RequestParam Role newRole
    ) {
        return ResponseEntity.ok(adminService.changeUserRole(id, newRole));
    }
}

Annotation @AuthenticationPrincipal tiêm trực tiếp người dùng đã xác thực, tránh việc thao tác thủ công với SecurityContext.

Xử lý lỗi xác thực

Xử lý lỗi tập trung mang lại các phản hồi nhất quán và đầy đủ thông tin khi xác thực thất bại.

SecurityExceptionHandler.javajava
@RestControllerAdvice
public class SecurityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(SecurityExceptionHandler.class);

    // Authentication error (incorrect credentials)
    @ExceptionHandler(BadCredentialsException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleBadCredentials(BadCredentialsException ex) {
        return new ErrorResponse(
            "INVALID_CREDENTIALS",
            "Invalid email or password",
            null
        );
    }

    // Access denied (authenticated but not authorized)
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
        return new ErrorResponse(
            "ACCESS_DENIED",
            "Access to this resource is not authorized",
            null
        );
    }

    // Expired JWT token
    @ExceptionHandler(ExpiredJwtException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleExpiredToken(ExpiredJwtException ex) {
        return new ErrorResponse(
            "TOKEN_EXPIRED",
            "Session expired, please log in again",
            null
        );
    }

    // Invalid JWT token
    @ExceptionHandler(JwtException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleInvalidToken(JwtException ex) {
        log.warn("Invalid JWT token: {}", ex.getMessage());
        return new ErrorResponse(
            "INVALID_TOKEN",
            "Invalid authentication token",
            null
        );
    }

    // Email already in use during registration
    @ExceptionHandler(EmailAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleEmailExists(EmailAlreadyExistsException ex) {
        return new ErrorResponse(
            "EMAIL_EXISTS",
            ex.getMessage(),
            null
        );
    }
}
ErrorResponse.javajava
public record ErrorResponse(
    String code,
    String message,
    Map<String, String> details
) {}

Các mã lỗi được chuẩn hóa giúp xử lý phía client và việc gỡ lỗi trở nên dễ dàng hơn.

Cấu hình application.yml

Cấu hình tách rời cho phép điều chỉnh các tham số JWT theo từng môi trường.

yaml
# application.yml
app:
  jwt:
    # Base64 secret key (256 bits minimum for HS256)
    # Generate with: openssl rand -base64 32
    secret: ${JWT_SECRET:yourSuperSecretKeyOf256BitsMinimum}
    # Validity duration in milliseconds (24 hours)
    expiration: 86400000

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/springjwt
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
yaml
# application-dev.yml
app:
  jwt:
    # Short expiration for development (1 hour)
    expiration: 3600000

spring:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: update

logging:
  level:
    org.springframework.security: DEBUG

Ở môi trường production, khóa bí mật nên đến từ biến môi trường hoặc dịch vụ quản lý bí mật như Vault.

Kiểm thử tích hợp

Các bài kiểm thử xác minh hành vi tổng thể của hệ thống xác thực — từ đăng ký đến truy cập tài nguyên được bảo vệ.

AuthenticationIntegrationTest.javajava
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = Replace.ANY)
class AuthenticationIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void shouldRegisterAndAuthenticateUser() {
        // Registration
        RegisterRequest registerRequest = new RegisterRequest(
            "John", "Doe", "john@example.com", "password123"
        );

        ResponseEntity<AuthenticationResponse> registerResponse = restTemplate
            .postForEntity("/api/auth/register", registerRequest, AuthenticationResponse.class);

        assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(registerResponse.getBody().token()).isNotBlank();

        // Login with same credentials
        AuthenticationRequest loginRequest = new AuthenticationRequest(
            "john@example.com", "password123"
        );

        ResponseEntity<AuthenticationResponse> loginResponse = restTemplate
            .postForEntity("/api/auth/login", loginRequest, AuthenticationResponse.class);

        assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(loginResponse.getBody().token()).isNotBlank();
    }

    @Test
    void shouldRejectInvalidCredentials() {
        AuthenticationRequest request = new AuthenticationRequest(
            "unknown@example.com", "wrongpassword"
        );

        ResponseEntity<ErrorResponse> response = restTemplate
            .postForEntity("/api/auth/login", request, ErrorResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void shouldAccessProtectedResourceWithValidToken() {
        // Register to get a token
        RegisterRequest registerRequest = new RegisterRequest(
            "Jane", "Doe", "jane@example.com", "password123"
        );

        AuthenticationResponse authResponse = restTemplate
            .postForObject("/api/auth/register", registerRequest, AuthenticationResponse.class);

        // Access protected resource with token
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(authResponse.token());

        ResponseEntity<UserDTO> response = restTemplate.exchange(
            "/api/users/me",
            HttpMethod.GET,
            new HttpEntity<>(headers),
            UserDTO.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().email()).isEqualTo("jane@example.com");
    }

    @Test
    void shouldRejectAccessWithoutToken() {
        ResponseEntity<Void> response = restTemplate
            .getForEntity("/api/users/me", Void.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

Những bài kiểm thử này bao quát các kịch bản chính: đăng ký, đăng nhập, truy cập được phép và truy cập bị từ chối.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Kết luận

Việc triển khai xác thực JWT với Spring Security 6 tuân theo một mô hình rõ ràng. Dịch vụ JWT phụ trách sinh và kiểm tra token, bộ lọc chặn các yêu cầu để thiết lập ngữ cảnh bảo mật, còn cấu hình quy định các quy tắc truy cập.

Danh sách kiểm tra khi triển khai:

  • ✅ Khóa bí mật tối thiểu 256 bit lưu trong biến môi trường
  • ✅ HTTPS bắt buộc ở production để bảo vệ token khi truyền
  • ✅ Thời gian sống của token phù hợp ngữ cảnh (15 phút – 24 giờ)
  • ✅ Refresh token cho các phiên dài mà không cần xác thực lại
  • ✅ Xử lý lỗi chuẩn hóa với mã nghiệp vụ
  • ✅ Ghi log các lần xác thực thất bại
  • ✅ Kiểm thử tích hợp bao phủ các kịch bản xác thực
  • ✅ Rate limiting trên các endpoint xác thực

Kiến trúc này tạo nền tảng vững chắc để bảo vệ REST API và vẫn dễ mở rộng cho các nhu cầu phức tạp hơn như xác thực đa yếu tố hay tích hợp OAuth2.

Thẻ

#spring security
#jwt
#java
#spring boot
#authentication

Chia sẻ

Bài viết liên quan