Single Sign-On с поставщиком VK

В этой статье рассмотрим Single Sign-On (SSO) с помощью поставщика VKontakte (но также тут есть вход с помощью GitHub и Google). Приложение представляет собой Spring Boot OAuth 2 клиент c Ajax-запросами на JQuery.
В отличие от Google и Github, которые настроены в Spring заранее, VK не входит пакет. Кроме того, запросы и ответы VK, участвующие в OAuth 2 немного отличаются от стандартных, поэтому потребуется прописать пару пользовательских бинов.

Что в VK отличается от стандарта по умолчанию

Сlient Сredentials  передаются в теле запроса

VK требует, чтобы в запросе Access-токена Сlient Сredentials передавались не в Basic Auth в заголовке Authorization (как у большинства провайдеров), а в теле запроса.  Но перенести Сlient Сredentials в тело запроса просто — настройкой в application.properties:

spring.security.oauth2.client.registration.vk.client-authentication-method=post

То есть поведение по умолчанию было таким:

Будет не так

А будет другим — Client Credentials уйдут в тело запроса.

В ответе отсутствует token_type: Bearer

Запрос изменили, но с ответом тоже не все ладно. В ответном JSON отсутствует пункт «token_type»:»Bearer»:

{
   "access_token":"ed0f46118dec0a5e6935ca1986004cb6a1e92631c95eebffb4f184b3856f206e88eec68993e7d19162ac3",
   "expires_in":86400,
   "user_id":1111111,
   "email":"1111222@mail.ru"
}

А Spring ожидает, что token_type должен быть непустым и выбрасывает исключение. Поэтому ниже мы модифицируем ответ с помощью CustomTokenResponseConverter.

После успешного получения Access Token-а по протоколу OpenID идет обращение за данными авторизовавшегося пользователя. Это конечная точка Userinfo провайдера. Тут ответ тоже нестандартный.

VK возвращает по адресу Userinfo ответ в обертке

Обычно в ответе сразу перечисляются атрибуты пользователя, но не в VK. Здесь ответ приходит обернутым в response и еще в массив:

{
    "response": [
        {
            "first_name": "Иван",
            "id": 1005684,
            "last_name": "Петров",
            "photo_max": "https://sun1.is74.userapi.com/s/v1/if2/SM08JDYA4scNcgqgQ5rqoAPsRu-R7CO_k_BneLs6AWDO1mhFVLiwhgtgmgVkh-uvEHXDUrWJ5eiI8FisARAdOj.jpg?size=200x0&quality=96&crop=741,68,736,736&ava=1"
        }
    ]
}

Мы исправим это с помощью пользовательского DefaultOAuth2UserService.

Maven-зависимости

Наше приложение представляет из себя OAuth 2 клиент:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-client</artifactId>
</dependency>

Кроме того, понадобится:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

Контроллер

Пусть будет контроллер, который по /user выдает имя аутентифицированного пользователя, а по /secret — данные, доступные только аутентифицированному пользователю:

@RestController
public class SomeController {

    @RequestMapping("/user")
    public Map<String, Object> user(@AuthenticationPrincipal OAuth2User principal) {
        return Collections.singletonMap("name", principal.getName());
    }

    @RequestMapping("/secret")
    public String secret() {
        return "protected data";
    }
}

Причем /user запрашивается с помощью JavaScript с главной страницы (см. в коде index.html).

WebConfig

Настроим доступ к страницам. Главная страница и статика доступны, остальные url — только для аутентифицированных пользователей. Причем аутентификация осуществляется с помощью OpenID, на это указывает строка .oauth2Login().

На ней и можно было бы поставить точку (точнее, точку с запятой), если бы в логине мог участвовать только GitHub и Google. Но из-за VK мы прописываем  пользовательский OAuth2AccessTokenResponseClient для добавления Bearer Token и СustomOAuth2UserService для преобразования ответа с конечной точки Userinfo.

@Configuration
public class WebConfig extends WebSecurityConfigurerAdapter {

