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

в 13:10, , рубрики: android development, azure ad, oauth 2.0, rest api, Разработка под 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).

На этом все. Буду признателен за конструктивную критику, а также буду рад ответить на ваши вопросы.

Автор: KomarovI

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js