ScribeJava — даже ваша бабушка сможет работать с OAuth
Именно этой фразой нас приветствует библиотека для работы с OAuth — ScribeJava (https://github.com/scribejava/scribejava). Если быть точнее, то фраза звучит так: «Who said OAuth/OAuth2 was difficult? Configuring ScribeJava is so easy your grandma can do it! check it out:».
И это действительно похоже на правду:
OAuth20Service service = new ServiceBuilder().apiKey(clientId).apiSecret(clientSecret)
.callback("http://your.site.com/callback").grantType("authorization_code").build(HHApi.instance());
String authorizationUrl = service.getAuthorizationUrl();
OAuth2AccessToken accessToken = service.getAccessToken(code);
Готово! Этих трех строчек достаточно, чтобы начать делать OAuth запросы. А сам OAuth запрос можно будет сделать так:
OAuthRequest request = new OAuthRequest(Verb.GET, "https://api.hh.ru/me", service);
service.signRequest(accessToken, request);
String response = request.send().getBody();
Данные о пользователе у нас в руках (в переменной response). И ни капли понимания, как в деталях работает OAuth. Хотим асинхронные http-запросы? Нам хватит тех же трех строчек. Ниже рассмотрим это на примере.
1. OAuth? О чем это?
Большинству сайтов в том или ином виде нужна регистрация пользователей: для комментариев, отслеживания заказов, откликов на вакансии — не важно.
При этом в интернете люди обычно демонстрируют ленивое поведение и не любят заполнять регистрационные формы, особенно если они это уже где-то сделали. На помощь таким сайтам приходит OAuth. Однако статья посвящена не самому протоколу OAuth, поэтому мы поговорим о том, как работать с OAuth, не вдаваясь в подробности и механизм его работы.
Если говорить в двух словах, то OAuth создан для того, чтобы давать авторизацию стороннему серверу (сайту) на получение каких-либо данных с другого ресурса (например, соц.сети). Т.е., например, пользователь ВКонтакте с помощью OAuth может дать право какому-нибудь сайту (например, hh.ru) запросить его данные или сделать от его лица какие-либо действия в сети ВКонтакте. Нужно отметить, что OAuth не создан для идентификации пользователя. Однако, в числе прочего, мы почти всегда можем запросить данные «о себе», таким образом получив id пользователя и идентифицировав его.
Если пошагово попробовать описать OAuth, то получится примерно так (на примере OAuth2 — он попроще).
- Мы регистрируем свое приложение на стороннем сайте, получаем client_id и client_secret — это делается один раз.
- Когда к нам приходит пользователь, наш сайт формирует ссылку на сторонний сайт, на котором мы хотим получить авторизацию на получение данных о пользователе. В ссылке обязательно фигурирует client_id нашего приложения на этом сайте. Далее наш сайт дает пользователю эту ссылку.
- Пользователь идет по ссылке на сторонний сайт, логинится (если еще не залогинен), одобряет запрашиваемые нами права (например, получение его ФИО) и возвращается к нам с дополнительным GET-параметром «code».
- Наш сайт напрямую (сервер-сервер) отсылает полученный GET параметр на сторонний сайт и в ответ получает токен (access_token).
- Мы делаем запросы на получение данных или совершение каких-либо активностей от имени пользователя, и в каждый запрос добавляем этот access_token.
2. Даже ваша бабушка сможет использовать OAuth
Попробуем разобрать подробнее пример из начала статьи, сделав OAuth запрос на hh.ru. Для этого нам потребуется создать OAuthService, используя билдер ServiceBuilder. Эта строчка кода будет выглядеть так:
OAuth20Service service = new ServiceBuilder()
.apiKey(clientId)
.apiSecret(clientSecret)
.callback("http://your.site.com/callback")
.grantType("authorization_code")
.build(HHApi.instance());
Вам нужно будет только подставить clientId и clientSecret вашего приложения, которое вы можете получить, зарегистрировав новое приложение на https://dev.hh.ru. Также нужно будет указать callback url, куда будет перенаправлен пользователь с нужным нам кодом (code).
String authorizationUrl = service.getAuthorizationUrl();
Отправляем пользователя на этот адрес. В нашем случае он будет выглядеть примерно так:
hh.ru/oauth/authorize? response_type=code&client_id=UHKBSA…&redirect_uri=https%3A%2F%2Fhhid.ru%2Foauth2%2Fcode
Если открыть этот адрес в браузере, пользователь увидит форму логина, затем форму выдачи прав приложению. Если он уже залогинен и/или давал права приложению ранее, то его сразу же перенаправит на указанный нами callback. В нашем случае такой:
hhid.ru/oauth2/code? code=I2R6O5…
Вот этот GET параметр «code» нам и нужен. Меняем его на токен:
String code = "I2R6O5...";
OAuth2AccessToken accessToken = service.getAccessToken(code);
На этом все! У нас есть токен (OAuth2AccessToken accessToken), если вывести его в консоль, то увидим внутренности:
OAuth2AccessToken {
access_token=I55KQQ...,
token_type=bearer,
expires_in=1209599,
refresh_token=PGELQV...,
scope=null}
Теперь попробуем получить какие-нибудь данные. Создаем запрос:
OAuthRequest request = new OAuthRequest(Verb.GET, "https://api.hh.ru/me", service);
Подписываем запрос токеном:
service.signRequest(accessToken, request);
Отсылаем запрос на hh.ru:
Response response = request.send();
Выводим результат:
System.out.println(response.getCode());
System.out.println(response.getBody());
Профит! В консоли мы увидим нечто подобное:
200
{"first_name": "Стас", "last_name": "Громов", "middle_name": null, "is_in_search": null, "is_anonymous": false, "resumes_url": null, "is_employer": false, "personal_manager": null, "email": "s.gromov@hh.ru", "manager": null, ...}
И нам не пришлось изучать, какие параметры нужно передавать, как это делать, как их шифровать, в каком порядке передавать, и много других нюансов как самого протокола OAuth, так и ее конкретной имплементации у HeadHunter.
ps. Полный код запускаемого примера можно увидеть здесь:
https://github.com/scribejava/scribejava/blob/master/scribejava-apis/src/test/java/com/github/scribejava/apis/examples/HHExample.java
Библиотека релизится в maven central, так что подключить ее к проекту будет очень просто:
com.github.scribejava
scribejava-apis
2.3.0
Если же у вас очень жесткие требования по размеру конечного приложения, то можно взять только core часть, без сборника различных API:
com.github.scribejava
scribejava-core
2.3.0
3.scribe-java → SubScribe → ScribeJava или как сделать форк и вернуть долг opensource сообществу
Мы получили данные о пользователе, которые можно использовать как для регистрации нового пользователя, так и для процесса аутентификации старого. Это может понадобиться не только новым сайтам, которым лень заморачиваться с регистрацией, но и старым, уже матерым, таким как hh.ru. Именно с этими мыслями мы и вошли в 2013-й год.
Нужно отметить, что, несмотря на существование относительно неплохих спецификаций OAuth протокола, как это обычно водится, каждый сервер изобретал и изобретает свои модификации. Где-то вольно интерпретируя стандарты, где-то пользуясь свободой, предоставляемой спецификациями, а где-то идя против них ради каких-то своих идей.
На hh.ru мы хотели добавить входы сразу через несколько различных соцсетей, а писать код, работающий с каждой из них, естественно, хотелось по минимуму. Наверняка кто-то уже все написал! Как минимум, список адресов, на которые нужно посылать запросы (а по факту и еще кучка мелких нюансов). Кроме того, хотелось бы по минимуму поддерживать написанный код, и, в случае необходимости, когда, например, соцсеть решит поменять урл, на который нужно идти за токеном, просто обновить версию библиотеки.
Мы изучили существовавшие на тот момент варианты, и так вышло, что самой простой в использовании и одновременно с самой большой базой АПИ, с которыми библиотека работала бы из коробки, оказалась scribe-java от https://github.com/fernandezpablo85/. На тот момент она имела несколько, но не все АПИ, которые мы хотели. Ну не страшно, можно дописать недостающие и отдать их в общее пользование.
С этого мы и начали. Но написав первый наш PullRequest на гитхабе, мы узнали, что автор «устал» уже от таких Пулл Реквестов и ответил нам заготовленной статьей на вики о том, что новые API он добавлять не будет ;-(По мнению автора, scribe-java должна была быть маленькой простой библиотекой (возможно без OAuth2 вовсе, оставив только первую версию протокола), а нам хотелось от нее иметь все же истинно «библиотечные» свойства, сборник всех адресов различных АПИ. Ну и это не страшно, если чем-то какая-то библиотека не устраивает, всегда можно сделать форк! Так и появился на свет проект SubScribe. С заголовком из пяти пунктов, которые обозначали основные причины создания нашего форка:
Main reasons of fork here:
1.https://github.com/fernandezpablo85/scribe-java/wiki/Scribe-scope-revised
2.We really think, OAuth2.0 should be here;
3.We really think, async http should be here for a high-load projects;
4.We really think, all APIs should be here. With all their specific stuff. It’s easier to change/fix/add API here, in this lib, one time, instead of N programmers will do the same things on their sides;
5. Scribe should be multi-maven-module project. Core and APIs should be deployed as separated artifacts.
© https://github.com/hhru/subscribe/blob/a8450ec2ed35ecaa64ef03afc1bd077ce14d8d61/README.md
Помимо описанного раннее, из причин создания форка можно выделить еще несколько. Будучи одним из самых нагруженных сайтов рунета и джоб-сайтов Европы, нам очень хотелось иметь возможность асинхронной работы. И это никак не входило в планы простой оригинальной библиотеки. Также мы решили исключить «страх» автора, что библиотека станет громоздкой из-за обилия специфичных каким-то отдельным конкретным АПИ фич. Мы разделили проект на два модуля. После некоторых телодвижений, 3-его марта 2014-го года первая версия SubScribe (сразу 2.0) появилась в центральном репозитории Мавена http://central.maven.org/maven2/ru/hh/oauth/subscribe/subscribe/2.0/. Где проект и просуществовал до версии 3.4, выпущенной 30-го июня 2015-го года. За это время, набрав немного уже своей собственной популярности и новых фич, новых АПИ, он не забывает бекпортить все вкусности из родителя scribe-java.
Так бы все и оставалось, если бы в начале осени 2015 Pablo Fernandez (https://github.com/fernandezpablo85), видимо, окончательно устав от своего детища, не наткнулся бы на наш форк. Пабло сказал, что впечатлен им и видит многое, что хотел бы сделать сам, но не дошли руки, и предложил проработать детали передачи проекта полностью нам. Немного помявшись для приличия, мы приняли предложение, и так появился ScribeJava — по сути переименованный обратно форк SubScribe. С этого момента у библиотеки появилась отдельная организация на github.com — https://github.com/scribejava.
На данный момент ScribeJava представляет собой open source проект под крылом hh.ru. Входит в перечень клиентских библиотек на java на главной странице официального сайт протокола OAuth2: http://oauth.net/2/. Имеет 280 наблюдателей, 3 106 звездочек и 1 220 форков на github.com.
4. Добавим асинхронности и обновим токен на примере работы с Google
Если у вас сильно нагруженный сайт и вы хотите сэкономить потоков и/или просто использовать ning http client, то мы можем попросить ScribeJava использовать асинхронный вариант работы. Для этого нужно, чтобы в вашем classpath присутствовал ning http client
com.ning
async-http-client
1.9.32
В этот раз будем использовать Асинхронный билдер сервиса ServiceBuilderAsync.
OAuth20Service service = new ServiceBuilderAsync()
.apiKey(clientId)
.apiSecret(clientSecret)
.scope("profile") // replace with desired scope
.state("secret" + new Random().nextInt(999_999))
.callback("https://hhid.ru/oauth2/code")
.asyncHttpClientConfig(clientConfig)
.build(GoogleApi20.instance());
Единственное отличие здесь — вызов метода asyncHttpClientConfig (clientConfig), в который мы должны отдать конфиг для асинхронного ning http клиента. Для примера, пусть он будет таким:
AsyncHttpClientConfig clientConfig = new AsyncHttpClientConfig.Builder()
.setMaxConnections(5)
.setRequestTimeout(10_000)
.setAllowPoolingConnections(false)
.setPooledConnectionIdleTimeout(1_000)
.setReadTimeout(1_000)
.build();
Так же Google требует передачи переменной state. Она необходима для защиты от CRSF атаки, но это за пределами интереса нашей статьи. Дальнейшая работа ничем не отличается от примера работы с api.hh.ru, рассмотренного в начале. Отсылаем пользователя по адресу:
String authorizationUrl = service.getAuthorizationUrl();
А к каждому методу с HTTP походом внутри добавляем постфикс «Async». Т.е. вместо метода getAccessToken будем вызывать метод getAccessTokenAsync.
Future accessTokenFuture = service.getAccessTokenAsync("code", null);
В ответ мы получим Future (асинхронность ведь). Или же можем опционально передать вторым аргументом Callback, как нам удобнее.
Готово! Просто, не правда ли? Теперь можно отсылать асинхронные запросы (OAuthRequestAsync) в гугл от лица пользователя:
OAuth2AccessToken accessToken = accessTokenFuture.get();
OAuthRequestAsync request = new OAuthRequestAsync(Verb.GET, "https://www.googleapis.com/plus/v1/people/me", service);
service.signRequest(accessToken, request);
Response response = request.sendAsync(null).get();
System.out.println(response.getCode());
System.out.println(response.getBody());
У полученного OAuthRequestAsync мы вызвали метод sendAsync, который по аналогии опционально ожидает Callback и возвращает нам Future. При этом у нас остается возможность слать синхронные запросы паралельно с асинхронными. Если же мы хотим как-то профорсить асинхронность (или синхронность) запросов, можно попросить ScribeJava сделать это через статический «конфигуратор»:
ScribeJavaConfig.setForceTypeOfHttpRequests(ForceTypeOfHttpRequest.FORCE_ASYNC_ONLY_HTTP_REQUESTS);
В этом случае, при попытке использовать синхронный вариант ScribeJava, мы будем получать Exception. Возможны и другие варианты, например, не выкидывать Exception, но логировать о каждом таком случае. Или наоборот требовать исключительно синхронной работы.
Рассмотрим здесь еще один полезный момент OAuth — refresh_token. Дело в том, что получаемый нами access_token имеет ограниченый срок жизни. И когда он протухает, нам необходимо получить новый токен. Здесь есть два варианта: либо дождаться пользователя и еще раз провести его через весь механизм, либо использовать refresh_token (его поддерживают не все, но Google, на примере которого мы его попробуем, поддерживает). Итак, для получения свежего access_token все, что нам нужно, это всего лишь:
OAuth2AccessToken refreshedAccessToken accessToken = service.refreshAccessToken(accessToken.getRefreshToken());
или для асинхронного варианта:
Future refreshedAccessTokenFuture = service.refreshAccessTokenAsync(accessToken.getRefreshToken(), null);
Стоит отметить, что в случае с Google refresh_token, который нужно передать в метод refreshAccessToken, не придет, если его специально не попросить. Для этого нужно при формировании адреса, на который пойдет пользователь добавить пару параметров:
//передать access_type=offline чтобы получить refresh_token
//https://developers.google.com/identity/protocols/OAuth2WebServer#preparing-to-start-the-oauth-20-flow
Map additionalParams = new HashMap<>();
additionalParams.put("access_type", "offline");
//Google отдаст refresh_token только на первый offline запрос, если нужно еще раз, нужно явно это попросить параметром prompt
additionalParams.put("prompt", "consent");
String authorizationUrl = service.getAuthorizationUrl(additionalParams);
ps. Этот и другие примеры в запускаемом виде (со статическим методом main) тут: https://github.com/scribejava/scribejava/tree/master/scribejava-apis/src/test/java/com/github/scribejava/apis/examples
5. Полезные ссылки
1.ScribeJava на github.com https://github.com/scribejava/scribejava
2.документация api.hh.ru https://github.com/hhru/api
3.документация Google https://developers.google.com/identity/protocols/OAuth2WebServer
4.RFC OAuth2 http://tools.ietf.org/html/rfc6749
5.javadoc online http://www.javadoc.io/doc/com.github.scribejava/scribejava-core
Post Scriptum
Комментарии очень приветствуются. Особенно в виде Пулл Реквестов на github.com