Часто на сайтах вам могут предложить войти с помощью Google, Facebook или ВКонтакте. Если у вас есть аккаунт в одном из этих сервисов, вам не нужно будет регистрироваться с нуля: заполнять имя, почту и ставить свою фотографию — всё это будет сделано автоматически. Разберёмся, как это работает и насколько это безопасно.
Это история о технологии OAuth2.
В Яндекс можно войти через Гугл. Как тебе такое, юзернейм?
Для чего это нужно
Каждый сайт заинтересован в новых посетителях, потому что им можно потом продать платную подписку или показать рекламу. Поэтому сайтам выгодно, чтобы регистрация была как можно проще, в идеале — по нажатию одной кнопки (а то и вообще без регистрации). Если пользователи должны регистрироваться вручную и вводить все свои данные, есть шанс, что они отвалятся.
Параллельно с этим в интернете есть сервисы, которыми пользуются все: Яндекс, Гугл, фейсбук или Вконтакте. Почему бы не брать данные о пользователе с этих сервисов?
Для этого и придумали OAuth.
Регистрация приложения
Скорее всего Вы уже зарегистрированы в соц. сети ВКонтакте, если нет, то Вам придется это сделать. Указывать номер телефона при регистрации обязательно. Переживать не стоит — никакой смс-рассылки от этих сервисов не приходит. Далее необходимо создать приложение. (Скриншоты были сделаны в апреле 2015, с той поры интерфейс мог измениться)
После смс-подтверждения создания приложения оно, как можно догадаться, создаётся и Вы попадаете на страницу её редактирования. Всю информацию и иконки желателно добавить, но больше всего нас там интересуют id приложения и защищённый ключ— это и есть идентификационные данные нашего сайта:
Приложения ВКонтакте поддерживают работу с несколькими доменами, так что можно создать одно приложение сразу для всех Ваших сайтов.
Как работает единая авторизация
Для пользователя всё выглядит просто: нажал «Войти через Яндекс», подтвердил Яндексу своё желание войти на нужный сайт, и всё — вы уже зарегистрировались на новом сайте и можете им пользоваться. Но что происходит под капотом?
Когда посетитель, например, сайта о программировании, нажимает «Войти через Яндекс», этот сайт отправляет в Яндекс запрос и говорит: «Тут кто-то хочет войти на мой сайт через ваш сервис, можете разобраться?»:
Когда Яндекс получает такой запрос, ему нужно понять, что за посетитель пришёл на сайт и есть ли у него аккаунт Яндекса. Для этого он показывает всплывающее окно, где посетитель может войти в свой Яндекс-аккаунт. Это нужно, чтобы сервис понимал, на чьё имя выдавать пропуск для сайта. Если пользователь уже залогинен в Яндексе, его сразу узнают.
Как только посетитель вводит свой логин и пароль, Яндекс узнаёт его и спрашивает, доверяет ли он этому сайту о программировании и может ли Яндекс поделиться с сайтом данными о его имени и почте:
Дальше Яндекс отдаёт ваши данные сайту, он вас узнаёт, и готово:
Что и как
Для удобства создаём класс, в него пишем константы (скачать конечный результат можно ниже из раздела «дополнительно»):
class OAuthVK { const APP_ID = 1234567; //ID приложения const APP_SECRET = ‘sometestappsecret’; //Защищенный ключ const URL_CALLBACK = ‘https://example.com/oauth/vk.php’; //URL, на который произойдет перенаправление после авторизации }
Первым делом нужно авторизовать пользователя, поэтому нужно перенаправить его на сайт ВКонтакте. Добавляем метод в имеющийся класс:
class OAuthVK { … const URL_AUTHORIZE = ‘https://oauth.vk.com/authorize’; … public static function goToAuth() { Utils::redirect(self::URL_AUTHORIZE . ‘?client_id=’ . self::APP_ID . ‘&scope=offline’ . ‘&redirect_uri=’ . urlencode(self::URL_CALLBACK) . ‘&response_type=code’); } … }
В поле scope передаются запрашиваемые права доступа приложения. Для считывания основной информации достаточно offline. Но если нужно большее, то их можно перечислять через запятую:
scope=friends,video,offline
Небольшое пояснение: redirect() — это метод, реализующий перенаправление. Для удобства в этом примере он вынесен в класс Utils
class Utils { public static function redirect($uri = ») { header(«HTTP/1.1 301 Moved Permanently»); header(«Location: «.$uri, TRUE, 302); exit; } }
После авторизации и подтверждения доступа пользователь переадресовывается на адрес URL_CALLBACK?code=xxx. Теперь нужно по переданному нам коду взять токен и запросить данные пользователя. Если же пользователь отклонил запрос или возникла ошибка, то он перенаправится на адрес URL_CALLBACK с кодом и описанием ошибки, например URL_CALLBACK?error=invalid_request&error_description=Invalid+display+parameter.
То есть, логику можно разместить в эти условия:
if (!empty($_GET[‘error’])) { // Пришёл ответ с ошибкой. } elseif (empty($_GET[‘code’])) { // Самый первый запрос. Отправляем пользователя на авторизацию OAuthVK::goToAuth(); } else { // Ответ от ВК пришёл удачный // Запрос токена и данных пользователя}
Запросить токен:
class OAuthVK { … const URL_ACCESS_TOKEN = ‘https://oauth.vk.com/access_token’; private static $token; public static $userId; … public static function getToken($code) { $url = self::URL_ACCESS_TOKEN . ‘?client_id=’ . self::APP_ID . ‘&client_secret=’ . self::APP_SECRET . ‘&code=’ . $_GET[‘code’] . ‘&redirect_uri=’ . urlencode(self::URL_CALLBACK); if (!($res = @file_get_contents($url))) { return false; } $res = json_decode($res); if (empty($res->access_token) || empty($res->user_id)) { return false; } self::$token = $res->access_token; self::$userId = $res->user_id; return true; } … }
Объект $res при удачном запросе будет содержать такие поля:
stdClass Object ( [access_token] => xxx [expires_in] => 43200 [user_id] => xxx )
Expires_in — это время жизни токена в секундах, оно может понадобится только при длительных запросах на сервер, а по остальным ключам и так понятно что здесь что. Далее, если используете OAuth для полноценной регистрации, логично сделать проверку зарегистрирован ли на сайте кто-либо с таким user_id и при наличии оного авторизовать его и перекинуть на главную страницу сайта (или любую другую), исключив последующие действия.
Далее по токену и user_id остается запросить данные пользователя. Все запросы к API ВКонтакте выполняются по такому адресу: https://api.vk.com/method/METHOD_NAME?PARAMETERS&access_token=ACCESS_TOKEN
METHOD_NAME – название метода из списка функций API PARAMETERS – параметры соответствующего метода API ACCESS_TOKEN – ключ доступа, полученный в результате успешной авторизации приложения
Ответ приходит в формате JSON. Если же вы более привыкли в XML, то либо привыкайте к JSON, либо отсылайте запросы на такой адрес: https://api.vk.com/method/METHOD_NAME.xml?PARAMETERS&access_token=ACCESS_TOKEN
Запрос данных пользователей по их user_id осуществляется через метод getProfiles или его полный аналог users.get. При необходимости можно также отправлять параметр fields и получать дополнительные данные пользователя.
class OAuthVK { … const URL_GET_PROFILES = ‘https://api.vk.com/method/getProfiles’; public static $userData; … public static function getUser() { if (!self::$userId) { return false; } $url = self::URL_GET_PROFILES. ‘?uid=’ . self::$userId . ‘&access_token=’ . self::$token; if (!($res = @file_get_contents($url))) { return false; } $user = json_decode($res); if (!empty($user->error)) { self::printError($user->error); return false; } if (empty($user->response[0])) { return false; } $user = $user->response[0]; if (empty($user->uid) || empty($user->first_name) || empty($user->last_name)) { return false; } return self::$userData = $user; } … }
В нашем примере ответ от сервера приходит в таком виде (в зависимости от параметров количество полей может быть больше):
stdClass Object ( [response] => Array ( [0] => stdClass Object ( [uid] => 1 [first_name] => Павел [last_name] => Дуров ) ) )
Имя, фамилия и uid пользователя теперь известны нам, что делать с ними дальше — решайте сами. Я использую OAuth для полноценной регистрации и создаю с этими данными нового пользователя на сайте.
Насколько это безопасно
Каждый сайт, который использует OAuth, сам определяет, какие данные о пользователе они хотят увидеть. Например, одному сайту достаточно знать ваше имя и почту, а другому хочется скачать вашу фотографию и узнать дату рождения.
Когда вы будете входить через OAuth, сервис вам скажет: «Вот какие данные у меня запрашивают. Давать доступ?». Когда вы разрешите доступ, эти данные перейдут на сайт. Откажетесь — не перейдут.
✅ Сайты, которые используют OAuth, не смогут прочитать вашу почту или личные сообщения. Но есть и другие технологии — например приложения в социальных сетях, — и уже они могут делать гораздо больше.
✅ Через OAuth нельзя отправить сообщения от вашего имени или сделать пост в вашей ленте новостей. Но, опять же, если это не OAuth, а отдельное приложение для фейсбука или VK, то возможно и такое. Помните все эти игры, которые постят от имени игроков «Я собрал капусту на своей ферме»? Вот это они.
✅ Через OAuth точно не передаётся ваш пароль от Яндекса, Гугла и других сервисов. Сервисы хранят пароли в зашифрованном виде, поэтому даже при всём желании не смогли бы его передать.
Контроллер
Пусть будет контроллер, который по /user выдает имя аутентифицированного пользователя, а по /secret — данные, доступные только аутентифицированному пользователю:
@RestController public class SomeController { @RequestMapping(«/user») public Map user(@AuthenticationPrincipal OAuth2User principal) { return Collections.singletonMap(«name», principal.getName()); } @RequestMapping(«/secret») public String secret() { return «protected data»; } }
Причем /user запрашивается с помощью JavaScript с главной страницы (см. в коде index.html).
Модификация 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=200×0&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> 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 userAttributes = (Map) valueList.get(0); Set 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 работает, как и остальные провайдеры.
До логина
После логина
Защищенная страница тоже открывается для аутентифицированного пользователя:
Защищенная страница
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.
Создание приложения во ВКонтакте:
Перед тем как начать разрабатывать авторизацию через ВК, нужно зарегистрировать наше приложение или сайт, для этого заходим во вконтакт и с левого бока должен быть пункт «разработчикам».
Также можете перейти по этой ссылки.
Там же нажимаем сверху на вкладку мои приложения.
В этой вкладки выбираем приложение и нажимаем редактировать, если нет нужного нам или вообще нет не одного, то нажимаем на кнопку «Создать приложение».
Тут думаю всё понятно, единственное, для некоторых может быть не понятным, что такое базовый домен, тут просто нужно вписать доменное имя своего сайта, нажимаем «Подключить сайт».
После того как создали сайт, заходим в настройки этого сайта и там самое главное, это три пункта, «ID приложения», «Защищённый ключ» и «Доверенный redirect URI», последнее это поле, в него вы должны вписать где будет обрабатыватся авторизация.