    ..

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
                .authorizeRequests(a -> a
                        .antMatchers("/", "/error", "/webjars/**").permitAll()
                        .anyRequest().authenticated()
                )
                .exceptionHandling(e -> e
                        .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                )
                .csrf(c -> c
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                )
                .logout(l -> l
                        .logoutSuccessUrl("/").permitAll()
                )
                .oauth2Login()
                //Access token Endpoint
                .tokenEndpoint()
                .accessTokenResponseClient(accessTokenResponseClient())
                //Userinfo endpoint
                .and()
                .userInfoEndpoint()
                .userService(customOAuth2UserService);
        // @formatter:on
    }
  ...
}

Вышеуказанные пользовательские классы рассмотрим чуть ниже, а пока настройки GitHub, Google и VK в файле application.yml.

Настройки GitHub, Google и VK в файле application.yml

Здесь client-id и client-secret нужно получить у соответствующего провайдера. Для этого нужно зарегистрировать у провайдера приложение и вставить сюда сгенерированные коды. В VK приложения создаются тут.

spring:
  thymeleaf:
    cache: false
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: your-client-id
            client-secret: your-secret
          google:
            client-id: your-client-id
            client-secret: your-secret
          vk:
            client-id: your-client-id
            client-secret: your-secret
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            #use post, as vk needs clientId and clientSecret as request params and does not accepts Basic auth
            client-authentication-method: post
            authorization-grant-type: authorization_code
            scope: email
        provider:
          vk:
            #revoke=1 needs to always show vk dialog
            authorization-uri: https://oauth.vk.com/authorize?revoke=1
            token-uri: https://oauth.vk.com/access_token
            user-info-uri: https://api.vk.com/method/users.get?v=5.52&fields=photo_max
            user-name-attribute: first_name
          google:
            user-name-attribute: name
          github:
            user-name-attribute: login

Про эту настройку уже упоминалось в начале статьи:

spring.security.oauth2.client.registration.vk.client-authentication-method=post

Она переносит Client Credentials в тело запроса.

Выбор имени аутентифицированного пользователя

Обратите внимание на user-name-attribute — это название возвращаемого с Userinfo атрибута пользователя, который превратится в:

principal.getName()

и будет выведен на странице как имя пользователя. Можно посмотреть ответы от каждого провайдера и выбрать подходящий атрибут.

Это имя мы возвращаем в контроллере выше.

Модификация Access Token Response

В настройках WebConfig мы прописали свой accessTokenResponseClient(), вот он:

@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
    DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient =
            new DefaultAuthorizationCodeTokenResponseClient();

    OAuth2AccessTokenResponseHttpMessageConverter tokenResponseHttpMessageConverter =
            new OAuth2AccessTokenResponseHttpMessageConverter();

    tokenResponseHttpMessageConverter.setTokenResponseConverter(new CustomTokenResponseConverter());

    RestTemplate restTemplate = new RestTemplate(Arrays.asList(
            new FormHttpMessageConverter(), tokenResponseHttpMessageConverter));

    restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

    accessTokenResponseClient.setRestOperations(restTemplate);
    return accessTokenResponseClient;
}

Тут для restTemplate указаны два конвертера: FormHttpMessageConverter и OAuth2AccessTokenResponseHttpMessageConverter. И второй использует под капотом наш пользовательский конвертер CustomTokenResponseConverter:

public class CustomTokenResponseConverter implements Converter<Map<String, String>, OAuth2AccessTokenResponse> {

    @Override
    public OAuth2AccessTokenResponse convert(Map<String, String> tokenResponseParameters) {
        String accessToken = tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN);

        OAuth2AccessToken.TokenType accessTokenType = OAuth2AccessToken.TokenType.BEARER;

        Map<String, Object> additionalParameters = new HashMap<>();

        tokenResponseParameters.forEach((s, s2) -> {
            additionalParameters.put(s, s2);
        });

        return OAuth2AccessTokenResponse.withToken(accessToken)
                .tokenType(accessTokenType)
                .additionalParameters(additionalParameters)
                .build();
    }
}

Он прописывает «token_type»:»Bearer» для ответов с любого провайдера. Хотя во всех ответах, кроме с VK, «token_type»:»Bearer» и так есть, это не мешает.

Модификация Userinfo Response

