استخدام Spring Boot لـ OAuth2 و JWT REST Protection
نشرت: 2022-03-11هذه المقالة عبارة عن دليل حول كيفية إعداد تنفيذ من جانب الخادم لـ JSON Web Token (JWT) - إطار ترخيص OAuth2 باستخدام Spring Boot و Maven.
يوصى بفهم أولي لـ OAuth2 ويمكن الحصول عليه من قراءة المسودة المرتبطة أعلاه أو البحث عن معلومات مفيدة على الويب مثل هذا أو هذا.
OAuth2 هو إطار عمل تفويض يحل محل الإصدار الأول من OAuth ، والذي تم إنشاؤه مرة أخرى في عام 2006. وهو يحدد تدفقات التفويض بين العملاء وواحدة أو أكثر من خدمات HTTP من أجل الوصول إلى الموارد المحمية.
يحدد OAuth2 الأدوار التالية من جانب الخادم:
- مالك المورد: الخدمة المسؤولة عن التحكم في وصول الموارد
- خادم المورد: الخدمة التي توفر الموارد بالفعل
- خادم التفويض: عملية تفويض التعامل مع الخدمة تعمل كوسيط بين العميل ومالك المورد
JSON Web Token ، أو JWT ، هو مواصفات لتمثيل المطالبات التي سيتم نقلها بين طرفين. يتم ترميز المطالبات ككائن JSON يستخدم كحمولة لهيكل مشفر ، مما يتيح توقيع المطالبات رقميًا أو تشفيرها.
يمكن أن تكون البنية المحتوية على JSON Web Signature (JWS) أو JSON Web Encryption (JWE).
يمكن اختيار JWT كتنسيق للوصول والتحديث الرموز المميزة المستخدمة داخل بروتوكول OAuth2.
اكتسبت OAuth2 و JWT شعبية كبيرة خلال السنوات الماضية بسبب الميزات التالية:
- يوفر نظام ترخيص عديم الحالة لبروتوكول REST عديم الحالة
- يتلاءم بشكل جيد مع بنية الخدمة المصغرة حيث يمكن لخوادم الموارد المتعددة مشاركة خادم ترخيص واحد
- محتوى الرمز سهل الإدارة من جانب العميل بسبب تنسيق JSON
ومع ذلك ، فإن OAuth2 و JWT ليسا دائمًا الخيار الأفضل في حالة أهمية الاعتبارات التالية للمشروع:
- لا يسمح بروتوكول عديم الحالة بإلغاء الوصول من جانب الخادم
- يضيف العمر الثابت للرمز المميز تعقيدًا إضافيًا لإدارة الجلسات الطويلة دون المساس بالأمان (على سبيل المثال ، رمز التحديث)
- شرط وجود متجر آمن لرمز من جانب العميل
تدفق البروتوكول المتوقع
في حين أن إحدى الميزات الرئيسية لـ OAuth2 هي تقديم طبقة ترخيص لفصل عملية التفويض عن مالكي الموارد ، من أجل البساطة ، فإن نتيجة المقالة هي إنشاء تطبيق واحد ينتحل صفة كل مالك الموارد وخادم التفويض و أدوار خادم الموارد . لهذا السبب ، سوف يتدفق الاتصال بين كيانين فقط ، الخادم والعميل.
يجب أن يساعد هذا التبسيط في التركيز على هدف المقالة ، أي إعداد مثل هذا النظام في بيئة Spring Boot.
يتم وصف التدفق المبسط أدناه:
- يتم إرسال طلب التفويض من العميل إلى الخادم (بصفته مالك المورد) باستخدام منح الإذن بكلمة المرور
- يتم إرجاع رمز الوصول إلى العميل (مع رمز التحديث)
- ثم يتم إرسال رمز الوصول من العميل إلى الخادم (يعمل كخادم موارد) عند كل طلب للوصول إلى الموارد المحمية
- يستجيب الخادم بالموارد المحمية المطلوبة
الربيع الأمن وحذاء الربيع
بادئ ذي بدء ، مقدمة موجزة عن مجموعة التكنولوجيا المختارة لهذا المشروع.
أداة إدارة المشروع المفضلة هي Maven ، ولكن نظرًا لبساطة المشروع ، لا ينبغي أن يكون من الصعب التبديل إلى أدوات أخرى مثل Gradle.
في استمرار المقال ، نركز على جوانب Spring Security فقط ، ولكن جميع مقتطفات التعليمات البرمجية مأخوذة من تطبيق يعمل بشكل كامل من جانب الخادم والذي يتوفر كود المصدر في مستودع عام إلى جانب العميل الذي يستهلك موارد REST الخاصة به.
Spring Security هو إطار عمل يوفر خدمات أمان تصريحية تقريبًا للتطبيقات المستندة إلى Spring. تعود جذورها إلى بداية الربيع وهي منظمة كمجموعة من الوحدات نظرًا للعدد الكبير من تقنيات الأمان المختلفة المغطاة.
دعنا نلقي نظرة سريعة على هندسة Spring Security (يمكن العثور على دليل أكثر تفصيلاً هنا).
يتعلق الأمن في الغالب بالمصادقة ، أي التحقق من الهوية والترخيص ومنح حقوق الوصول إلى الموارد.
يدعم أمان Spring مجموعة كبيرة من نماذج المصادقة ، إما مقدمة من جهات خارجية أو تم تنفيذها محليًا. يمكن العثور على قائمة هنا.
فيما يتعلق بالإذن ، تم تحديد ثلاثة مجالات رئيسية:
- الويب يطلب الإذن
- أسلوب الإذن على مستوى
- الوصول إلى تخويل مثيلات كائن المجال
المصادقة
الواجهة الأساسية هي AuthenticationManager
وهي المسؤولة عن توفير طريقة المصادقة. UserDetailsService
هي واجهة مرتبطة بجمع معلومات المستخدم ، والتي يمكن تنفيذها بشكل مباشر أو استخدامها داخليًا في حالة طرق JDBC أو LDAP القياسية.
تفويض
الواجهة الرئيسية هي AccessDecisionManager
؛ التي تطبيقات لجميع المجالات الثلاثة المذكورة أعلاه تفوض إلى سلسلة من AccessDecisionVoter
. يمثل كل مثيل للواجهة الأخيرة ارتباطًا بين Authentication
(هوية مستخدم ، اسم أساسي) ، ومورد ومجموعة من ConfigAttribute
، مجموعة القواعد التي تصف كيف سمح مالك المورد بالوصول إلى المورد نفسه ، ربما من خلال استخدام أدوار المستخدم.
يتم تنفيذ تأمين تطبيق الويب باستخدام العناصر الأساسية الموضحة أعلاه في سلسلة من عوامل تصفية servlet ، ويتم عرض فئة WebSecurityConfigurerAdapter
للتعبير عن قواعد الوصول للمورد.
يتم تمكين أمان الطريقة أولاً من خلال وجود التعليق التوضيحي @EnableGlobalMethodSecurity(securedEnabled = true)
، ثم باستخدام مجموعة من التعليقات التوضيحية المتخصصة لتطبيقها على كل طريقة تكون محمية مثل @PreAuthorize
و @Secured
و @PostAuthorize
.
يضيف Spring Boot إلى كل هذا مجموعة من تكوينات التطبيق العاقدة ومكتبات الطرف الثالث من أجل تسهيل التطوير مع الحفاظ على معايير الجودة العالية.
JWT OAuth2 مع حذاء الربيع
دعنا الآن ننتقل إلى المشكلة الأصلية لإعداد تطبيق يطبق OAuth2 و JWT مع Spring Boot.
بينما توجد مكتبات OAuth2 متعددة من جانب الخادم في عالم Java (يمكن العثور على قائمة هنا) ، فإن التنفيذ القائم على الربيع هو الخيار الطبيعي حيث نتوقع أن نجده مدمجًا جيدًا في بنية Spring Security وبالتالي تجنب الحاجة إلى التعامل مع الكثير من التفاصيل منخفضة المستوى لاستخدامه.
يتم التعامل مع جميع تبعيات المكتبة المتعلقة بالأمان بواسطة Maven بمساعدة Spring Boot ، وهو المكون الوحيد الذي يتطلب إصدارًا صريحًا داخل ملف تكوين maven pom.xml (على سبيل المثال ، يتم استنتاج إصدارات المكتبة تلقائيًا بواسطة Maven باختيار أحدثها إصدار متوافق مع إصدار Spring Boot المُدرج).
ابحث أدناه عن مقتطف من ملف تكوين maven pom.xml الذي يحتوي على التبعيات المتعلقة بأمان Spring Boot:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>
يعمل التطبيق كخادم تفويض / مالك مورد OAuth2 وكخادم موارد.
يتم نشر الموارد المحمية (كخادم موارد) ضمن / api / path ، بينما يتم تعيين مسار المصادقة (كمالك المورد / خادم التخويل) على / oauth / token ، باتباع الإعداد الافتراضي المقترح.
هيكل التطبيق:
- حزمة
security
التي تحتوي على تكوين الأمان - حزمة
errors
التي تحتوي على معالجة الأخطاء -
users
، حزمglee
لموارد REST ، بما في ذلك النموذج والمستودع ووحدة التحكم
تغطي الفقرات التالية التكوين لكل واحد من أدوار OAuth2 الثلاثة المذكورة أعلاه. الفئات ذات الصلة موجودة داخل حزمة security
:
-
OAuthConfiguration
، تمديدAuthorizationServerConfigurerAdapter
-
ResourceServerConfiguration
، تمديدResourceServerConfigurerAdapter
-
ServerSecurityConfig
، توسيعWebSecurityConfigurerAdapter
-
UserService
، تنفيذUserDetailsService
الإعداد لمالك المورد وخادم التخويل
يتم تمكين سلوك خادم التخويل من خلال وجود التعليق التوضيحي @EnableAuthorizationServer
. يتم دمج التكوين الخاص به مع التكوين المتعلق بسلوك مالك المورد وكلاهما مضمن في فئة AuthorizationServerConfigurerAdapter
.
التكوينات المطبقة هنا تتعلق بـ:
- وصول العميل (باستخدام
ClientDetailsServiceConfigurer
)- تحديد استخدام التخزين في الذاكرة أو التخزين المستند إلى JDBC للحصول على تفاصيل العميل باستخدام أساليب
inMemory
أوjdbc
- المصادقة الأساسية للعميل باستخدام
clientId
وclientSecret
(المشفرة باستخدام وحدةPasswordEncoder
المختارة) - وقت صلاحية الوصول وتحديث الرموز المميزة باستخدام
accessTokenValiditySeconds
وrefreshTokenValiditySeconds
- أنواع المنح المسموح بها باستخدام سمة
authorizedGrantTypes
- يحدد نطاقات الوصول بطريقة
scopes
- تحديد موارد العميل التي يمكن الوصول إليها
- تحديد استخدام التخزين في الذاكرة أو التخزين المستند إلى JDBC للحصول على تفاصيل العميل باستخدام أساليب
- نقطة نهاية خادم التخويل (باستخدام
AuthorizationServerEndpointsConfigurer
)- تحديد استخدام رمز JWT مع
accessTokenConverter
- تحديد استخدام
UserDetailsService
وAuthenticationManager
لإجراء المصادقة (كمالك المورد)
- تحديد استخدام رمز JWT مع
package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }
يصف القسم التالي التكوين المطلوب تطبيقه على خادم الموارد.

