Spring Security - مثال لتطبيق ويب لتفويض OAuth2 من خلال BitBucket

في هذه المقالة ، سننظر في طريقة لتفويض المستخدمين في تطبيقات الويب على Spring Boot باستخدام بروتوكول OAuth2 باستخدام خادم تفويض خارجي (باستخدام مثال Bitbucket).

ما الذي نريد الحصول عليه


لنفترض أننا نقوم بتطوير تطبيق ويب آمن يمكنه الوصول إلى موارد المستخدم على خادم خارجي ، على سبيل المثال ، نظام تكامل مستمر ، ويعمل Bitbucket كخادم خارجي ، ولا نريد تخزين اسم المستخدم وكلمة المرور للمستخدم من النظام الخارجي. أي أننا بحاجة إلى تفويض المستخدم من خلال Bitbucket من أجل الوصول إلى حسابه وموارده ، بالإضافة إلى التحقق من أنه مستخدم لتطبيقنا وجعله حتى لا يكشف لنا بيانات اعتماده من Bitbucket. أتساءل كيف نفعل ذلك - مرحبا بك في القط.

OAuth2


OAuth2 هو بروتوكول تفويض يسمح لك بتزويد طرف ثالث بوصول محدود إلى الموارد المحمية للمستخدم دون الحاجة إلى منحها (طرف ثالث) اسم مستخدم وكلمة مرور.

يحدد OAuth2 4 أدوار:

  • مالك المورد
  • خادم الموارد
  • خادم التفويض
  • العميل (التطبيق)

مالك المورد هو مستخدم يستخدم تطبيق عميل ويسمح له بالوصول إلى حسابه المستضاف على خادم الموارد. وصول التطبيق إلى الحساب مقيد بالأذونات الممنوحة.

على الخادم الموارد المضيفين تأمين حسابات المستخدمين.
يقوم خادم المصادقة بمصادقة مالك المورد ويصدر رموز الدخول. يمكن أن يكون خادم التفويض في الوقت نفسه خادم موارد.

تطبيق العميل هو تطبيق يريد الوصول إلى حساب المستخدم وموارده.

في حالتنا ، فإن تطبيق العميل هو التطبيق الذي نقوم بتطويره ، وسيكون Bitbucket خادم ترخيص وخادم موارد.

يدعم OAuth2 أربعة أنواع من التفويض: رمز التفويض ، ضمنيًا ، بيانات اعتماد كلمة مرور مالك المورد ، وبيانات اعتماد العميل. لن نعتبرهم جميعًا ، نحن مهتمون بنوع كود التفويض. تم تحسين نوع رمز التفويض لتطبيقات الخادم حيث لا يتوفر رمز المصدر للجمهور ويمكن الاحتفاظ بسرية Client Secret. يعمل هذا النوع على أساس إعادة التوجيه ، أي أنه سيتم إعادة توجيه المستخدم إلى خادم التفويض لتأكيد هويته والسماح للتطبيق باستخدام حسابه.

تتكون عملية التفويض من خلال رمز التفويض من سلسلة من طلبين:

  • طلب تفويض
  • طلب رمز الدخول

يتم استخدام طلب التفويض لتأكيد هوية المستخدم ، وكذلك طلب تفويض لتطبيقنا من المستخدم. هذا الطلب هو طلب GET بالمعلمات التالية:

  • response_type - يجب أن تكون القيمة مساوية للرمز
  • client_id - القيمة التي تم الحصول عليها عند تسجيل عميل مع موفر OAuth2
  • redirect_uri - عنوان URL حيث سيتم إعادة توجيه المستخدم بعد التفويض
  • نطاق - معلمة اختيارية تشير إلى مستوى الوصول المطلوب
  • الحالة - سلسلة يتم إنشاؤها عشوائيًا للتحقق من الاستجابة

مثال على الطلب:

GET https://server.example.com/authorize?response_type=code&client_id=CLIENT_ID&state=xyz&redirect_uri=REDIRECT_URI

إذا أكد المستخدم هويته وسمح للتطبيق بالوصول إلى موارده ، فسيتم إعادة توجيه وكيل المستخدم إلى عنوان URL لرد الاتصال المحدد أثناء تسجيل العميل برمز المعلمة الإضافية التي تحتوي على رمز التفويض ومعلمة الحالة مع القيمة التي تم تمريرها في الطلب.

مثال رد الاتصال:

GET https://client.example.com/cb?code=AUTH_CODE_HERE&state=xyz

