Spring Security كـ Resource Server
في معمارية OAuth2 الحديثة نادرًا ما يُصدر تطبيقك التوكنات بنفسه. بدلًا من ذلك، يتولى خادم التصريح (Authorization Server) المخصص — سواء أكان Keycloak أم Auth0 أم Okta أم خادم Spring Authorization Server الخاص بك — إصدار توكنات JWT، وعلى كل خادم الموارد (Resource Server) تاليًا — وهي الـ APIs التي تمتلك البيانات فعليًا — التحقق من كل توكن حامل (bearer token) وارد بصورة مستقلة مع كل طلب. يتناول هذا الدرس هذا الدور تحديدًا: تهيئة Spring Security 6 ليعمل بوصفه Resource Server عديم الحالة (stateless) يتحقق من توكنات JWT الحاملة دون الحاجة إلى الاتصال بخادم التصريح مع كل طلب.
لماذا لا يثق Resource Server بالتوكن عمياً
التوكن الحامل ليس إلا سلسلة نصية، ومن يعترضها يستطيع إعادة استخدامها. مهمة Resource Server الإجابة عن ثلاثة أسئلة قبل تلبية أي طلب:
- هل التوقيع صالح؟ وُقِّع التوكن بالمفتاح الخاص لخادم التصريح، والتحقق منه بالمفتاح العام يُثبت عدم العبث به.
- هل انتهت صلاحية التوكن؟ تُقارن قيمة الادعاء
exp بساعة الخادم.
- هل هذا التوكن مخصص لي؟ يُسمي الادعاء
aud (الجمهور) خادم الموارد أو الخوادم المأذون لها بقبوله.
التحقق عديم الحالة: لأن خادم التصريح يوقّع JWT بمفتاح غير متماثل (RS256 أو ES256)، لا يحتاج Resource Server إلا إلى المفتاح العام — لا إلى المفتاح الخاص أبدًا. يستطيع التحقق من ملايين التوكنات في الثانية دون أي اتصال شبكي بخادم التصريح. هذه هي ميزة التوسع الجوهرية لـ JWTs مقارنةً بالتوكنات المبهمة (opaque tokens).
إضافة الاعتمادية
يقع دعم Resource Server في Spring Security ضمن spring-security-oauth2-resource-server، المحزوم مع المُشغِّل spring-boot-starter-oauth2-resource-server. أضفه إلى ملف pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
يجلب هذا المُشغِّل الواحد مفككَ JWT ومرشحَ التوكن الحامل وكل محوّلات Spring Security المطلوبة.
تهيئة مفكك JWT عبر OIDC Discovery
الأسلوب الأبسط والموصى به هو توجيه Spring إلى نقطة نهاية well-known الخاصة بخادم التصريح. يُحمّل Spring مجموعة المفاتيح العامة (JWKS) تلقائيًا ويخزّنها مؤقتًا ويُجددها حين يُدير خادم التصريح مفاتيحه.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myrealm
يستنتج Spring عنوان JWKS من issuer-uri + /.well-known/openid-configuration. يتحقق أيضًا من أن الادعاء iss في كل JWT وارد يطابق هذا العنوان — وهو فحص تزوير مجاني كنت ستكتبه بنفسك لولا ذلك.
المفتاح العام الثابت / دون اتصال: إذا لم يكن خادم التصريح متوافقًا مع OIDC أو كان غير متاح عند بدء التشغيل، يمكنك توفير عنوان JWK Set URI أو حتى مفتاح RSA العام الخام مباشرةً:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certs
استخدام
jwk-set-uri يتجاوز التحقق من المُصدر، لذا أضفه يدويًا في إعدادات الأمان.
فئة إعداد الأمان
بمجرد وضع المُشغِّل في مسار الفئات وتعيين issuer-uri، يُهيئ Spring Boot تلقائيًا Resource Server يشترط وجود JWT صالح على كل طلب. من الناحية العملية ستتجاوز الإعدادات الافتراضية دائمًا لإضافة قواعد تصريح دقيقة:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // يُفعّل @PreAuthorize على توابع المتحكم
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Resource servers عديمة الحالة — لا تنشئ جلسة HTTP أبدًا
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// تعطيل CSRF: التوكنات الحاملة تجعله غير ضروري
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
// تعريف هذا التطبيق بوصفه OAuth2 Resource Server
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
// يضع Keycloak الأدوار في "realm_access.roles"؛ اضبط وفق خادم التصريح لديك
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}
لا تعطّل CSRF وتنسى سرقة التوكن. تعطيل CSRF صحيح للـ APIs عديمة الحالة لأنه لا توجد ملفات تعريف ارتباط يمكن اختطافها — لكن فقط إذا أرسل العملاء التوكن دائمًا في الترويسة Authorization: Bearer <token>. إذا كانت API تستهلكها أيضًا واجهة أمامية تعمل في المتصفح وتخزن JWT في ملف تعريف ارتباط، فأعد تفعيل حماية CSRF.
فهم JwtAuthenticationConverter
بعد التحقق من التوقيع وانتهاء الصلاحية، يجب على Spring تحويل ادعاءات JWT إلى كائن Authentication في Spring Security — تحديدًا JwtAuthenticationToken. يتحكم JwtAuthenticationConverter في هذا التعيين؛ مسؤوليته الجوهرية استخراج الصلاحيات الممنوحة (الأدوار والنطاقات) من ادعاءات التوكن.
تضع خوادم تصريح مختلفة الأدوار في مواضع مختلفة:
- Keycloak:
realm_access.roles (JSON متداخل)
- Auth0: ادعاء مخصص، مثل
https://example.com/roles
- Spring Authorization Server:
scope (سلسلة مفصولة بمسافات، مسبوقة بـ SCOPE_)
لاستخراج الادعاءات المعقدة يمكنك تنفيذ Converter<Jwt, Collection<GrantedAuthority>> مباشرةً وتوصيله بالمحوِّل. إليك مثالًا يقرأ البنية المتداخلة في Keycloak:
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class KeycloakRolesConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Map<String, Object> realmAccess =
jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return List.of();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
}
التحقق من الجمهور — تقييد التوكنات المقبولة
لا يتحقق مفكك JWT في Spring افتراضيًا من الادعاء aud. في بيئة الخدمات المصغرة حيث يُصدر خادم تصريح واحد توكنات لعدة Resource Servers، يعني تجاوز التحقق من الجمهور أن توكن الخدمة أ يمكن إعادة تشغيله ضد الخدمة ب. هيّئ دائمًا محققًا للجمهور:
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) {
NimbusJwtDecoder decoder =
(NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
List<String> audiences = jwt.getAudience();
if (audiences.contains("orders-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token",
"Token not intended for this service", null));
};
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
audienceValidator);
decoder.setJwtValidator(combined);
return decoder;
}
الوصول إلى التوكن في المتحكم
بمجرد أن تُتحقق سلسلة المرشحات من التوكن، يصبح كائن Jwt الكامل متاحًا في سياق الأمان. يمكنك حقنه في توابع المتحكم باستخدام @AuthenticationPrincipal:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProfileController {
@GetMapping("/api/me")
public Map<String, Object> profile(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"subject", jwt.getSubject(),
"email", jwt.getClaimAsString("email"),
"name", jwt.getClaimAsString("name"),
"issuedAt", jwt.getIssuedAt()
);
}
}
أمان التوابع مع @PreAuthorize: بما أن
@EnableMethodSecurity موجود في فئة الإعداد، يمكنك تقييد نقاط نهاية بعينها بأدوار محددة دون تكرار قواعد
requestMatchers في كل مكان:
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/orders/{id}")
public void deleteOrder(@PathVariable Long id) { ... }
المقايضات في الأنظمة الموزعة
التحقق من JWT عديم الحالة سريع وقابل للتوسع، لكنه ينطوي على مقايضات حقيقية يجب أن تفهمها:
- لا إلغاء فوري: يظل JWT المسروق صالحًا حتى انتهاء صلاحيته. خفف هذا الأمر بأوقات انتهاء صلاحية قصيرة (5–15 دقيقة) ودوران توكنات التحديث (تناولنا ذلك في الدرس السابع).
- انجراف الساعة: إذا تقدمت ساعة Resource Server عن ساعة خادم التصريح، ستبدو التوكنات منتهية الصلاحية. يتيح
NimbusJwtDecoder تهيئة تسامح clockSkew قابل للضبط (الافتراضي: 60 ثانية).
- تخزين JWKS مؤقتًا: يُخزّن Spring مجموعة المفاتيح العامة مؤقتًا ويُجددها حين يواجه توقيعًا لا يطابق أي مفتاح مخزّن. إذا كان خادم التصريح يدير مفاتيحه بتكرار كبير، اضبط مدة صلاحية التخزين المؤقت. لا تعطّل التخزين المؤقت — فجلب JWKS مع كل طلب يُفسد الغرض من التحقق عديم الحالة.
- تدوير المفاتيح: يجب على خادم التصريح نشر المفاتيح الجديدة بـ
kid (معرّف المفتاح) جديد إلى جانب القديمة خلال نافذة التدوير. يُطابق Spring الترويسة kid لكل JWT بإدخال JWKS المخزّن مؤقتًا، لذا تكون معظم سيناريوهات التدوير شفافة بالنسبة لـ Resource Server.
الخلاصة
تهيئة Spring Security بوصفه OAuth2 Resource Server تتطلب ثلاث خطوات ملموسة: إضافة المُشغِّل، وتوجيه issuer-uri إلى خادم التصريح، وكتابة SecurityFilterChain يستدعي oauth2ResourceServer(...).jwt(...). يتولى الإطار جلب JWKS والتحقق من التوقيع وفحص انتهاء الصلاحية والمُصدر وملء سياق الأمان. مسؤولياتك هي التحقق من الجمهور واستخراج الأدوار عبر JwtAuthenticationConverter واختيار أوقات انتهاء صلاحية قصيرة بحكمة للحد من أثر التوكن المسروق. في الدرس القادم تجمع كل هذه العناصر في مشروع محمي بالكامل.