REST Security مع JWT باستخدام Java و Spring Security
نشرت: 2022-03-11حماية
الأمن هو عدو الملاءمة ، والعكس صحيح. هذه العبارة صحيحة لأي نظام ، افتراضي أو حقيقي ، من مدخل المنزل الفعلي إلى منصات الويب المصرفية. يحاول المهندسون باستمرار إيجاد التوازن الصحيح لحالة الاستخدام المحددة ، ويميلون إلى جانب أو آخر. عادة ، عندما يظهر تهديد جديد ، نتجه نحو الأمن ونبتعد عن الراحة. بعد ذلك ، نرى ما إذا كان بإمكاننا استعادة بعض الراحة المفقودة دون تقليل الأمان كثيرًا. علاوة على ذلك ، فإن هذه الحلقة المفرغة تستمر إلى الأبد.
دعنا نحاول فحص حالة أمان REST اليوم ، باستخدام برنامج تعليمي مباشر للأمان Spring لإثبات ذلك أثناء العمل.
بدأت خدمات REST (التي تعني النقل التمثيلي للحالة) كنهج مبسط للغاية لخدمات الويب التي لها مواصفات ضخمة وتنسيقات مرهقة ، مثل WSDL لوصف الخدمة ، أو SOAP لتحديد تنسيق الرسالة. في REST ، ليس لدينا أي من هؤلاء. يمكننا وصف خدمة REST في ملف نصي عادي واستخدام أي تنسيق رسالة نريد ، مثل JSON أو XML أو حتى نص عادي مرة أخرى. تم تطبيق النهج المبسط على أمان خدمات REST أيضًا ؛ لا يوجد معيار محدد يفرض طريقة معينة لمصادقة المستخدمين.
على الرغم من أن خدمات REST لا تحتوي على الكثير من التحديد ، إلا أن أحد أهمها هو الافتقار إلى الحالة. هذا يعني أن الخادم لا يحتفظ بأي حالة للعميل ، مع الجلسات كمثال جيد. وبالتالي ، يرد الخادم على كل طلب كما لو كان أول طلب يقدمه العميل. ومع ذلك ، حتى الآن ، لا تزال العديد من التطبيقات تستخدم المصادقة القائمة على ملفات تعريف الارتباط ، والتي يتم توريثها من التصميم المعماري القياسي لموقع الويب. يجعل النهج عديم الحالة لـ REST ملفات تعريف الارتباط للجلسة غير مناسبة من وجهة نظر الأمان ، ولكن مع ذلك ، لا تزال مستخدمة على نطاق واسع. إلى جانب تجاهل حالة انعدام الجنسية المطلوبة ، جاء النهج المبسط كمقايضة أمنية متوقعة. مقارنة بمعيار WS-Security المستخدم لخدمات الويب ، من الأسهل بكثير إنشاء خدمات REST واستهلاكها ، ومن ثم انتقلت الراحة إلى السطح. المفاضلة هي أمان ضئيل جدًا ؛ يعد اختطاف الجلسة وتزوير الطلبات عبر المواقع (XSRF) من أكثر مشكلات الأمان شيوعًا.
في محاولة للتخلص من جلسات العميل من الخادم ، تم استخدام بعض الطرق الأخرى من حين لآخر ، مثل مصادقة Basic أو Digest HTTP. كلاهما يستخدم رأس Authorization
لنقل بيانات اعتماد المستخدم ، مع إضافة بعض الترميز (HTTP الأساسي) أو التشفير (ملخص HTTP). بالطبع ، كانت لديهم نفس العيوب الموجودة في مواقع الويب: يجب استخدام HTTP Basic عبر HTTPS نظرًا لإرسال اسم المستخدم وكلمة المرور بترميز base64 القابل للعكس بسهولة ، وقد فرض HTTP Digest استخدام تجزئة MD5 القديمة التي ثبت أنها غير آمنة.
أخيرًا ، استخدمت بعض التطبيقات رموزًا عشوائية لمصادقة العملاء. يبدو أن هذا الخيار هو أفضل ما لدينا ، في الوقت الحالي. إذا تم تنفيذه بشكل صحيح ، فإنه يصلح جميع مشكلات الأمان الخاصة بـ HTTP Basic أو HTTP Digest أو ملفات تعريف الارتباط للجلسة ، وهو سهل الاستخدام ويتبع النمط عديم الحالة.
ومع ذلك ، مع مثل هذه الرموز التعسفية ، هناك القليل من المعايير المتضمنة. كان لدى كل مقدم خدمة فكرته حول ما يجب إدخاله في الرمز وكيفية تشفيره أو تشفيره. يتطلب استهلاك الخدمات من مزودين مختلفين وقتًا إضافيًا للإعداد ، فقط للتكيف مع تنسيق الرمز المحدد المستخدم. من ناحية أخرى ، فإن الطرق الأخرى (ملف تعريف الارتباط للجلسة و HTTP Basic و HTTP Digest) معروفة جيدًا للمطورين ، وتعمل جميع المتصفحات تقريبًا على جميع الأجهزة معهم خارج الصندوق. الأطر واللغات جاهزة لهذه الأساليب ، ولها وظائف مدمجة للتعامل مع كل منها بسلاسة.
مصادقة JWT
JWT (مختصر من JSON Web Token) هو المعيار المفقود لاستخدام الرموز المميزة للمصادقة على الويب بشكل عام ، وليس فقط لخدمات REST. حاليًا ، هو في حالة المسودة مثل RFC 7519. إنه قوي ويمكن أن يحمل الكثير من المعلومات ، ولكنه لا يزال سهل الاستخدام على الرغم من أن حجمه صغير نسبيًا. مثل أي رمز آخر ، يمكن استخدام JWT لتمرير هوية المستخدمين المصادق عليهم بين مزود الهوية ومزود الخدمة (والتي ليست بالضرورة نفس الأنظمة). يمكن أن تحمل أيضًا جميع مطالبات المستخدم ، مثل بيانات التفويض ، لذلك لا يحتاج مزود الخدمة إلى الدخول إلى قاعدة البيانات أو الأنظمة الخارجية للتحقق من أدوار المستخدم والأذونات لكل طلب ؛ يتم استخراج هذه البيانات من الرمز المميز.
إليك كيفية تصميم أمان JWT للعمل:
- يقوم العملاء بتسجيل الدخول عن طريق إرسال بيانات اعتمادهم إلى مزود الهوية.
- يتحقق موفر الهوية من أوراق الاعتماد ؛ إذا كان كل شيء على ما يرام ، فإنه يسترد بيانات المستخدم ، وينشئ JWT يحتوي على تفاصيل المستخدم والأذونات التي سيتم استخدامها للوصول إلى الخدمات ، كما أنه يحدد انتهاء الصلاحية على JWT (والذي قد يكون غير محدود).
- يقوم موفر الهوية بالتوقيع ، وإذا لزم الأمر ، يقوم بتشفير JWT وإرساله إلى العميل كاستجابة للطلب الأولي ببيانات الاعتماد.
- يقوم العميل بتخزين JWT لفترة زمنية محدودة أو غير محدودة ، اعتمادًا على انتهاء الصلاحية الذي يحدده موفر الهوية.
- يرسل العميل JWT المخزنة في عنوان التخويل لكل طلب إلى مزود الخدمة.
- لكل طلب ، يأخذ مزود الخدمة JWT من رأس
Authorization
ويفك تشفيره ، إذا لزم الأمر ، يتحقق من صحة التوقيع ، وإذا كان كل شيء على ما يرام ، يستخرج بيانات المستخدم والأذونات. بناءً على هذه البيانات فقط ، ومرة أخرى دون البحث عن مزيد من التفاصيل في قاعدة البيانات أو الاتصال بموفر الهوية ، يمكنه قبول طلب العميل أو رفضه. الشرط الوحيد هو أن يكون لدى موفري الهوية والخدمات اتفاق بشأن التشفير حتى تتمكن الخدمة من التحقق من التوقيع أو حتى فك تشفير الهوية التي تم تشفيرها.
يسمح هذا التدفق بمرونة كبيرة مع الحفاظ على الأشياء آمنة وسهلة التطوير. باستخدام هذا الأسلوب ، من السهل إضافة عقد خادم جديدة إلى مجموعة مزودي الخدمة ، وتهيئتها مع القدرة فقط على التحقق من التوقيع وفك تشفير الرموز من خلال تزويدهم بمفتاح سري مشترك. لا يلزم النسخ المتماثل للجلسة أو مزامنة قاعدة البيانات أو الاتصال بين العقد. الراحة في كامل مجدها.
يتمثل الاختلاف الرئيسي بين JWT والرموز التعسفية الأخرى في توحيد محتوى الرمز المميز. هناك طريقة أخرى موصى بها وهي إرسال رمز JWT المميز في رأس Authorization
باستخدام مخطط الحامل. يجب أن يبدو محتوى العنوان كما يلي:
Authorization: Bearer <token>
تنفيذ أمان REST
لكي تعمل خدمات REST كما هو متوقع ، نحتاج إلى نهج ترخيص مختلف قليلاً مقارنةً بالمواقع الكلاسيكية متعددة الصفحات.
بدلاً من بدء عملية المصادقة عن طريق إعادة التوجيه إلى صفحة تسجيل الدخول عندما يطلب العميل موردًا مؤمنًا ، يقوم خادم REST بمصادقة جميع الطلبات باستخدام البيانات المتاحة في الطلب نفسه ، رمز JWT في هذه الحالة. إذا فشلت مثل هذه المصادقة ، فإن إعادة التوجيه لا معنى لها. ترسل واجهة برمجة تطبيقات REST ببساطة استجابة رمز HTTP 401 (غير مصرح به) ويجب أن يعرف العملاء ما يجب عليهم فعله ؛ على سبيل المثال ، سيعرض المتصفح عنصر div ديناميكي للسماح للمستخدم بتوفير اسم المستخدم وكلمة المرور.
من ناحية أخرى ، بعد مصادقة ناجحة في مواقع الويب الكلاسيكية متعددة الصفحات ، تتم إعادة توجيه المستخدم باستخدام رمز HTTP 301 (تم نقله بشكل دائم) ، عادةً إلى صفحة رئيسية أو ، حتى أفضل ، إلى الصفحة التي طلب المستخدم تشغيلها في البداية عملية المصادقة. مع REST ، مرة أخرى هذا لا معنى له. بدلاً من ذلك ، سنستمر ببساطة في تنفيذ الطلب كما لو لم يتم تأمين المورد على الإطلاق ، وإرجاع رمز HTTP 200 (موافق) ونص الاستجابة المتوقعة.
مثال أمان الربيع
الآن ، دعنا نرى كيف يمكننا تنفيذ واجهة برمجة تطبيقات REST القائمة على رمز JWT باستخدام Java و Spring ، أثناء محاولة إعادة استخدام السلوك الافتراضي Spring Security حيثما أمكننا ذلك.
كما هو متوقع ، يأتي إطار عمل Spring Security مزودًا بالعديد من فئات المكونات الإضافية الجاهزة التي تتعامل مع آليات التفويض "القديمة": ملفات تعريف الارتباط للجلسة و HTTP Basic و HTTP Digest. ومع ذلك ، فإنه يفتقر إلى الدعم المحلي لـ JWT ، ونحن بحاجة إلى جعل أيدينا متسخة لإنجاحها. للحصول على نظرة عامة أكثر تفصيلاً ، يجب عليك الرجوع إلى وثائق Spring Security الرسمية.
الآن ، لنبدأ بتعريف مرشح Spring Security المعتاد في web.xml
:
<filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
لاحظ أن اسم مرشح Spring Security يجب أن يكون بالضبط springSecurityFilterChain
لبقية تكوين Spring للعمل خارج الصندوق.
يأتي بعد ذلك إعلان XML عن حبوب الربيع المتعلق بالأمان. لتبسيط XML ، سنقوم بتعيين مساحة الاسم الافتراضية security
عن طريق إضافة xmlns="http://www.springframework.org/schema/security"
إلى عنصر XML الأساسي. يبدو باقي XML كما يلي:
<global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
- (1) في هذا السطر ، نقوم بتنشيط
@PreFilter
،@PreAuthorize
، @@PostFilter
، @@PostAuthorize
التعليقات التوضيحية على أي فاصوليا ربيعية في السياق. - (2) نحدد نقاط نهاية تسجيل الدخول والتسجيل لتخطي الأمان ؛ حتى "مجهول" يجب أن يكون قادرًا على القيام بهاتين العمليتين.
- (3) بعد ذلك ، نحدد سلسلة التصفية المطبقة على جميع الطلبات أثناء إضافة تهيئتين مهمتين: مرجع نقطة الدخول وتعيين إنشاء الجلسة إلى
stateless
الحالة (لا نريد إنشاء الجلسة لأغراض أمنية لأننا نستخدم الرموز المميزة لكل طلب) . - (4) لا نحتاج إلى حماية
csrf
لأن الرموز المميزة لدينا محصنة ضدها. - (5) بعد ذلك ، نقوم بتوصيل مرشح المصادقة الخاص بنا داخل سلسلة مرشح Spring المحددة مسبقًا ، قبل مرشح تسجيل الدخول إلى النموذج مباشرةً.
- (6) هذه الحبة هي إعلان مرشح المصادقة الخاص بنا ؛ نظرًا لأنه يوسع مرشح Spring's
AbstractAuthenticationProcessingFilter
، فإننا نحتاج إلى الإعلان عنه في XML لربط خصائصه (لا يعمل السلك التلقائي هنا). سنشرح لاحقًا ما يفعله المرشح. - (7) معالج النجاح الافتراضي لـ
AbstractAuthenticationProcessingFilter
ليس جيدًا بما يكفي لأغراض REST لأنه يعيد توجيه المستخدم إلى صفحة نجاح ؛ هذا هو السبب في أننا وضعنا منطقتنا هنا. - (8) يتم استخدام إعلان الموفر الذي تم إنشاؤه بواسطة مدير
authenticationManager
بواسطة عامل التصفية الخاص بنا لمصادقة المستخدمين.
الآن دعنا نرى كيف نقوم بتنفيذ الفئات المحددة المذكورة في XML أعلاه. لاحظ أن الربيع سوف يربطهم لنا. نبدأ بأبسطها.

