Створення бібліотеки для авторизації за допомогою 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)
Вами<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)
Вами<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)
Вами<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)
Вами<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)
Вами<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)
Вами<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 = 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 в діалозі — обов'язково виставте їй конкретну висоту, оскільки в іншому випадку вона просто буде мати нульову висоту.

Стаття була написана за мотивами моєї курсової роботи, яку я роблю на даний момент, в зв'язку з тим що я чекаю, поки мені видадуть обліковий запис Microsoft AD, в якому можна буде делегувати необхідні для подальшої роботи дозволу з додатками. Надалі буде ще кілька статей, присвячених роботі з OneNote for Business API (в основному — з classNotebooks секцією їх api).

На цьому все. Буду вдячний за конструктивну критику, а також буду радий відповісти на ваші запитання.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.