Итак, цель данной статьи — показать, как работать с 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).
На этом все. Буду признателен за конструктивную критику, а также буду рад ответить на ваши вопросы.
Автор: KomarovI