RestAuthenticationEntryPoint.java
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }
كما هو موضح أعلاه ، تقوم هذه الفئة فقط بإرجاع رمز HTTP 401 (غير مصرح به) عند فشل المصادقة ، مما يؤدي إلى تجاوز إعادة توجيه Spring الافتراضية.
JwtAuthenticationSuccessHandler.java
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }
يزيل هذا التجاوز البسيط السلوك الافتراضي للمصادقة الناجحة (إعادة التوجيه إلى الصفحة الرئيسية أو أي صفحة أخرى يطلبها المستخدم). إذا كنت تتساءل لماذا لا نحتاج إلى تجاوز AuthenticationFailureHandler
، فذلك لأن التنفيذ الافتراضي لن يعيد التوجيه إلى أي مكان إذا لم يتم تعيين عنوان URL الخاص بإعادة التوجيه ، لذلك نتجنب فقط تعيين عنوان URL ، وهو أمر جيد بما فيه الكفاية.
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }
هذه الفئة هي نقطة الدخول إلى عملية مصادقة JWT لدينا ؛ يستخرج المرشح رمز JWT من رؤوس الطلب ويفوض المصادقة إلى AuthenticationManager
المحقون. إذا لم يتم العثور على الرمز المميز ، فسيتم طرح استثناء يوقف الطلب من المعالجة. نحتاج أيضًا إلى تجاوز للمصادقة الناجحة لأن تدفق الربيع الافتراضي سيوقف سلسلة المرشح ويمضي في إعادة التوجيه. ضع في اعتبارك أننا بحاجة إلى تنفيذ السلسلة بالكامل ، بما في ذلك إنشاء الاستجابة ، كما هو موضح أعلاه.
JwtAuthenticationProvider.java
public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }
في هذه الفئة ، نستخدم AuthenticationManager
الافتراضي من Spring ، لكننا نقوم بحقنه مع AuthenticationProvider
الخاص بنا الذي يقوم بعملية المصادقة الفعلية. لتنفيذ ذلك ، قمنا بتوسيع AbstractUserDetailsAuthenticationProvider
، والذي يتطلب منا فقط إرجاع UserDetails
بناءً على طلب المصادقة ، في حالتنا ، رمز JWT الملفوف في فئة JwtAuthenticationToken
. إذا كان الرمز المميز غير صالح ، فإننا نطرح استثناءً. ومع ذلك ، إذا كان صحيحًا وكان فك التشفير بواسطة JwtUtil
ناجحًا ، فإننا نستخرج تفاصيل المستخدم (سنرى بالضبط كيف في صنف JwtUtil
) ، دون الوصول إلى قاعدة البيانات على الإطلاق. جميع المعلومات المتعلقة بالمستخدم ، بما في ذلك أدواره ، موجودة في الرمز المميز نفسه.
JwtUtil.java
public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }
أخيرًا ، تكون فئة JwtUtil
مسؤولة عن تحليل الرمز المميز في كائن User
وإنشاء الرمز المميز من كائن User
. إنه أمر واضح ومباشر لأنه يستخدم مكتبة jjwt
للقيام بكل أعمال JWT. في مثالنا ، نقوم ببساطة بتخزين اسم المستخدم ومعرف المستخدم وأدوار المستخدم في الرمز المميز. يمكننا أيضًا تخزين المزيد من العناصر التعسفية وإضافة المزيد من ميزات الأمان ، مثل انتهاء صلاحية الرمز المميز. يتم استخدام تحليل الرمز المميز في AuthenticationProvider
كما هو موضح أعلاه. يتم استدعاء طريقة generateToken()
من خدمات REST لتسجيل الدخول والتسجيل ، وهي غير مؤمنة ولن تؤدي إلى أي فحوصات أمنية أو تتطلب وجود رمز مميز في الطلب. في النهاية ، يُنشئ الرمز المميز الذي سيتم إرجاعه إلى العملاء ، بناءً على المستخدم.
خاتمة
على الرغم من أن أساليب الأمان القديمة الموحدة (ملف تعريف ارتباط الجلسة و HTTP Basic و HTTP Digest) ستعمل مع خدمات REST أيضًا ، إلا أنها جميعًا تواجه مشكلات سيكون من الجيد تجنبها باستخدام معيار أفضل. تصل JWT في الوقت المناسب تمامًا لإنقاذ الموقف ، والأهم من ذلك أنها قريبة جدًا من أن تصبح معيارًا لـ IETF.
تتمثل القوة الرئيسية لـ JWT في التعامل مع مصادقة المستخدم بطريقة عديمة الحالة ، وبالتالي قابلة للتطوير ، مع الحفاظ على كل شيء آمنًا مع معايير التشفير الحديثة. يؤدي تخزين المطالبات (أدوار المستخدم والأذونات) في الرمز المميز نفسه إلى إنشاء مزايا ضخمة في هياكل النظام الموزعة حيث لا يتمتع الخادم الذي يصدر الطلب بإمكانية الوصول إلى مصدر بيانات المصادقة.