Создание библиотеки для авторизации с помощью AzureAD для Android

от автора

Итак, цель данной статьи — показать, как работать с 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 запрос.

Теперь что это значит с точки зрения непосредственно разработчика?
Первое, что мы должны будем сделать — расписать в отдельных классах необходимые нам константы

Endpoints.class

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}"; } 

QueryFields.class

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"; } 

RequestFields.class

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="; } 

RequestFieldValues.class

public class RequestFieldValues {     public static final String TENANT_COMMON = "common";     public static final String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; } 

ResponseFields.class

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-клиента:

Const.class

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();         }     } } 

Token DTO

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).

OAuth2WebViewClient

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);         }     } } 

По факту — все готово для использования, но можно добавить для более гибкого функционала еще пару классов, чтобы сократить бойлерплейт еще на чуть-чуть.

AzureAuthenticationWebView

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);     } } 

AzureStorageManager

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();     } } 

QueryStringBuilder

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;     } } 

В принципе, на этом можно остановиться, если реализовывать исключительно процесс авторизации, но мне показалось, что будет уместен также менеджер токенов, поскольку очень уж часто приходилось выполнять какие-то манипуляции с токенами. А потому, в качестве бонуса идет еще один класс, который в дополнение к предыдущим реализует хранение токенов, а также простой рефреш. Вуаля:

TokenManager

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *