المخاوف العرضية في البوابة
تقع بوابة API على حافة نظامك — كل طلب من كل عميل يمر عبرها قبل وصوله إلى أي خدمة داخلية. هذا الموضع يجعل البوابة المكان الطبيعي لاستضافة المخاوف العرضية: السلوكيات التي تحتاجها كل خدمة لكنها لا ينبغي أن تنفّذها بشكل منفرد. وأبرز هذه المخاوف تأثيرًا هما المصادقة والتفويض وتقييد معدل الطلبات. يشرح هذا الدرس السبب والكيفية والمفاضلات التي تهم المطور في بيئة الإنتاج الفعلية.
لماذا نمركز هذه المخاوف؟
بدون بوابة، تضطر كل خدمة مصغّرة إلى التحقق من الرموز المميزة ومراجعة الصلاحيات وفرض الحصص بمفردها. هذا التكرار يخلق ثلاث مشكلات جدية:
- عدم الاتساق: تفرض الخدمات التي طوّرتها فرق مختلفة قواعد متباينة، مما يُفضي إلى ثغرات أمنية أو سياسات حصص متعارضة.
- اتساع سطح الهجوم: كل خدمة تقبل رمزًا مميزًا خامًا هي هدف محتمل. اختراق أي واحدة منها يُعرّض الأخريات لإعادة استخدام الرمز (token replay).
- صعوبة الصيانة: تدوير مفتاح التوقيع أو تغيير مستوى الحصة أو إلغاء رمز مخترَق يستلزم تعديل كل خدمة بدلًا من نقطة واحدة.
المركزة في البوابة تحل هذه المشكلات الثلاث: نقطة إنفاذ واحدة، ومكان وحيد لتغيير السياسة، وتستطيع الخدمات الداخلية الوثوق بأنها لا تتلقى إلا طلبات تم التحقق منها مسبقًا.
المصادقة في البوابة باستخدام JWT
النمط السائد هو التحقق من JWT (رمز JSON المميز) في البوابة. يحصل العميل على JWT موقَّع من موفر هوية (خدمة مصادقة، Keycloak، Auth0، إلخ)، يُرفقه كرمز Bearer، وتتحقق البوابة من التوقيع والانتهاء قبل إعادة توجيه الطلب. تتلقى الخدمات الداخلية طلب HTTP بسيطًا مع الادعاءات (claims) مستخرجة بالفعل — ولا تحتاج أي تبعية أمنية على الإطلاق.
في Spring Cloud Gateway (التفاعلية، Spring Boot 3)، يُنفَّذ هذا كـ GlobalFilter:
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class JwtAuthFilter implements GlobalFilter, Ordered {
private final JwtTokenValidator validator;
public JwtAuthFilter(JwtTokenValidator validator) {
this.validator = validator;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
if (isPublicPath(path)) {
return chain.filter(exchange); // تجاوز المصادقة لـ /auth/**, /public/**
}
String authHeader = exchange.getRequest()
.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String token = authHeader.substring(7);
try {
Claims claims = validator.validate(token); // يرمي استثناء عند رمز سيئ أو منتهٍ
// إرسال الادعاءات كترويسات لكي تقرأها الخدمات الداخلية
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header("X-User-Id", claims.getSubject())
.header("X-User-Roles", String.join(",", getRoles(claims))))
.build();
return chain.filter(mutated);
} catch (JwtException ex) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() { return -100; } // تشغيل قبل التوجيه
private boolean isPublicPath(String path) {
return path.startsWith("/auth/") || path.startsWith("/public/");
}
}
لماذا نُعدِّل الطلب بإضافة ترويسات؟ يجب ألا تثق الخدمات الداخلية أبدًا بترويسة X-User-Id التي يوفرها المستخدم — إذ يمكن لمستخدم خبيث تزويرها. يجب على البوابة حذف أي ترويسة كهذه من الطلب الوارد وإعادة إضافتها فقط بعد نجاح التحقق من JWT. هذا يضمن أن القيمة موثوقة تمامًا.
الأداة المساعدة JwtTokenValidator هي غلاف رفيع حول مكتبة JWT (مثل jjwt أو nimbus-jose-jwt). مثال مختصر باستخدام jjwt:
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Base64;
@Component
public class JwtTokenValidator {
private final Key signingKey;
public JwtTokenValidator(@Value("${jwt.secret}") String base64Secret) {
byte[] keyBytes = Base64.getDecoder().decode(base64Secret);
this.signingKey = Keys.hmacShaKeyFor(keyBytes);
}
public Claims validate(String token) {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token) // يرمي ExpiredJwtException و MalformedJwtException وما إلى ذلك
.getBody();
}
}
افضّل المفاتيح غير المتماثلة (RS256 / ES256) في الإنتاج. مع السر المشترك (HS256)، كل خدمة تحتاج إلى التحقق من الرمز يجب أن تعرف السر أيضًا، وبالتالي يمكنها تزوير الرموز. مع RS256، تُوقِّع خدمة المصادقة بمفتاحها الخاص؛ والبوابة وأي جهة تحقق أخرى تحتاج فقط إلى المفتاح العام.
تقييد معدل الطلبات في البوابة
يمنع تقييد معدل الطلبات أي عميل منفرد — سواء كان تكاملًا مُخلًّا أو حلقة إعادة محاولة مُهيَّأة بشكل خاطئ أو هجومًا متعمدًا — من إغراق الخدمات الداخلية. تطبيق القيد في البوابة يعني رفض الطلب الذي يتجاوز الحصة قبل استهلاك أي وحدة معالجة مركزية أو اتصالات قاعدة بيانات أو عمليات إدخال/إخراج في الخلفية.
تأتي Spring Cloud Gateway مزوّدة بفلتر RequestRateLimiter مدعوم بـ Redis (يستخدم نصوص Lua لعدّ دلو الرموز بشكل ذري). أضف التبعية وهيّئها في application.yml:
# application.yml
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://ORDER-SERVICE
predicates:
- Path=/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 20 # الرموز المضافة في الثانية
redis-rate-limiter.burstCapacity: 40 # الحد الأقصى للرموز في الدلو
redis-rate-limiter.requestedTokens: 1 # الرموز المستهلكة لكل طلب
key-resolver: "#{@userKeyResolver}" # مرجع Bean بتعبير Spring SpEL
يحدد الـ bean المسمى key-resolver هوية الدلو — لكل مستخدم، لكل IP، أو لكل مفتاح API. محلّل يستخدم معرف المستخدم الموثق الذي أرسله فلتر JWT:
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class RateLimitConfig {
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
String userId = exchange.getRequest()
.getHeaders().getFirst("X-User-Id");
return Mono.just(userId != null ? userId : "anonymous");
};
}
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
);
}
}
عندما يفرغ الدلو، تُعيد البوابة تلقائيًا HTTP 429 Too Many Requests مع ترويستين تشخيصيتين: X-RateLimit-Remaining وX-RateLimit-Replenish-Rate.
تقييد المعدل في الذاكرة لا يعمل في بوابة متعددة النسخ. كل نسخة ستحافظ على دلوها الخاص، مما يسمح للعميل بمضاعفة حصته الفعلية بعدد نسخ البوابة. يُعدّ Redis (أو مخزن مشترك آخر) ضروريًا للإنفاذ الموزع الصحيح. لا تتجاوز تبعية Redis أبدًا في النشر المُوسَّع أفقيًا.
الجمع بين المصادقة وتقييد المعدل
يتكامل الفلتران بشكل طبيعي لأن ترتيب GlobalFilter يتحكم في خط الأنابيب. يعمل فلتر JWT أولًا (بالترتيب -100)، يُضيف X-User-Id للطلب، ثم يستخدم فلتر تقييد المعدل هذه الترويسة كمفتاح للدلو. هذا يعني أن القيود تُطبَّق دائمًا لكل هوية موثقة — لا لكل IP، الذي يسهل تزويره خلف NAT.
تهيئة مسار كامل يجمع الاثنين معًا:
spring:
cloud:
gateway:
default-filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
key-resolver: "#{@userKeyResolver}"
routes:
- id: product-service
uri: lb://PRODUCT-SERVICE
predicates:
- Path=/products/**
# فحص JWT يُطبَّق عالميًا عبر JwtAuthFilter المُعلَّم بـ @Component
# تقييد المعدل على مستوى المسار يُجاوز الافتراضي لهذه الخدمة:
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@userKeyResolver}"
الآثار الأمنية والمفاضلات
- البوابة كنقطة فشل وحيدة: مركزة المصادقة تعني أن بوابة مُهيَّأة بشكل خاطئ أو مُعطَّلة ستُوقف كامل سطح API. شغِّل نسخًا متعددة واكتب اختبارات تكامل شاملة لفلتر المصادقة.
- انحراف الساعة وانتهاء JWT: يُتحقق من انتهاء JWT مقابل ساعة نظام البوابة. إذا كانت الخدمات المصغّرة منتشرة على أجهزة افتراضية بساعات غير متزامنة، قد يُقبل رمز في البوابة ويبدو منتهيًا لجهة تحقق داخلية. استخدم NTP وأضف تسامحًا صغيرًا مع انحراف الساعة (
setAllowedClockSkewSeconds(30) في jjwt).
- إلغاء الرمز: رموز JWT عديمة الحالة ولا يمكن إلغاؤها قبل انتهائها. احتفظ بمدة صلاحية رمز الوصول قصيرة (5–15 دقيقة) وأصدر رموز تحديث بشكل منفصل. للإلغاء الفوري، احتفظ بقائمة رفض في Redis تتحقق منها البوابة عند كل طلب.
- دقة تقييد المعدل: القيود العالمية الإجمالية تحمي البنية التحتية؛ القيود الدقيقة لكل مسار تحمي الخدمات الفردية من إرهاقها. عرِّف كليهما.
الخلاصة
إنفاذ المصادقة وتقييد معدل الطلبات في البوابة يُبقي الخدمات الداخلية نظيفة، ويضمن اتساق السياسة عبر النظام بأكمله، ويُقلص سطح الهجوم تقليصًا جذريًا. النمط هو: التحقق من JWT في GlobalFilter ذي أولوية عالية، وتمييز الطلب بادعاءات موثوقة كترويسات، ثم السماح لفلتر RequestRateLimiter المدعوم بـ Redis بفرض الحصص لكل هوية موثقة. في الدرس القادم ستُشاهد كيف يتعاون سجل الخدمات وخادم التهيئة والبوابة معًا كطبقة بنية تحتية متكاملة.