В этой статье рассмотрим 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, ... }
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.
Метод 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.
Спасибо
После нажатия на «Разрешить» в окне запроса доступа к аккаунту, выскакивает ошибка (Whitelabel page, код ошибки — 500).
В консоли пишет, что ошибка «NullPointerException». ArrayList = null.
Не могу понять в чем причина
там надо версию поменять в application.properties на новую: user-info-uri: https://api.vk.com/method/users.get?v=8.1&fields=photo_max
Потому что пишет, что versions before 8.1 are depricated.
Спасибо большое , все работает)
Ребята, если вы, как и я целый день будете ломать голову — почему не работает представленное решение — вот вам шорткат — в пропертях (apllication.yaml) вместо
client-authentication-method: post
прописываем
client-authentication-method: client_secret_post