Прицельный обзор: OAuth вне браузера через Scribe

| Комментарии

Scribe - простая java библиотека, позволяющая проводить авторизацию на сервисах по OAuth протоколу. Библиотека интересна тем, что рекомендуется twitter’ом и linkedin’ом для работы с их реализациями OAuth аутентификации.

Библиотека хостится на Github’е, а в качестве CI используется Travis-CI. Кто не знает, Travis-CI - распределенная система сборки проекта, тесно интегрирующаяся с Github репозиторием.

Build Tool - Maven. Проект реализован в одном модуле.

Проект ведет рядовой разработчик аутсорсинговой компании в южной америке, которая занимается некоторыми задачами разработки LinkedIn’а.

Библиотека удобно оборачивает OAuth протокол для авторизации из java приложения, предлагая весомый набор предустановленных OAuth провайдеров, таких как Twitter, Google, Yahoo. В комплекте идет пакет examples, где показано как работать с каждым из провайдеров.

Все начинается с создания OAuthService объекта:

1
2
3
4
5
OAuthService service = new ServiceBuilder()
       .provider(TwitterApi.class)
             .apiKey("6icbcAXyZx67r8uTAUM5Qw")
             .apiSecret("SCCAdUUc6LXxiazxH3N0QfpNUvlUy84mZ2XZKiv39s")
             .build();

Классический Builder-подход, где задается OAuthProvider и данные для аутентификации. Отдельный провайдер отвечает за отдельный сервис. В данном случае - это Twitter. Провайдер ответственен за создание OAuthService’а, который несет в себе специфичную для конкретного сервиса информацию - url, версия OAuth и т.д.

Разработчик грамотно организовал дерево классов. Все провайдеры унаследованы от интерфейса Api, в котором описана сигнатура единственного метода createService. На следующем уровне этот интерфейс расширяют абстрактные классы, специфичные для разных версий OAuth (реализованы обе версии). Конкретные провайдеры наследуют класс, реализующий интерфейс Api с привязкой к версии OAuth протокола и определяют внутри себя URL для доступа к сервису:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class FacebookApi extends DefaultApi20
{
  private static final String AUTHORIZE_URL = "https://www.facebook.com/dialog/oauth?client_id=%s&redirect_uri=%s";
  private static final String SCOPED_AUTHORIZE_URL = AUTHORIZE_URL + "&scope=%s";

  @Override
  public String getAccessTokenEndpoint()
  {
  return "https://graph.facebook.com/oauth/access_token";
  }

  @Override
  public String getAuthorizationUrl(OAuthConfig config)
  {
  Preconditions.checkValidUrl(config.getCallback(), "Must provide a valid url as callback. Facebook does not support OOB");

  // Append scope if present
  if(config.hasScope())
  {
  return String.format(SCOPED_AUTHORIZE_URL, config.getApiKey(), OAuthEncoder.encode(config.getCallback()), OAuthEncoder.encode(config.getScope()));
  }
  else
  {
      return String.format(AUTHORIZE_URL, config.getApiKey(), OAuthEncoder.encode(config.getCallback()));
  }
  }
}

Версия OAuth определяет реализацию OAuthService класса, тип запроса (GET, POST) и другие специфичные для версии вещи, например формат данных.

Внутри метода build вызывается метод createService провайдера. После получения OAuthService’а действуем согласно алгоритму работы по OAuth протоколу. Рассмотрим первую версию OAuth. Сначала получем Request Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Token requestToken = service.getRequestToken();

  public Token getRequestToken()
  {
  config.log("obtaining request token from " + api.getRequestTokenEndpoint());
  OAuthRequest request = new OAuthRequest(api.getRequestTokenVerb(), api.getRequestTokenEndpoint());

  config.log("setting oauth_callback to " + config.getCallback());
  request.addOAuthParameter(OAuthConstants.CALLBACK, config.getCallback());
  addOAuthParams(request, OAuthConstants.EMPTY_TOKEN);
  appendSignature(request);

  config.log("sending request...");
  Response response = request.send();
  String body = response.getBody();

  config.log("response status code: " + response.getCode());
  config.log("response body: " + body);
  return api.getRequestTokenExtractor().extract(body);
  }

Внутри метода формируется OAuth запрос, данные для которого берутся из Api объекта провайдера и этот запрос отправляется на сервер. Вся работа с сетью строится на базе стандартного пакета java.net. Таким образом при отправке запроса(request.send()) сначала открывается соединение:

1
2
3
4
5
6
7
8
9
private void createConnection() throws IOException
  {
  String completeUrl = getCompleteUrl();
  if (connection == null)
  {
      System.setProperty("http.keepAlive", connectionKeepAlive ? "true" : "false");
      connection = (HttpURLConnection) new URL(completeUrl).openConnection();
  }
  }

Далее добавляются header'ы в запрос и пишется тело сообщения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void addHeaders(HttpURLConnection conn)
  {
  for (String key : headers.keySet())
      conn.setRequestProperty(key, headers.get(key));
  }

  void addBody(HttpURLConnection conn, byte[] content) throws IOException
  {
  conn.setRequestProperty(CONTENT_LENGTH, String.valueOf(content.length));

  // Set default content type if none is set.
  if (conn.getRequestProperty(CONTENT_TYPE) == null)
  {
      conn.setRequestProperty(CONTENT_TYPE, DEFAULT_CONTENT_TYPE);
  }
  conn.setDoOutput(true);
  conn.getOutputStream().write(content);
  }

В результате возвращается объект ответа(Response), который в конструкторе принимает данное соединение:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Response(HttpURLConnection connection) throws IOException
  {
  try
  {
      connection.connect();
      code = connection.getResponseCode();
      headers = parseHeaders(connection);
      stream = isSuccessful() ? connection.getInputStream() : connection.getErrorStream();
  }
  catch (UnknownHostException e)
  {
      throw new OAuthException("The IP address of a host could not be determined.", e);
  }
  }

Как видно, только в момент инстацирования Response объекта произойдет подключение к удаленному ресурсу. Это ничто иное, как размывание логики приложения. Говоря об архитектурных агрехах, можно еще упоминуть следующий факт - объект запроса использует переменные на уровне класса, которые меняются внутри его публичных методов. В итоге, состояние объекта предсказать невозможно.

На выходе из метода создает объект Token, который заполняется данными ответа. Ответ парсится с помощью регулярных выражений. Даже JSON ответ обрабатывается регулярными выражениями. Объект парсера специфичен для конкретной версии OAuth и хранится в провайдере.

Далее создается объект Verifier, который в конструкторе принимает код подтверждения, выдаваемый сервисом:

1
Scanner in = new Scanner(System.in);

В рамках примера используется командная строка, куда необходимо ввести этот код. Далее вызывается запрос на получение Access Token’а:

1
Token accessToken = service.getAccessToken(requestToken, verifier);

Принцип работы аналогичен отправки запроса на Request Token. Отличие лишь в данных, которые пересылаются сервису, в url получателя и в парсере ответа, который строит объект Token’а:

1
2
3
4
5
6
7
8
9
10
11
12
13
public Token getAccessToken(Token requestToken, Verifier verifier)
  {
  config.log("obtaining access token from " + api.getAccessTokenEndpoint());
  OAuthRequest request = new OAuthRequest(api.getAccessTokenVerb(), api.getAccessTokenEndpoint());
  request.addOAuthParameter(OAuthConstants.TOKEN, requestToken.getToken());
  request.addOAuthParameter(OAuthConstants.VERIFIER, verifier.getValue());

  config.log("setting token to: " + requestToken + " and verifier to: " + verifier);
  addOAuthParams(request, requestToken);
  appendSignature(request);
  Response response = request.send();
  return api.getAccessTokenExtractor().extract(response.getBody());
  }

Далее можно осуществлять запросы к защищенному ресурсу сервиса с Token’ом доступа:

1
2
3
4
OAuthRequest request = new OAuthRequest(Verb.GET, PROTECTED_RESOURCE_URL);
service.signRequest(accessToken, request);
request.addHeader("GData-Version", "3.0");
Response response = request.send();

Где метод signRequest добавляет в header запроса токен:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void signRequest(Token token, OAuthRequest request)
  {
  config.log("signing request: " + request.getCompleteUrl());

  // Do not append the token if empty. This is for two legged OAuth calls.
  if (!token.isEmpty())
  {
      request.addOAuthParameter(OAuthConstants.TOKEN, token.getToken());
  }
  config.log("setting token to: " + token);
  addOAuthParams(request, token);
  appendSignature(request);
  }

Ну вот и все. Интересно было читать код Scribe. Конечно пришлось пару раз вспомнить заветы товарища Фаулера, но куда без этого?

Comments