يتم استخدام طلب رمز الوصول لتبادل رمز التفويض المستلم لرمز الوصول إلى موارد المستخدم. هذا الطلب هو طلب POST مع المعلمات التالية:

  • نوع المنحة - يجب أن تكون القيمة رمز التفويض
  • كود - كود التفويض تم الحصول عليه في الخطوة السابقة
  • redirect_uri - يجب أن يتطابق مع عنوان URL المحدد في الخطوة السابقة
  • client_id - القيمة التي تم الحصول عليها عند تسجيل عميل مع موفر OAuth2
  • client_secret - القيمة التي تم الحصول عليها عند تسجيل عميل لدى موفر OAuth2

مثال على الطلب:

POST https://server.example.com/token
    grant_type=authorization_code&
    code=AUTH_CODE&
    redirect_uri=REDIRECT_URI&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET

تحتوي استجابة الخادم على رمز الوصول وعمره:

{
    "access_token": "2YotnFZFEjr1zCsicMWpAA",
    "expires_in": 3600,
    "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA"
}

هذه العملية برمتها مؤتمتة بالفعل في Spring Security ولا داعي للقلق بشأن تنفيذها.

التسجيل العملاء


بادئ ذي بدء ، سوف نسجل طلبنا كعميل في Bitbucket للحصول على مفتاح (مفتاح) ورمز وصول (سر العميل).



أدخل اسم العميل وعنوان URL الخاص بالاتصال. ثم نلاحظ ما سيكون متاحًا لهذا العميل.



يتم تخزين قيم المفتاح و ClientSecret التي تم الحصول عليها في application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

تكوين أمان الربيع


بعد ذلك ، لنقم بإعداد Spring Security. لكي يعمل OAuth2 ، يجب إنشاء كائن ClientRegistration. يخزن ClientRegistration معلومات حول العميل المسجل لدى موفر OAuth2. هنا نحتاج إلى client_id و client_secret التي تم الحصول عليها في الخطوة السابقة. نظرًا لأنه يمكن أن يكون هناك العديد من كائنات ClientRegistration بشكل عام ، يستخدم Spring Security كائن ClientRegistrationRepository لتخزينها والوصول إليها. لنقم بإنشائه أيضًا. نشير أيضًا إلى أن المستخدم المصرح له فقط يمكنه الاتصال بأي طلب وإعادة تعريف UserService بتطبيقه.

SecurityConfig.java
@Configuration
public class SecurityConfig {

    @Value("${client_id}")
    private String clientId;

    @Value("${client_secret}")
    private String clientSecret;

    @Bean
    public ClientRegistration clientRegistration() {
        return ClientRegistration
                .withRegistrationId("bitbucket")
                .clientId(clientId)
                .clientSecret(clientSecret)
                .userNameAttributeName("username")
                .clientAuthenticationMethod(BASIC)
                .authorizationGrantType(AUTHORIZATION_CODE)
                .userInfoUri("https://api.bitbucket.org/2.0/user")
                .tokenUri("https://bitbucket.org/site/oauth2/access_token")
                .authorizationUri("https://bitbucket.org/site/oauth2/authorize")
                .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
                .build();
    }

    @Bean
    @Autowired
    public MyOAuth2UserService oAuth2userService(UserService userService) {
        return new MyOAuth2UserService(userService);
    }

    @Bean
    @Autowired
    public ClientRegistrationRepository clientRegistrationRepository(List<ClientRegistration> registrations) {
        return new InMemoryClientRegistrationRepository(registrations);
    }

    @Configuration
    @EnableWebSecurity
    public static class AuthConfig extends WebSecurityConfigurerAdapter {

        private final MyOAuth2UserService userService;

        @Autowired
        public AuthConfig(MyOAuth2UserService userService) {
            this.userService = userService;
        }

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests(authorizeRequests -> authorizeRequests
                            .anyRequest().authenticated()
                    )
                    .oauth2Login(oauth2Login -> oauth2Login
                            .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService))
                    );
        }
    }
}


التخصيص UserService


لا يقوم Spring Security بتنفيذ عملية التفويض بالكامل فحسب ، بل يوفر أيضًا القدرة على تخصيصها. على سبيل المثال ، إمكانية تخصيص طلب التفويض ، وطلب رمز الوصول ، بالإضافة إلى إمكانية المعالجة اللاحقة المخصصة لاستجابة لطلب رمز الوصول . بعد المصادقة الناجحة ، يستخدم Spring Security UserInfo Endpoint لاسترداد سمات المستخدم من خادم المصادقة. على وجه الخصوص ، يتم استخدام تطبيق واجهة OAuth2UserService لهذا الغرض.