Наконец, модифицируем ответ с конечной точки Userinfo. Как было сказано, он возвращается в обертке (см. выше), а нужно, чтобы сразу шли атрибуты, как с Google или Github.

GitHub Userinfo response:

{
    "login": "myluckagain",
    "id": 28870472,
    "node_id": "MDQ6VXNlcjI4ODcwNDcy",
    "avatar_url": "https://avatars.githubusercontent.com/u/28870472?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/myluckagain",
    "html_url": "https://github.com/myluckagain",
    ...
    "repos_url": "https://api.github.com/users/myluckagain/repos",
    "events_url": "https://api.github.com/users/myluckagain/events{/privacy}",
    "received_events_url": "https://api.github.com/users/myluckagain/received_events",
    "type": "User",
    "site_admin": false,
    "name": "sys",
    "company": null,
...
}
У каждого поставщика свой набор атрибутов, настройка user-name-attribute отвечает за то, какой атрибут будет именем OAuth2User, как было сказано выше.

VK Userinfo response:

{
    "response": [
        {
            "first_name": "Иван",
            "id": 1005684,
            "last_name": "Петров",
            "photo_max": "https://sun1.is74.userapi.com/s/v1/if2/SM08JDYA4scNcgqgQ5rqoAPsRu-R7CO_k_BneLs6AWDO1mhFVLiwhgtgmgVkh-uvEHXDUrWJ5eiI8FisARAdOj.jpg?size=200x0&quality=96&crop=741,68,736,736&ava=1"
        }
    ]
}

Итак, для модификации ответа напишем свой CustomOAuth2UserService.

Вообще можно и не писать CustomOAuth2UserService, а вместо этого в качестве значения user-name-attribute прописать единственный доступный атрибут response. Но тогда результат principal.getName() будет странным — весь JSON, находящийся внутри response.

Метод loadUser() собственно и возвращает ответ — в виде OAuth2User.

Выглядит пользовательский класс страшно, но на самом деле это всего лишь копия DefaultOAuth2UserService с блоком if в начале метода:

  • если провайдер не VK, то делегируем работу родителю super.loadUser(userRequest), чтобы все шло как обычно;
  • если провайдер VK, то делаем то же самое, а в конце извлекаем атрибуты из обертки «response» и из массива.

Класс CustomOAuth2UserService:

@Component
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private RestOperations restOperations;
    private Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();
    private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

    private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

    private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

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

    public CustomOAuth2UserService() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        this.restOperations = restTemplate;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {


        OAuth2User oAuth2User;

        if (!userRequest.getClientRegistration().getRegistrationId().equals("vk")) {
            oAuth2User = super.loadUser(userRequest);
            return oAuth2User;
        }

        Assert.notNull(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 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: " +
                            userRequest.getClientRegistration().getRegistrationId(),
                    null
            );
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);

        ResponseEntity<Map<String, Object>> response;
        try {
            response = this.restOperations.exchange(request, 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);
        }
        //извлекаем атрибуты из обертки "response"
        ArrayList valueList = (ArrayList) response.getBody().get("response");
        Map<String, Object> userAttributes = (Map<String, Object>) valueList.get(0);
        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        authorities.add(new OAuth2UserAuthority(userAttributes));
        OAuth2AccessToken token = userRequest.getAccessToken();
        for (String authority : token.getScopes()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
        }

        return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);

    }

}

Теперь VK работает, как и остальные провайдеры.

До логина
После логина

Защищенная страница тоже открывается для аутентифицированного пользователя:

Защищенная страница

Итоги

Мы настроили SSO с провайдером VK. Исходный код примера есть на GitHub.

 

 

Single Sign-On с поставщиком VK: 5 комментариев

  1. После нажатия на «Разрешить» в окне запроса доступа к аккаунту, выскакивает ошибка (Whitelabel page, код ошибки — 500).
    В консоли пишет, что ошибка «NullPointerException». ArrayList = null.
    Не могу понять в чем причина

  2. Ребята, если вы, как и я целый день будете ломать голову — почему не работает представленное решение — вот вам шорткат — в пропертях (apllication.yaml) вместо
    client-authentication-method: post
    прописываем
    client-authentication-method: client_secret_post

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *