Backend

99 Ngày Spring — Ngày 49: Resource server

SSite Admin
14 tháng 09, 2026 7 phút đọc 0 lượt xem
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.

Sketchnote Ngày 49: resource server — một dòng issuer-uri là đủ, JWKS và khóa công khai, xoay khóa nhờ kid, bốn thứ Spring tự kiểm gồm cả iss, và bẫy tiền tố SCOPE_

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 STATELESS như 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ạn
  • Xoay 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 iss là 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 Jwt lấ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-uri và gọi một endpoint có bảo vệ bằng token thật.

  • Mở .well-known/jwks.json trê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_ trong hasAuthority để 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 49: Debug & đọc lỗi hiệu quả

Hai dòng đầu stack trace nói gần hết câu chuyện, chuỗi Caused by phải đọc từ dưới lên, breakpoint có điều kiện và theo ngoại lệ — cùng ranh giới giữa lúc dùng debugger và lúc chỉ còn log.

14 thg 9, 20267 phút0
99 Ngày Java — Ngày 48: Logging đúng cách

Vì sao System.out.println không phải logging, SLF4J là cái cầu chứ không phải bộ ghi log, mức log là câu hỏi ai sẽ đọc dòng này, dấu {} là chuyện hiệu năng — và những thứ tuyệt đối không được log.

13 thg 9, 20268 phút3
99 Ngày Spring — Ngày 48: OAuth2 login

Luồng authorization code năm bước và vì sao phải qua code thay vì trả token thẳng, tham số state chống CSRF, cấu hình oauth2Login gọn tới bất ngờ — và vì sao khóa định danh phải là cặp nhà cung cấp cộng id.

13 thg 9, 20267 phút5