Xác thực bằng JWT
Nội dung bài · 6 mục
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:
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
@Beantạo một bean, Spring đưa nó vào nơi cần qua tham số hay constructor. jwt.keylà khoá bí mật để ký token, đặt trongapplication.propertiesvà dài ít nhất 32 ký tự. Ai có khoá này là tự tạo được token.JwtDecoderkiểm tra chữ ký của token trong mỗi request.JwtEncoderký 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ọiPOSTkhông token đều bị 403.authorizeHttpRequestsliệt kê quy tắc:POST /api/productsphải đăng nhập, mọi request khác ai cũng gọi được.oauth2ResourceServer(... jwt ...)đọc token trong headerAuthorizationđể 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
PasswordEncodercủ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-nheChạ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 201Chư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
SecurityFilterChainvớioauth2ResourceServer, khai báoJwtDecoderdù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âuQuy 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?
Client gửi token theo mỗi request ở đâu?
Vì sao khoá bí mật jwt.key không được lộ ra ngoài?