В этой статье рассмотрим 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