سنقوم بإنشاء تطبيقنا الخاص لهذه الخدمة ، بحيث بعد التحقق من المستخدم على خادم الترخيص ، نتحقق أيضًا من أنه مستخدم لتطبيقنا ، أو نسجلها إذا كان التسجيل مفتوحًا للجميع. بشكل افتراضي ، يستخدم Spring Security تطبيق DefaultOAuth2UserService. سيشكل أساس تنفيذنا.

MyOAuth2UserService.java
public class MyOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

    private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

    private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

    private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<Map<String, Object>>() {
    };

    private final UserService userService;

    private final RestOperations restOperations;

    private final Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();

    public MyOAuth2UserService(UserService userService) {
        this.userService = requireNonNull(userService);
        this.restOperations = createRestTemplate();
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        checkNotNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        if (!StringUtils.hasText(userNameAttributeName)) {
            OAuth2Error oauth2Error = new OAuth2Error(
                    MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
                    "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + registrationId, null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        ResponseEntity<Map<String, Object>> response;
        try {
            // OAuth2UserRequestEntityConverter cannot return null values.
            //noinspection ConstantConditions
            response = this.restOperations.exchange(requestEntityConverter.convert(userRequest), PARAMETERIZED_RESPONSE_TYPE);
        } catch (OAuth2AuthorizationException ex) {
            OAuth2Error oauth2Error = ex.getError();
            StringBuilder errorDetails = new StringBuilder();
            errorDetails.append("Error details: [");
            errorDetails.append("UserInfo Uri: ").append(
                    userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
            errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
            if (oauth2Error.getDescription() != null) {
                errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
            }
            errorDetails.append("]");
            oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        } catch (RestClientException ex) {
            OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + ex.getMessage(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        }

        Map<String, Object> userAttributes = emptyIfNull(response.getBody());

        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        authorities.add(new OAuth2UserAuthority(userAttributes));

        for (String authority : userRequest.getAccessToken().getScopes()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
        }

        //     ,   
        //          ,
        //    
        User user = findOrCreate(userAttributes);
        userAttributes.put(MyOAuth2User.ID_ATTR, user.getId());
        return new MyOAuth2User(userNameAttributeName, userAttributes, authorities);
    }

    private User findOrCreate(Map<String, Object> userAttributes) {
        String login = (String) userAttributes.get("username");
        String username = (String) userAttributes.get("display_name");

        Optional<User> userOpt = userService.findByLogin(login);
        if (!userOpt.isPresent()) {
            User user = new User();
            user.setLogin(login);
            user.setName(username);
            return userService.create(user);
        }
        return userOpt.get();
    }

    private RestTemplate createRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        return restTemplate;
    }
}


اختبار نقطة النهاية


حان الوقت لإنشاء نقطة نهاية لاختبار صحة ما قمنا به للتو. ستتألف نقطة النهاية لدينا من طلب واحد فقط ، سيرحب بالمستخدم الحالي.

مرحبًا بك في Endpoint.java
@Path("/")
public class WelcomeEndpoint {

    @Autowired
    private UserService userService;

    @GET
    public String welcome() {
        User currentUser = getCurrentUser();
        return String.format("Welcome, %s! (user id: %s, user login: %s)",
                currentUser.getName(), currentUser.getId(), currentUser.getLogin());
    }

    public User getCurrentUser() {
        return userService.findByLogin(getAuthenticatedUser().getName()).orElseThrow(() -> new RuntimeException("No user logged in."));
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    private MyOAuth2User getAuthenticatedUser() {
        return (MyOAuth2User) getAuthentication().getPrincipal();
    }
}


فحص طبي


أطلقنا التطبيق وانتقلنا إلى العنوان http: // localhost: 8080 ونرى أنه تمت إعادة توجيهنا إلى موقع Bitbucket لتأكيد حسابنا. أدخل اسم المستخدم وكلمة المرور.



نحتاج الآن إلى السماح لتطبيقنا بالوصول إلى حسابنا ومواردنا.



تحية المستخدم تحتوي على كل من السمات من خادم التفويض ومعرف المستخدم في تطبيقنا.



مصدر


كود المصدر الكامل لهذا التطبيق موجود على جيثب .

المراجع



All Articles