Bảo vệ REST API bằng Spring Security và JWT

1. Tổng quan

REST API thường không dùng session. Mỗi request phải tự mang theo thông tin xác thực, và JWT (JSON Web Token) là cách phổ biến nhất để làm việc này.

Bài viết hướng dẫn bạn bảo vệ một API quản lý đơn hàng bằng Spring Security 6 và Spring Boot 3. Chúng ta sẽ phát JWT khi người dùng đăng nhập, xác thực token ở mọi request và phân quyền theo role. Phần cuối là test tự động cho các hành vi chính.

Chúng ta không viết filter JWT thủ công. Thay vào đó, bài dùng OAuth2 Resource Server có sẵn trong Spring Security. Cách này ít code hơn và an toàn hơn.

2. Dependency Maven

Bạn cần các dependency sau trong pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Starter oauth2-resource-server kéo theo thư viện Nimbus JOSE + JWT. Thư viện này lo phần ký và kiểm tra chữ ký token. Bạn không cần thêm jjwt hay thư viện JWT nào khác.

3. Cấu hình khóa bí mật

Để đơn giản, chúng ta ký token bằng thuật toán đối xứng HS256. Cùng một khóa bí mật được dùng để ký và để kiểm tra chữ ký.

jwt.secret=${JWT_SECRET:doi-chuoi-nay-thanh-mot-khoa-bi-mat-dai-it-nhat-32-byte}
jwt.expiration-minutes=15

Biểu thức ${JWT_SECRET:...} đọc khóa từ biến môi trường JWT_SECRET. Nếu biến này không tồn tại, Spring dùng giá trị mặc định phía sau dấu hai chấm. Giá trị mặc định chỉ dành cho môi trường dev.

Lưu ý: HS256 yêu cầu khóa dài tối thiểu 256 bit, tức 32 byte. Nếu khóa ngắn hơn, Nimbus sẽ ném KeyLengthException ngay lần đầu ký token.

4. Cấu hình Spring Security

4.1. JwtEncoder, JwtDecoder và người dùng

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(uds);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }
}

Vai trò của từng bean:

  • SecretKey bọc chuỗi bí mật thành khóa HMAC-SHA256.
  • JwtEncoder dùng khóa này để ký token khi người dùng đăng nhập.
  • JwtDecoder kiểm tra chữ ký và thời hạn (exp) của token trong mỗi request. Resource Server tự động dùng bean này.
  • InMemoryUserDetailsManager tạo hai người dùng mẫu. Khi làm thật, bạn thay bằng một implementation đọc từ database.
  • AuthenticationManager kiểm tra username và password ở endpoint đăng nhập.

4.2. SecurityFilterChain

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    return http.build();
}

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

