Итак, цель данной статьи — показать, как работать с OAuth 2.0 на примере авторизации через Azure AD API. В итоге у нас получится полноценный модуль, выносящий максимально возможное количество кода из проекта, к которому он будет подключен.
В данной статье будут использованы библиотеки Retrofit, rxJava, retrolambda. Их использование обусловлено лишь моим желанием минимизировать бойлерплейт, и ничем больше. А потому сложностей по переводу на полностью ванильную сборку быть не должно.
Первое, что нам нужно будет сделать — осознать, что представляет собой протокол авторизации OAuth 2.0 (в данном случае будет использоваться исключительно code flow) и как это будет выглядеть применительно к нашей цели:
1. Если есть кэшированный токен, перепрыгиваем на пункт 4.
2. Инициализируем ‘WebView’, в котором откроем страницу авторизации нашего приложения.
3. После ввода данных пользователем и клика по Sign in, будет автоматический редирект на другую страницу, в query parameters которой имеется параметр code. Он то нам и нужен!
4. Обмениваем code на токен через POST запрос.
Теперь что это значит с точки зрения непосредственно разработчика?
Первое, что мы должны будем сделать — расписать в отдельных классах необходимые нам константы
public class Endpoints { public static final String OAUTH2_BASE_URL = "https://login.microsoftonline.com"; public static final String OAUTH2_ENDPOINT = "/oauth2"; public static final String OAUTH2_AUTHORIZATION_ENDPOINT = "/authorize"; public static final String OAUTH2_TOKEN_ENDPOINT = "/token"; public static final String OAUTH2_TENANT_PATH_FIELD = "/{tenant}"; }
public class QueryFields { public static final String QUERY_OAUTH2_CLIENT_ID = "client_id"; public static final String QUERY_OAUTH2_RESPONSE_TYPE = "response_type"; public static final String QUERY_OAUTH2_REDIRECT_URI = "redirect_uri"; public static final String QUERY_OAUTH2_RESOURCE = "resource"; }
public class RequestFields { public static final String OAUTH2_CLIENT_ID = "client_id"; public static final String OAUTH2_GRANT_TYPE = "grant_type"; public static final String OAUTH2_RESOURCE = "resource"; public static final String OAUTH2_CODE = "code"; public static final String OAUTH2_REDIRECT_URI = "redirect_uri"; public static final String OAUTH2_RAW_CODE_QUERY_FIELD = "?code"; public static final String OAUTH2_CODE_QUERY_FIELD = "code"; public static final String OAUTH2_RAW_QEURY_ERROR_FIELD = "error="; }
public class RequestFieldValues { public static final String TENANT_COMMON = "common"; public static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; }
public class ResponseFields { public static final String OAUTH2_TOKEN_TYPE = "token_type"; public static final String OAUTH2_TOKEN_EXPIRES_IN = "expires_in"; public static final String OAUTH2_TOKEN_SCOPE = "scope"; public static final String OAUTH2_TOKEN_EXPIRES_ON = "expires_on"; public static final String OAUTH2_TOKEN_NOT_BEFORE = "not_before"; public static final String OAUTH2_TOKEN_RESOURCE = "resource"; public static final String OAUTH2_TOKEN_ACCESS_TOKEN = "access_token"; public static final String OAUTH2_TOKEN_REFRESH_TOKEN = "refresh_token"; public static final String OAUTH2_TOKEN_ID_TOKEN = "id_token"; }
Назначим заодно параметры дефолтного OkHttp-клиента:
public class Const { public static int CONNECT_TIMEOUT = 15; public static int WRITE_TIMEOUT = 60; public static int TIMEOUT = 60; }
Теперь приступим к делу. По факту, наиболее важная часть нашей библиотеки будет состоять из двух файлов — интерфейс OAuth2, содержащий сигнатуры запросов и фабрику API, и OAuth2WebViewClient, который представляет собой кастомизированный под наши нужды WebViewClient.
Начнем по порядку.
Сигнатуры обращений для обмена code на token выглядят следующим образом:
@FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<Token>> tradeCodeForToken( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_CODE) String code, @Field(OAUTH2_REDIRECT_URI) String redirectUri );
@FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<Token>> refreshToken( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_TOKEN_REFRESH_TOKEN) String refreshToken, @Field(OAUTH2_REDIRECT_URI) String redirectUri );
Здесь первый метод — сигнатура запроса, описанного в пункте 4, а второй — рефреш токена, который будет периодически требоваться, так как токен сессии чаще всего валиден в течение часа.
Теперь приступим к созданию фабрики API. Итак, что она будет собой представлять? За время моей тесной дружбы с Retrofit-ом я пришел к данному варианту реализации сего механизма:
class Factory { public static OAuth2 buildOAuth2API(boolean enableDebug) { return buildRetrofit(OAUTH2_BASE_URL, enableDebug).create(OAuth2.class); } protected static Retrofit buildRetrofit(String baseUrl, boolean enableDebug) { return new Retrofit.Builder() .baseUrl(baseUrl) .addConverterFactory(GsonConverterFactory.create(new GsonBuilder().create())) .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .client(buildClient(enableDebug)) .build(); } protected static OkHttpClient buildClient(boolean enableDebug) { OkHttpClient.Builder builder = new OkHttpClient.Builder() .connectTimeout(Const.CONNECT_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(Const.WRITE_TIMEOUT, TimeUnit.SECONDS) .readTimeout(Const.TIMEOUT, TimeUnit.SECONDS); if(enableDebug) { builder.addInterceptor( new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) ); } return builder.build(); } }
Данный класс должен находиться в ранее описанном интерфейсе.
public interface OAuth2 { /** The request signature that returns a deserialized token */ @FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<Token>> tradeCodeForToken( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_CODE) String code, @Field(OAUTH2_REDIRECT_URI) String redirectUri ); /** The request signature that returns a raw json object instead of deserealized token */ @FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<JsonObject>> tradeCodeForTokenRaw( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_CODE) String code, @Field(OAUTH2_REDIRECT_URI) String redirectUri ); /** The request signature that allows refreshing token */ @FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<Token>> refreshToken( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_TOKEN_REFRESH_TOKEN) String refreshToken, @Field(OAUTH2_REDIRECT_URI) String redirectUri ); /** The request signature that allows refreshing token and returns a raw json instead of deserialized token */ @FormUrlEncoded @POST(OAUTH2_TENANT_PATH_FIELD + OAUTH2_ENDPOINT + OAUTH2_TOKEN_ENDPOINT) Observable<Response<Token>> refreshTokenRaw( @Path(OAUTH2_TENANT_PATH_FIELD) String tenant, @Field(OAUTH2_CLIENT_ID) String clientId, @Field(OAUTH2_GRANT_TYPE) String grantType, @Field(OAUTH2_RESOURCE) String resource, @Field(OAUTH2_TOKEN_REFRESH_TOKEN) String refreshToken, @Field(OAUTH2_REDIRECT_URI) String redirectUri ); class Factory { public static OAuth2 buildOAuth2API(boolean enableDebug) { return buildRetrofit(OAUTH2_BASE_URL, enableDebug).create(OAuth2.class); } protected static Retrofit buildRetrofit(String baseUrl, boolean enableDebug) { return new Retrofit.Builder() .baseUrl(baseUrl) .addConverterFactory(GsonConverterFactory.create(new GsonBuilder().create())) .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .client(buildClient(enableDebug)) .build(); } protected static OkHttpClient buildClient(boolean enableDebug) { OkHttpClient.Builder builder = new OkHttpClient.Builder() .connectTimeout(Const.CONNECT_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(Const.WRITE_TIMEOUT, TimeUnit.SECONDS) .readTimeout(Const.TIMEOUT, TimeUnit.SECONDS); if(enableDebug) { builder.addInterceptor( new HttpLoggingInterceptor().setLevel( HttpLoggingInterceptor.Level.BODY ) ); } return builder.build(); } } }
public class Token { @SerializedName(OAUTH2_TOKEN_TYPE) private String tokenType; @SerializedName(OAUTH2_TOKEN_EXPIRES_IN) private String expiresIn; @SerializedName(OAUTH2_TOKEN_SCOPE) private String scope; @SerializedName(OAUTH2_TOKEN_EXPIRES_ON) private String expiresOn; @SerializedName(OAUTH2_TOKEN_NOT_BEFORE) private String notBefore; @SerializedName(OAUTH2_TOKEN_RESOURCE) private String resource; @SerializedName(OAUTH2_TOKEN_ACCESS_TOKEN) private String accessToken; @SerializedName(OAUTH2_TOKEN_REFRESH_TOKEN) private String refreshToken; @SerializedName(OAUTH2_TOKEN_ID_TOKEN) private String idToken; public Token(String tokenType, String expiresIn, String scope, String expiresOn, String notBefore, String resource, String accessToken, String refreshToken, String idToken) { this.tokenType = tokenType; this.expiresIn = expiresIn; this.scope = scope; this.expiresOn = expiresOn; this.notBefore = notBefore; this.resource = resource; this.accessToken = accessToken; this.refreshToken = refreshToken; this.idToken = idToken; } public String getTokenType() { return tokenType; } public void setTokenType(String tokenType) { this.tokenType = tokenType; } public String getExpiresIn() { return expiresIn; } public void setExpiresIn(String expiresIn) { this.expiresIn = expiresIn; } public String getScope() { return scope; } public void setScope(String scope) { this.scope = scope; } public String getExpiresOn() { return expiresOn; } public void setExpiresOn(String expiresOn) { this.expiresOn = expiresOn; } public String getNotBefore() { return notBefore; } public void setNotBefore(String notBefore) { this.notBefore = notBefore; } public String getResource() { return resource; } public void setResource(String resource) { this.resource = resource; } public String getAccessToken() { return accessToken; } public void setAccessToken(String accessToken) { this.accessToken = accessToken; } public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } public String getIdToken() { return idToken; } public void setIdToken(String idToken) { this.idToken = idToken; } @Override public String toString() { return "MicrosoftAzureOAuthToken{" + "tokenType='" + tokenType + '\'' + ", expiresIn='" + expiresIn + '\'' + ", scope='" + scope + '\'' + ", expiresOn='" + expiresOn + '\'' + ", notBefore='" + notBefore + '\'' + ", resource='" + resource + '\'' + ", accessToken='" + accessToken + '\'' + ", refreshToken='" + refreshToken + '\'' + ", idToken='" + idToken + '\'' + '}'; } public String toJsonString() { return new Gson().toJson(this, Token.class); } public static Token fromJsonString(String jsonString) { return new Gson().fromJson(jsonString, Token.class); } }
Приступим к реализации кастомного WebViewClient-а. Для этого нам нужно определиться, что именно мы хотим сделать. По факту, на вход при его инициализации должны подаваться ссылки на callback-и, или на BehaviourSubject-ы (по вкусу, мне нравится в данном случае первое). Всего их будет три: первый — будет триггериться при успешном получении кода, второй — при наличии ‘error=’ подстроки в url после редиректа и третий — слушающий все остальные переходы.
Для реализации нам понадобится переопределить два метода WebViewClient: shouldOverrideUrlLoading(WebView webView, String url)и onPageFinished(WebView webView, String url).
public class OAuth2WebViewClient extends WebViewClient { private Action1<String> onSuccess; private Action1<String> onError; private Action1<String> onUnknownUrlPassed; public OAuth2WebViewClient(Action1<String> onSuccess, Action1<String> onError, Action1<String> onUnknownUrlPassed) { this.onSuccess = onSuccess; this.onUnknownUrlPassed = onUnknownUrlPassed; this.onError = onError; } @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { if(url.contains(OAUTH2_RAW_CODE_QUERY_FIELD) || url.contains(OAUTH2_RAW_QEURY_ERROR_FIELD)) { return true; } else { view.loadUrl(url); return false; } } @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); if(url.contains(OAUTH2_RAW_CODE_QUERY_FIELD)) { Uri uri = Uri.parse(url); onSuccess.call(uri.getQueryParameter(OAUTH2_CODE_QUERY_FIELD)); } else if(url.contains(OAUTH2_RAW_CODE_QUERY_FIELD)) { onError.call(url); } else { onUnknownUrlPassed.call(url); } } }
По факту — все готово для использования, но можно добавить для более гибкого функционала еще пару классов, чтобы сократить бойлерплейт еще на чуть-чуть.
public class AzureAuthenticationWebView extends WebView { public AzureAuthenticationWebView(Context context) { super(context); } public AzureAuthenticationWebView(Context context, AttributeSet attrs) { super(context, attrs); } public AzureAuthenticationWebView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public AzureAuthenticationWebView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public void init(OAuth2WebViewClient client, String query) { WebSettings settings = this.getSettings(); settings.setJavaScriptEnabled(true); settings.setSupportMultipleWindows(true); this.setWebViewClient(client); this.loadUrl(query); } }
public class AzureStorageManager { private ObscuredSharedPreferences preferences; public AzureStorageManager(ObscuredSharedPreferences preferences) { this.preferences = preferences; } public Token readToken() { String rawToken = preferences.getString(TOKEN_JSON_KEY, ""); return Token.fromJsonString(rawToken); } public void writeToken(Token token) { ObscuredSharedPreferences.Editor editor = preferences.edit(); editor.putString(TOKEN_JSON_KEY, token.toJsonString()); editor.commit(); } }
public class QueryStringBuilder { private String query; public QueryStringBuilder(String tenant) { query = OAUTH2_BASE_URL.concat("/").concat(tenant).concat(OAUTH2_ENDPOINT).concat(OAUTH2_AUTHORIZATION_ENDPOINT).concat("?"); } public QueryStringBuilder setClientId(String clientId) { query = prepareQuery(query); query = query.concat(QUERY_OAUTH2_CLIENT_ID).concat("=").concat(clientId); return this; } public QueryStringBuilder setResponseType(String responseType) { query = prepareQuery(query); query = query.concat(QUERY_OAUTH2_RESPONSE_TYPE).concat("=").concat(responseType); return this; } public QueryStringBuilder setRedirectUri(String redirectUri) { query = prepareQuery(query); query = query.concat(QUERY_OAUTH2_REDIRECT_URI).concat("=").concat(redirectUri); return this; } public QueryStringBuilder setResource(String resource) { query = prepareQuery(query); query = query.concat(QUERY_OAUTH2_RESOURCE).concat("=").concat(resource); return this; } public String build() { return query; } private String prepareQuery(String query) { if(query != null && query.length() != 0 && !(String.valueOf(query.charAt(query.length() - 1)).equals("?"))) { query = query.concat("&"); } return query; } }
В принципе, на этом можно остановиться, если реализовывать исключительно процесс авторизации, но мне показалось, что будет уместен также менеджер токенов, поскольку очень уж часто приходилось выполнять какие-то манипуляции с токенами. А потому, в качестве бонуса идет еще один класс, который в дополнение к предыдущим реализует хранение токенов, а также простой рефреш. Вуаля:
public class TokenManager { private Subscription subscription = Subscriptions.empty(); private AzureStorageManager storageManager; private String tenantType; private String clientId; private String redirectUri; public TokenManager(AzureStorageManager storageManager, String tenantType, String clientId, String redirectUri) { this.storageManager = storageManager; this.tenantType = tenantType; this.clientId = clientId; this.redirectUri = redirectUri; } /** Performs (code -> token) exchange using MS OAuth2 API * Caches the token if the response code is equals to HTTP_OK */ public void tradeCodeForToken(String code, String resource, final Action1<Token> onSuccess, Action1<Integer> onHttpError, Action1<Throwable> onFailure) { subscription = OAuth2.Factory.buildOAuth2API(false) .tradeCodeForToken( tenantType, clientId, GRANT_TYPE_REFRESH_TOKEN, resource, code, redirectUri ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .filter(response -> { if(response.code() != HTTP_OK) { onHttpError.call(response.code()); return false; } return true; }) .map(Response::body) .subscribe( token -> { storageManager.writeToken(token); onSuccess.call(token); }, e -> { onFailure.call(e); subscription.unsubscribe(); }, () -> subscription.unsubscribe() ); } /** Refreshes expired token * Caches the token if the response code is equals to HTTP_OK */ public void refreshToken(Token expiredToken, final Action1<Token> onSuccess, Action1<Integer> onHttpError, Action1<Throwable> onFailure) { subscription = OAuth2.Factory.buildOAuth2API(false) .refreshToken( tenantType, clientId, GRANT_TYPE_REFRESH_TOKEN, expiredToken.getResource(), expiredToken.getRefreshToken(), redirectUri ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .filter(response -> { if(response.code() != HTTP_OK) { onHttpError.call(response.code()); return false; } return true; }) .map(Response::body) .subscribe( token -> { storageManager.writeToken(token); onSuccess.call(token); }, e -> { onFailure.call(e); subscription.unsubscribe(); }, () -> subscription.unsubscribe() ); } }
Вот и все, полноценная библиотека авторизации готова. Она легко кастомизируема, и, что самое главное — она работает!
Небольшое примечание — в случае, если вы захотите использовать WebView в диалоге — обязательно выставьте ей конкретную высоту, поскольку в противном случае она просто будет иметь нулевую высоту.
Статья была написана по мотивам моей курсовой работы, которую я делаю на данный момент, в связи с тем что я ожидаю, пока мне выдадут аккаунт Azure AD, в котором можно будет делегировать необходимые для дальнейшей работы разрешения приложениям. В дальнейшем будет еще несколько статей, посвященных работе с OneNote for Business API (в основном — с classNotebooks секцией их api).
На этом все. Буду признателен за конструктивную критику, а также буду рад ответить на ваши вопросы.
ссылка на оригинал статьи https://habrahabr.ru/post/313454/
Добавить комментарий