99 Ngày Spring — Ngày 49: Resource server
Hôm qua ứng dụng của bạn là client: nó đi xin token. Hôm nay là phía còn lại — resource server: nó nhận token và quyết định cho qua hay không. Khác biệt lớn nhất so với filter JWT tự viết ở Ngày 45 là bạn không còn giữ khóa ký: người phát token là Keycloak, Auth0, hay auth service của công ty, còn bạn chỉ verify. Và điều đó lại chính là điểm mạnh nhất của cách làm này.

Một dòng cấu hình, không một dòng code
Phần này ngắn tới mức khó tin: khai issuer-uri rồi thêm oauth2ResourceServer, xong. Spring tự gọi tới .well-known/openid-configuration để tìm bộ khóa công khai, tự tải về và tự cache — bạn không viết một dòng code verify nào.
// RESOURCE SERVER — nhìn từ phía CÒN LẠI của OAuth2.
// Ngày 48: ứng dụng của bạn là CLIENT — nó đi xin token.
// Hôm nay: ứng dụng của bạn là RESOURCE SERVER — nó NHẬN token và quyết định
// cho qua hay không. Đây chính là hình dạng của mọi API trong hệ microservice.
// KHÁC BIỆT LỚN với filter JWT tự viết ở Ngày 45:
// Ngày 45: BẠN phát token, BẠN ký, BẠN tự verify → bạn giữ khóa bí mật
// Hôm nay: NGƯỜI KHÁC phát token (Keycloak, Auth0, Cognito, hay auth service
// của chính công ty bạn). Bạn chỉ VERIFY. Bạn KHÔNG có khóa ký.
// Toàn bộ cấu hình chỉ có ngần này:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.congty.vn/realms/main
// ⭐ MỘT dòng đó là đủ. Spring tự gọi tới
// <issuer>/.well-known/openid-configuration để lấy địa chỉ bộ khóa công khai,
// rồi tự tải khóa về và TỰ CACHE. Bạn không viết một dòng code nào.
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) // ❗ như Ngày 45
.build();
}Đây là hình dạng của mọi API trong hệ microservice: không service nào tự đăng nhập, tất cả cùng tin một auth server.
Vẫn phải bật
STATELESSnhư Ngày 45 — quên là Spring lại sinh session, và bạn mất đúng cái lợi của token.So với Ngày 45, khác biệt là bạn verify chứ không phát token — nên bạn không cần và không nên giữ khóa ký.
JWKS — verify được mà không giữ bí mật nào
Đây là ý tưởng đẹp nhất của cả bài. Auth server ký bằng khóa riêng và không đưa nó cho ai; nó chỉ công bố khóa công khai. Nhờ vậy hai mươi service đều verify được token mà không service nào giữ bí mật — một service bị chiếm cũng không phát ra được token giả.
// KHÓA CÔNG KHAI — vì sao bạn KHÔNG cần bí mật gì cả.
// Auth server ký token bằng khóa RIÊNG (private key). Nó KHÔNG đưa khóa đó cho ai.
// Nó công bố khóa CÔNG KHAI ở một địa chỉ, gọi là JWKS:
// GET https://auth.congty.vn/.well-known/jwks.json
// { "keys": [ { "kid": "abc123", "kty": "RSA", "n": "...", "e": "AQAB" } ] }
// ⭐ VÌ SAO ĐIỀU NÀY QUAN TRỌNG: hai mươi service của bạn đều verify được token,
// mà KHÔNG service nào giữ khóa bí mật. Một service bị chiếm cũng KHÔNG
// phát ra được token giả. Đây là điểm hơn hẳn khóa đối xứng dùng chung.
// XOAY KHÓA hoạt động nhờ trường "kid" trong phần đầu token:
// token nói "tôi được ký bằng khóa kid=abc123"
// → Spring tìm khóa đó trong bộ khóa đã tải; không thấy thì tải lại JWKS
// → nhờ vậy auth server đổi khóa mà các service KHÔNG cần khởi động lại.
// ❗ BỐN THỨ Spring TỰ kiểm, đừng tự viết lại:
// · chữ ký khớp khóa công khai
// · exp — token còn hạn
// · nbf — chưa tới lúc dùng thì chưa được dùng
// · iss — đúng auth server bạn tin, KHÔNG phải một server bất kỳ
// Thiếu kiểm iss là lỗ hổng chết người: kẻ tấn công tự dựng auth server của hắn,
// tự ký một token nói hắn là admin — và service của bạn tin.
// ❗ Cần kiểm thêm "aud" (token này dành cho AI) thì phải khai rõ:
JwtValidators.createDefaultWithIssuer(issuer); // + audience validator của bạnXoay khóa không cần khởi động lại service: token mang
kid, Spring không thấy khóa đó thì tự tải lại JWKS.Spring tự kiểm chữ ký, hạn dùng, thời điểm hiệu lực và issuer — bốn thứ này đừng bao giờ tự viết lại.
Bỏ kiểm
isslà lỗ hổng chết người: kẻ tấn công dựng auth server của hắn, tự ký token nói hắn là admin, và service của bạn tin.Muốn kiểm thêm
aud— token này dành cho ai — thì phải khai rõ, mặc định không kiểm.
Scope — và vì sao nó không phải role
Lại một tiền tố nữa: Spring thêm SCOPE_ giống hệt chuyện ROLE_ hôm kia, và lại gây ra đúng cái 403 im lặng đó. Nhưng phần đáng nghĩ hơn là scope không phải role: role nói người dùng là ai, còn scope nói ứng dụng được phép làm gì.
// SCOPE — phân quyền của thế giới OAuth2, và một cái bẫy tên gọi.
// Token mang một trường "scope":
// { "sub": "u-42", "scope": "order:read order:write", "iss": "https://auth..." }
// ❗ BẪY: Spring tự thêm tiền tố "SCOPE_" — y hệt chuyện "ROLE_" của Ngày 47.
.requestMatchers(GET, "/api/don/**").hasAuthority("SCOPE_order:read") // ✅
.requestMatchers(GET, "/api/don/**").hasAuthority("order:read") // ❌ luôn 403
@PreAuthorize("hasAuthority('SCOPE_order:write')") // trên method cũng vậy
// ⭐ SCOPE ≠ ROLE — đây là chỗ nhiều người lẫn, và nó là chuyện thiết kế:
// ROLE nói NGƯỜI DÙNG là ai: người này là quản lý
// SCOPE nói ỨNG DỤNG được phép làm gì: app di động này chỉ được ĐỌC đơn
// Một quản lý dùng app chỉ-đọc thì vẫn KHÔNG ghi được. Hai tầng, cùng áp dụng.
// Vì thế mới có màn hình "Ứng dụng X muốn truy cập..." — đó chính là scope.
// Lấy claim tùy ý ra khỏi token — KHÔNG cần tra database:
@GetMapping("/toi")
public String toi(@AuthenticationPrincipal Jwt jwt) {
return jwt.getClaimAsString("email"); // ⭐ dữ liệu nằm SẴN trong token
}
// Ánh xạ role của Keycloak (nằm trong realm_access.roles) sang authority:
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
var roles = ((Map<String,List<String>>) jwt.getClaim("realm_access")).get("roles");
return roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
});
// ❗ Mỗi auth server đặt role ở một chỗ khác nhau — luôn IN token ra xem trước.Một quản lý dùng app chỉ-đọc thì vẫn không ghi được — hai tầng cùng áp dụng, và đó chính là ý nghĩa của màn hình "Ứng dụng X muốn truy cập…".
@AuthenticationPrincipal Jwtlấy thẳng claim từ token, không cần tra database — đó là lý do token mang theo dữ liệu.Mỗi auth server đặt role ở một chỗ khác nhau — Keycloak để trong
realm_access.roles; luôn in token ra xem trước khi viết converter.
Bài tập nhỏ
Dựng Keycloak bằng Docker, khai
issuer-urivà gọi một endpoint có bảo vệ bằng token thật.Mở
.well-known/jwks.jsontrên trình duyệt để nhìn tận mắt bộ khóa công khai.Sửa một ký tự trong phần payload của token rồi gọi lại — xem 401 vì chữ ký không khớp.
Bỏ tiền tố
SCOPE_tronghasAuthorityđể gặp 403, rồi thêm lại.In toàn bộ claim của token ra log (nhớ Ngày 48: đừng log token thật, chỉ log tên claim) để xem auth server của bạn đặt role ở đâu.
Kết lại
Bốn ý gói lại hôm nay: resource server là phía nhận token, và toàn bộ cấu hình gọn lại trong một dòng issuer-uri; JWKS cho phép verify mà không giữ bí mật nào — hai mươi service cùng verify, không service nào phát được token giả; bỏ kiểm iss là lỗ hổng chết người, vì kẻ tấn công chỉ cần dựng auth server của riêng hắn; và scope không phải role — role nói bạn là ai, scope nói ứng dụng được phép làm gì, hai tầng cùng áp dụng. Ngày 50 khép lại mười ngày bảo mật bằng một bài tổng kết: checklist trước khi lên production — CSRF khi nào thật sự cần, CORS đi cùng security, quản lý secret, và rate limiting. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


