VieTopik
Tiếng HànTiếng AnhIT
  • Góc học tập
Tải app
  • Thư viện
  • Luyện thi
  • Cẩm nang
  • Góc học tập
Spring Boot Web APIHoàn thiện API
Bài 18/19
6 phút

Xác thực bằng JWT

Nội dung bài · 6 mục
  1. 1.Khái niệm
  2. 2.Ví dụ
  3. 3.Cấp token khi đăng nhập
  4. 4.Thử ngay
  5. 5.Lỗi hay gặp
  6. 6.Tóm tắt

Ai cũng gọi được POST /api/products để thêm sản phẩm, kể cả người lạ. API cần biết người gọi là ai, và chỉ cho phép người có quyền làm việc quan trọng. Bài này dùng JWT để làm việc đó.

Khái niệm

🔑 Authentication (xác thực): xác định người gọi API là ai.

🚦 Authorization (phân quyền): quyết định người đó có được làm việc này không.

🎫 JWT (JSON Web Token): chuỗi chứa thông tin người dùng kèm chữ ký của server, client gửi theo mỗi request trong header Authorization: Bearer <token>.

Luồng làm việc:

POST /api/auth/login (tên, mật khẩu) token POST /api/products + Bearer token kiểm tra chữ ký của token 201 Created Client Server
Đăng nhập lấy token, rồi gửi token theo mỗi request

Ví dụ

Thêm dependency vào pom.xml, trong thẻ <dependencies>:

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

Không ghi <version>, vì Spring Boot đã chọn sẵn phiên bản khớp. Starter này kéo theo Spring Security, và mọi endpoint bị khoá cho tới khi có cấu hình dưới đây.

// SecurityConfig.java
package com.shop.api;

import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.*;
import org.springframework.context.annotation.*;
import org.springframework.http.*;
import org.springframework.security.config.*;
import org.springframework.security.config.annotation.web.builders.*;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.web.*;

@Configuration
class SecurityConfig {
    @Bean
    public SecretKey jwtKey(
            @Value("${jwt.key}") String secret) {
        byte[] bytes = secret.getBytes(
            StandardCharsets.UTF_8);
        return new SecretKeySpec(bytes, "HmacSHA256");
    }

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

    @Bean
    public JwtEncoder jwtEncoder(SecretKey key) {
        return NimbusJwtEncoder.withSecretKey(key)
            .build();
    }

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    HttpMethod.POST, "/api/products")
                .authenticated()
                .anyRequest().permitAll())
            .oauth2ResourceServer(oauth -> oauth
                .jwt(Customizer.withDefaults()));
        return http.build();
    }
}
  • Mỗi method @Bean tạo một bean, Spring đưa nó vào nơi cần qua tham số hay constructor.
  • jwt.key là khoá bí mật để ký token, đặt trong application.properties và dài ít nhất 32 ký tự. Ai có khoá này là tự tạo được token.
  • JwtDecoder kiểm tra chữ ký của token trong mỗi request. JwtEncoder ký token mới lúc đăng nhập. Cả hai dùng chung một khoá.
  • csrf -> csrf.disable() là lambda nhận cấu hình CSRF rồi tắt nó. CSRF chỉ cần cho web dùng cookie, để nguyên thì mọi POST không token đều bị 403.
  • authorizeHttpRequests liệt kê quy tắc: POST /api/products phải đăng nhập, mọi request khác ai cũng gọi được.
  • oauth2ResourceServer(... jwt ...) đọc token trong header Authorization để biết người gọi là ai.

Controller không cần gì thêm, vì quy tắc nằm ở SecurityConfig:

// ProductsController.java
package com.shop.api;

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/products")
class ProductsController {
    @GetMapping
    public String getAll() {
        return "Ai cũng xem được";
    }

    @PostMapping
    public ResponseEntity<Void> create() {
        return ResponseEntity.status(201).build();
    }
}
  • Không có token hợp lệ thì request bị chặn trước khi tới create, trả 401.

Cấp token khi đăng nhập

// AuthController.java
package com.shop.api;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
class AuthController {
    private final JwtEncoder encoder;