Bean này nằm trong cùng lớp SecurityConfig. Từng phần làm những việc sau:

  • csrf.disable(): API không dùng cookie để xác thực, nên không bị tấn công CSRF.
  • STATELESS: Spring không tạo HttpSession. Mỗi request được xác thực lại từ token.
  • authorizeHttpRequests: endpoint đăng nhập mở công khai. /api/admin/** chỉ dành cho role ADMIN. Mọi endpoint còn lại yêu cầu đăng nhập.
  • oauth2ResourceServer(...jwt...): bật filter đọc header Authorization: Bearer <token> và giải mã token bằng JwtDecoder.

Mặc định, Spring đọc quyền từ claim scope và thêm tiền tố SCOPE_. Chúng ta muốn dùng hasRole("ADMIN"), nên JwtAuthenticationConverter được cấu hình lại để đọc claim roles. Tiền tố được bỏ đi vì giá trị trong token đã có sẵn dạng ROLE_ADMIN.

5. Phát token khi đăng nhập

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

Token chứa các claim sau: người phát hành (iss), thời điểm phát (iat), thời điểm hết hạn (exp), username (sub) và danh sách role.

Dòng JwsHeader.with(MacAlgorithm.HS256) là bắt buộc. Nếu thiếu, NimbusJwtEncoder mặc định chọn RS256. Khi đó nó không tìm được khóa phù hợp và ném lỗi Failed to select a JWK signing key.

5.2. AuthController

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

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public record LoginRequest(String username, String password) {}
    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate() kiểm tra username và password qua DaoAuthenticationProvider. Nếu sai, phương thức ném BadCredentialsException. Spring Security dịch lỗi này thành HTTP 401. Nếu đúng, controller tạo token và trả về cho client.

Gọi thử bằng curl:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. Endpoint được bảo vệ

@RestController
@RequestMapping("/api")
public class OrderController {

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwt inject token đã được xác thực vào controller. Bạn lấy username bằng jwt.getSubject(). Để đọc claim khác, dùng jwt.getClaim("roles").

Thử gọi với ba trường hợp khác nhau:

# Không có token
curl -i localhost:8080/api/orders

# alice có token hợp lệ
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# alice gọi endpoint admin
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

Kết quả cho thấy hai mã lỗi khác nhau. 401 nghĩa là chưa xác thực: thiếu token, token sai chữ ký hoặc token đã hết hạn. 403 nghĩa là đã xác thực nhưng không đủ quyền.

7. Viết test

spring-security-test cung cấp post-processor jwt(). Nó giả lập một JWT đã xác thực mà không cần ký token thật. Chúng ta kết hợp nó với một test end-to-end có đăng nhập thật.

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"sai\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

Các static import cần dùng là MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt và Assertions.assertThat của AssertJ.

Test thứ hai kiểm tra toàn bộ luồng. Nó gọi endpoint đăng nhập, lấy token, rồi dùng token để gọi API. Test này phát hiện được lỗi cấu hình encoder và decoder, ví dụ quên JwsHeader hay khóa quá ngắn.

Test cuối dùng jwt() để kiểm tra riêng phần phân quyền. Lưu ý rằng .authorities(...) ghi đè converter, nên bạn phải truyền đúng tên authority ROLE_CUSTOMER.

8. Lỗi hay gặp và khi nào không nên dùng

Các lỗi thường gặp:

  • Khóa quá ngắn: khóa dưới 32 byte gây KeyLengthException với HS256.
  • Quên header HS256: JwtEncoder báo Failed to select a JWK signing key.
  • Sai tiền tố authority: để converter mặc định thì quyền có dạng SCOPE_.... Khi đó hasRole("ADMIN") luôn trả 403. Bạn hãy kiểm tra claim và tiền tố cho khớp nhau.
  • Hardcode secret trong repo: luôn đọc secret từ biến môi trường hoặc secret manager.
  • Đưa dữ liệu nhạy cảm vào payload: payload JWT chỉ được mã hóa Base64URL, không được mã hóa bí mật. Bất kỳ ai có token đều đọc được nội dung. Đừng đặt mật khẩu, số thẻ hay thông tin cá nhân vào token.

Lưu ý: JWT không thể thu hồi trước khi hết hạn nếu bạn không thêm cơ chế phụ như blacklist. Hãy giữ access token ngắn hạn (5–15 phút). Nếu cần phiên dài, dùng thêm refresh token lưu ở server để có thể thu hồi.

Khi nào không nên dùng cách này:

  • Ứng dụng web render phía server, như Thymeleaf: session kèm cookie đơn giản và an toàn hơn.
  • Cần đăng xuất hoặc khóa tài khoản có hiệu lực ngay lập tức: session hoặc opaque token kèm introspection phù hợp hơn.
  • Hệ thống nhiều service: chia sẻ một khóa HMAC cho mọi service rất rủi ro. Khi đó, bạn nên dùng authorization server riêng, như Spring Authorization Server hoặc Keycloak. Authorization server ký token bằng RSA hoặc EC, còn các service chỉ cần cấu hình spring.security.oauth2.resourceserver.jwt.issuer-uri để lấy public key qua JWKS.

9. Kết luận

Bài viết đã xây dựng một REST API stateless được bảo vệ bằng JWT, gồm các bước sau:

  • Dùng NimbusJwtEncoder và NimbusJwtDecoder với khóa HS256.
  • Phát token ở endpoint /api/auth/login thông qua AuthenticationManager.
  • Để OAuth2 Resource Server tự xác thực header Bearer.
  • Ánh xạ claim roles thành authority để dùng hasRole.
  • Kiểm tra các trường hợp 401, 403 và luồng thành công bằng MockMvc và AssertJ.

Từ nền tảng này, bạn có thể thay InMemoryUserDetailsManager bằng người dùng lưu trong database, thêm refresh token, hoặc chuyển sang khóa bất đối xứng khi hệ thống mở rộng thành nhiều service.

spring-bootspring-securityjwtrest-apijava