الإعداد لخادم الموارد
يتم تمكين سلوك خادم المورد باستخدام التعليق التوضيحي @EnableResourceServer
ويتم تضمين التكوين الخاص به في فئة ResourceServerConfiguration
.
التكوين المطلوب الوحيد هنا هو تعريف تعريف المورد من أجل مطابقة وصول العميل المحدد في الفئة السابقة.
package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }
يدور عنصر التكوين الأخير حول تعريف أمان تطبيق الويب.
إعداد أمان الويب
يتم تضمين تكوين أمان الويب الربيعي في فئة ServerSecurityConfig
، والتي يتم تمكينها باستخدام التعليق التوضيحي @EnableWebSecurity
. يسمح @EnableGlobalMethodSecurity
بتحديد الأمان على مستوى الأسلوب. تم تعيين السمة proxyTargetClass
من أجل جعل هذا يعمل مع أساليب RestController
، لأن وحدات التحكم عادة ما تكون فئات ، ولا تنفذ أي واجهات.
تحدد ما يلي:
- موفر المصادقة المراد استخدامه ، وتحديد مزود
authenticationProvider
الفول - أداة ترميز كلمة المرور المراد استخدامها ، وتحديد وحدة تشفير
passwordEncoder
- مدير المصادقة فول
- تكوين الأمان للمسارات المنشورة باستخدام
HttpSecurity
- استخدام
AuthenticationEntryPoint
مخصص لمعالجة رسائل الخطأ خارج معالج أخطاء Spring REST القياسيResponseEntityExceptionHandler
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }
يتعلق استخراج الرمز أدناه بتنفيذ واجهة UserDetailsService
لتوفير مصادقة مالك المورد.
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }
القسم التالي حول وصف تنفيذ وحدة تحكم REST لمعرفة كيفية تعيين قيود الأمان.
REST تحكم
داخل وحدة التحكم REST ، يمكننا إيجاد طريقتين لتطبيق التحكم في الوصول لكل طريقة مورد:
- استخدام مثيل
OAuth2Authentication
تم تمريره بواسطة Spring كمعامل - استخدام التعليقات التوضيحية
@PreAuthorize
أو@PostAuthorize
package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }
خاتمة
يسمح Spring Security و Spring Boot بإعداد خادم مصادقة / مصادقة OAuth2 كامل بسرعة بطريقة تقريبية تقريبًا. يمكن تقصير الإعداد بشكل أكبر عن طريق تكوين خصائص عميل OAuth2 مباشرة من ملف application.properties/yml
، كما هو موضح في هذا البرنامج التعليمي.
جميع الكود المصدري متاح في مستودع GitHub هذا: spring-glee-o-meter. يمكن العثور على عميل Angular الذي يستهلك الموارد المنشورة في مستودع GitHub هذا: glee-o-meter.