    public AuthController(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    @PostMapping("/login")
    public String login(
            @RequestParam String userName) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .subject(userName)
            .expiresAt(now.plus(1, ChronoUnit.HOURS))
            .build();
        var params = JwtEncoderParameters.from(claims);
        return encoder.encode(params).getTokenValue();
    }
}
  • Token chứa tên người dùng (claim sub) và hết hạn sau 1 giờ tính từ Instant.now(), thời điểm hiện tại.
  • Ví dụ bỏ qua bước kiểm tra mật khẩu để gọn. Dự án thật lưu mật khẩu đã băm và kiểm tra bằng PasswordEncoder của Spring Security.

Thử ngay

Giữ các file cũ, thay ProductsController.java và thêm các file khác theo phần Ví dụ. Thêm dòng này vào application.properties, khoá này chỉ để thử trên máy.

jwt.key=day-la-khoa-bi-mat-dai-hon-32-ky-tu-nhe

Chạy server rồi gọi:

curl -i -X POST http://localhost:5000/api/products
curl -i -X POST "http://localhost:5000/api/auth/login?userName=an"
curl -i -X POST http://localhost:5000/api/products -H "Authorization: Bearer <token vừa nhận>"

Đoán trước khi chạy: lần gọi đầu, chưa có token, trả status code nào?

Xem kết quả
Lần 1 (không token):  HTTP/1.1 401
Lần 2 (đăng nhập):    HTTP/1.1 200
                      eyJraWQiOiI2d1QybmMtZmZSYjJEY3p6S1ZSb1hm...
Lần 3 (có token):     HTTP/1.1 201

Chưa có token thì Spring Security trả 401, kèm header WWW-Authenticate: Bearer .... Có token đúng chữ ký, còn hạn thì create được chạy. GET /api/products rơi vào anyRequest().permitAll() nên ai cũng gọi được.

Lỗi hay gặp

Đặt quy tắc rộng trước quy tắc hẹp. Spring xét quy tắc từ trên xuống và dừng ở quy tắc đầu tiên khớp. /api/** khớp mọi request tới API, nên quy tắc authenticated() bên dưới không bao giờ được dùng. Gọi POST không token vẫn nhận 201.

// SAI — /api/** khớp trước, POST không cần token
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").permitAll()
    .requestMatchers(
        HttpMethod.POST, "/api/products")
    .authenticated()
    .anyRequest().permitAll());
// ĐÚNG — quy tắc hẹp trước, anyRequest cuối cùng
// SecurityConfig.java
package com.shop.api;

import org.springframework.context.annotation.*;
import org.springframework.http.*;
import org.springframework.security.config.annotation.web.builders.*;
import org.springframework.security.web.*;

@Configuration
class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
            .requestMatchers(
                HttpMethod.POST, "/api/products")
            .authenticated()
            .anyRequest().permitAll());
        return http.build();
    }
}

Nhầm 401 với 403. 401 là chưa xác thực: không có token hoặc token sai. 403 là server đã biết người gọi là ai nhưng người đó không đủ quyền, ví dụ quy tắc ghi .hasRole("ADMIN") mà người gọi không phải Admin.

Tóm tắt

  • Authentication: người gọi là ai. Authorization: người đó được làm gì.
  • Client đăng nhập để lấy JWT, rồi gửi theo header Authorization: Bearer.
  • Cấu hình SecurityFilterChain với oauth2ResourceServer, khai báo JwtDecoder dùng khoá bí mật.
  • Quy tắc xét từ trên xuống, hẹp trước rộng sau. Thiếu token là 401, thiếu quyền là 403.

Tự kiểm tra

0/3 câu
Câu 1

Quy tắc ghi .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN"). Người dùng đã đăng nhập (token hợp lệ) nhưng không phải Admin gọi DELETE /api/products/1. Nhận status code nào?

Câu 2

Client gửi token theo mỗi request ở đâu?

Câu 3

Vì sao khoá bí mật jwt.key không được lộ ra ngoài?

Xử lý lỗi tập trungViết test cho API

Nội dung bài

  1. 1.Khái niệm
  2. 2.Ví dụ
  3. 3.Cấp token khi đăng nhập
  4. 4.Thử ngay
  5. 5.Lỗi hay gặp
  6. 6.Tóm tắt