Resource Server với Keycloak: nhiều service cùng validate một token
Còn mấy bài nữa?
Đã học 7 bài. Còn 3 bài chính nữa là xong phần Spring Security:
| Bài | Nội dung | Trạng thái |
|---|---|---|
| 4.4.1–4.4.7 | Filter chain → OAuth2/OIDC | Đã học |
| 4.4.8 | Resource Server, Keycloak, security trong microservices | Tiếp theo |
| 4.4.9 | Password encoding, CORS, CSRF | Chưa học |
| 4.4.10 | Method security và test | Chưa học |
Tiếp đi: Resource Server và Keycloak.
Bài này kết nối Spring Security với microservices. shop-app giờ đã tách thành nhiều service, và việc login giao cho Keycloak.
1. Bài toán
Client (Web / Mobile)
|
v
API Gateway
|
v
order-service ---> payment-service ---> notification-service
John login qua Keycloak, nhận access token, rồi gọi:
POST /api/orders
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Ba câu hỏi:
- Gateway đã validate JWT rồi,
order-servicecó cần validate lại không? order-servicegọipayment-servicethì truyền token của John hay token của service?- Role của John trong Keycloak làm sao thành
hasRole('ADMIN')trong Spring?
2. Vấn đề
- Nếu chỉ gateway kiểm tra, ai gọi thẳng
order-servicehoặc giả headerX-User-Idlà bypass được. - Nếu mỗi service gọi Keycloak cho mỗi request thì Keycloak thành nút cổ chai.
- Keycloak để role trong claim lồng nhau (
realm_access.roles), Spring mặc định chỉ đọcscope=>hasRoleluôn trả 403.
3. Giải pháp: mỗi service là một Resource Server
Gateway và từng service đều validate JWT độc lập (defense in depth, zero trust). Keycloak ký token bằng RS256: private key chỉ Keycloak giữ, các service chỉ cần public key để verify cục bộ, không gọi Keycloak mỗi request.
- issuer-uri: Spring đọc
{issuer}/.well-known/openid-configuration, lấyjwks_uri. - JWKS: tập public key của Keycloak. Spring cache lại, chọn key theo
kidtrong header JWT. - JwtAuthenticationConverter: map claim thành
GrantedAuthority.
4. Code từng bước
Bước 1: dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Bước 2: config (Keycloak chạy ở 8081, realm shop)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8081/realms/shop
# hoặc chỉ định thẳng, khỏi gọi discovery lúc khởi động:
# jwk-set-uri: http://localhost:8081/realms/shop/protocol/openid-connect/certs
audiences: order-service # Spring Boot 3.2+
Bước 3: SecurityConfig + map role Keycloak
Token Keycloak trông thế này:
{
"iss": "http://localhost:8081/realms/shop",
"sub": "f1c2...-uuid",
"aud": ["order-service"],
"azp": "shop-web",
"realm_access": { "roles": ["USER", "ADMIN"] },
"resource_access": { "order-service": { "roles": ["ORDER_DELETE"] } },
"scope": "openid profile email"
}
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakConverter())));
return http.build();
}
private Converter<Jwt, AbstractAuthenticationToken> keycloakConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); // SCOPE_xxx
return jwt -> {
Set<GrantedAuthority> authorities = new HashSet<>(scopes.convert(jwt));
// realm roles -> ROLE_USER, ROLE_ADMIN
Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
// client roles của chính service này -> ORDER_DELETE
Map<String, Object> resource = jwt.getClaimAsMap("resource_access");
if (resource != null && resource.get("order-service") instanceof Map<?, ?> client
&& client.get("roles") instanceof Collection<?> roles) {
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority(r.toString())));
}
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
};
}
}
Bước 4: test bằng curl
# lấy token từ Keycloak (môi trường dev, client test)
TOKEN=$(curl -s -X POST http://localhost:8081/realms/shop/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=order-tester \
-d client_secret=$TEST_CLIENT_SECRET | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/orders # 200
curl localhost:8080/api/orders # 401
curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/admin/users # 403 nếu thiếu ROLE_ADMIN
5. Cách hoạt động
Keycloak (realm shop)
/.well-known/openid-configuration
/protocol/openid-connect/certs (JWKS, public keys)
| tải 1 lần, cache, refresh khi gặp kid lạ
+-----------------+-----------------+
v v v
API Gateway order-service payment-service
Mỗi request tại mỗi service (BearerTokenAuthenticationFilter):
1. Lấy token từ header Authorization
2. Đọc kid trong header JWT -> chọn public key trong JWKS cache
3. Verify chữ ký RS256
4. Kiểm tra exp, nbf, iss (và aud nếu cấu hình)
5. JwtAuthenticationConverter -> authorities
6. Authorization: hasRole / @PreAuthorize
Keycloak down thì sao? Token đã phát vẫn được verify bằng key đã cache. Nhưng login, refresh, lấy token mới, tải key mới (khi Keycloak rotate key) sẽ lỗi. Phân biệt rõ: token issuance cần Keycloak, token validation thì không.
6. So sánh: ai validate và cách gọi xuống downstream
| Phương án | Ưu | Rủi ro |
|---|---|---|
| Chỉ gateway validate, truyền header user xuống | Service đơn giản | Gọi thẳng service / giả header là bypass |
| Gateway + từng service đều validate | Defense in depth, service tự authorization | Mỗi service cấu hình resource server (rẻ, verify cục bộ) |
| Chiến lược gọi service khác | Khi nào dùng |
|---|---|
| Token Relay | Downstream làm thay mặt user và token có aud phù hợp |
| Token Exchange | Cần token mới, hẹp hơn, dành riêng cho downstream |
| Client Credentials | Service tự làm việc, không có user (job, consumer) |
| mTLS | Xác thực danh tính service ở tầng kết nối, dùng kèm OAuth2 |
7. Khi nào nên dùng / không nên dùng
- Nên: mọi service nhận request từ ngoài hoặc từ service khác đều là resource server.
- Nên: kiểm tra
audđể token của app này không dùng được cho service khác. - Không nên: introspection mỗi request khi đã có JWT ký RS256, trừ khi cần revoke tức thì.
- Không nên: nhét JWT của user vào message RabbitMQ; message có thể retry, nằm DLQ lâu hơn hạn token. Event chỉ chứa
orderId,userId,correlationId; consumer chạy dưới danh tính service.
Gateway nhận token rồi thì làm sao chuyển token xuống order-service?
Có hai trường hợp, tùy gateway là resource server hay OAuth2 client.
Gateway là resource server (client gửi Bearer token): Spring Cloud Gateway mặc định giữ nguyên header Authorization khi forward. Gateway validate sớm để chặn rác, service validate lại.
spring:
cloud:
gateway:
routes:
- id: orders
uri: lb://order-service
predicates: [ Path=/api/orders/** ]
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8081/realms/shop
Gateway là OAuth2 client / BFF (browser login qua gateway, giữ session cookie): gateway giữ token trong session và dùng filter TokenRelay để gắn access token vào request xuống service:
spring:
cloud:
gateway:
default-filters:
- TokenRelay=
Browser --cookie--> Gateway (oauth2Login, giữ token) --Bearer token--> order-service
Cách BFF này giúp token không bao giờ xuống browser, nhưng gateway giữ session nên phải tính CSRF (bài 4.4.9).
Còn order-service gọi payment-service bằng Client Credentials thì code sao?
Spring có sẵn OAuth2AuthorizedClientManager: tự xin token, cache và xin lại khi hết hạn.
spring:
security:
oauth2:
client:
registration:
payment-client:
provider: keycloak
client-id: order-service
client-secret: ${ORDER_SERVICE_SECRET}
authorization-grant-type: client_credentials
provider:
keycloak:
token-uri: http://localhost:8081/realms/shop/protocol/openid-connect/token
@Configuration
public class OAuth2ClientConfig {
@Bean
OAuth2AuthorizedClientManager clientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clientService) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
manager.setAuthorizedClientProvider(
OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
return manager;
}
}
@Service
public class PaymentClient {
private final OAuth2AuthorizedClientManager manager;
private final RestTemplate restTemplate;
public PaymentClient(OAuth2AuthorizedClientManager manager, RestTemplate restTemplate) {
this.manager = manager;
this.restTemplate = restTemplate;
}
public String getPayments() {
OAuth2AuthorizedClient client = manager.authorize(
OAuth2AuthorizeRequest.withClientRegistrationId("payment-client")
.principal("order-service").build());
if (client == null) throw new IllegalStateException("Cannot authorize OAuth2 client");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(client.getAccessToken().getTokenValue());
return restTemplate.exchange("http://payment-service/api/payments",
HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody();
}
}
| User token | Service token | |
|---|---|---|
| Đại diện | Người dùng | Ứng dụng/service |
| Flow | Authorization Code + PKCE | Client Credentials |
| Ví dụ | John tạo order | Job retry payment |
Lưu ý: service token không tự có mọi quyền của user. payment-service vẫn phải kiểm tra nghiệp vụ.
Phỏng vấn hỏi về resource server và Keycloak thì trả lời sao?
Q1. Mỗi microservice có cần validate JWT không, hay chỉ gateway?
🇻🇳 Tiếng Việt:
Em cho mỗi service là một resource server và tự validate JWT, gateway chỉ là lớp chặn sớm. Như vậy gọi thẳng service hay giả header không bypass được, và mỗi service tự áp authorization nghiệp vụ. Chi phí thấp vì JWT RS256 verify cục bộ bằng public key đã cache.
🇬🇧 English:
I make every service a resource server that validates the JWT itself, and the gateway is only an early filter. That way, calling a service directly or forging headers does not bypass security, and each service enforces its own business authorization. It is cheap because RS256 tokens are verified locally with cached public keys.
Q2. Spring Resource Server verify JWT của Keycloak thế nào?
🇻🇳 Tiếng Việt:
Từ
issuer-uri, Spring đọc discovery document để lấyjwks_uri, tải và cache public key. Mỗi request nó chọn key theokid, verify chữ ký, kiểm traexp,issvàaud, rồi dùng converter map claim thành authorities.
🇬🇧 English:
From the
issuer-uri, Spring reads the discovery document to find thejwks_uri, then downloads and caches the public keys. On each request it picks the key bykid, verifies the signature, checksexp,issandaud, and uses a converter to map claims to authorities.
Q3. Làm sao dùng role Keycloak với hasRole?
🇻🇳 Tiếng Việt:
Mặc định Spring chỉ map claim
scopethànhSCOPE_xxx. Keycloak để realm role trongrealm_access.rolesvà client role trongresource_access.{client}.roles, nên em viếtJwtAuthenticationConverterriêng, thêm prefixROLE_cho realm role.
🇬🇧 English:
By default Spring only maps the
scopeclaim toSCOPE_xxxauthorities. Keycloak puts realm roles inrealm_access.rolesand client roles inresource_access.{client}.roles, so I write a customJwtAuthenticationConverterthat adds theROLE_prefix to realm roles.
Q4. Keycloak down thì hệ thống ra sao?
🇻🇳 Tiếng Việt:
Token đã phát vẫn được verify bằng public key đã cache. Nhưng login, refresh token và client credentials mới sẽ lỗi, nên Keycloak phải chạy cluster, có HA.
🇬🇧 English:
Existing tokens are still verified with cached public keys, but login, token refresh and new client-credentials tokens fail, so Keycloak must run as a highly available cluster.
Cách nhớ ngắn gọn
issuer-uri→ discovery → JWKS → chọn key theokid→ verify cục bộ.- Keycloak role:
realm_access/resource_access→ converter →ROLE_. - Gateway chặn sớm, service vẫn validate; nhớ kiểm tra
aud. - Thay mặt user: Token Relay. Không có user: Client Credentials.
Tự kiểm tra
Vì sao resource server không cần private key của Keycloak?
RS256 là ký bất đối xứng: Keycloak ký bằng private key, service verify bằng public key lấy từ JWKS.
Token có realm_access.roles: ["ADMIN"] nhưng hasRole('ADMIN') vẫn 403. Vì sao?
Converter mặc định chỉ đọc scope. Cần custom JwtAuthenticationConverter map realm role thành ROLE_ADMIN.
Không kiểm tra aud thì rủi ro gì?
Token cấp cho app/service khác (cùng issuer) cũng được chấp nhận, mở rộng phạm vi token bị lộ.
Filter TokenRelay của Gateway dùng khi nào?
Khi gateway là OAuth2 client (oauth2Login/BFF) giữ token trong session và cần gắn access token vào request xuống service.
Scheduled job retry payment nên dùng token nào?
Service token qua Client Credentials, quyền tối thiểu; không dùng token admin hay hardcode token.