diff --git a/twidere/src/main/java/org/mariotaku/twidere/util/MicroBlogAPIFactory.java b/twidere/src/main/java/org/mariotaku/twidere/util/MicroBlogAPIFactory.java index 0f62a34e5..374a488b9 100644 --- a/twidere/src/main/java/org/mariotaku/twidere/util/MicroBlogAPIFactory.java +++ b/twidere/src/main/java/org/mariotaku/twidere/util/MicroBlogAPIFactory.java @@ -351,7 +351,7 @@ public class MicroBlogAPIFactory implements TwidereConstants { } @NonNull - static String substituteLegacyApiBaseUrl(@NonNull String format, String domain) { + static String substituteLegacyApiBaseUrl(@NonNull String format, @Nullable String domain) { final int idxOfSlash = format.indexOf("://"); // Not an url if (idxOfSlash < 0) return format; @@ -362,8 +362,12 @@ public class MicroBlogAPIFactory implements TwidereConstants { if (!host.equalsIgnoreCase("api.twitter.com")) return format; final StringBuilder sb = new StringBuilder(); sb.append(format.substring(0, startOfHost)); - sb.append(domain); - sb.append(".twitter.com"); + if (domain != null) { + sb.append(domain); + sb.append(".twitter.com"); + } else { + sb.append("twitter.com"); + } if (endOfHost != -1) { sb.append(format.substring(endOfHost)); } diff --git a/twidere/src/main/java/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.java b/twidere/src/main/java/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.java deleted file mode 100644 index 17cfd361d..000000000 --- a/twidere/src/main/java/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.java +++ /dev/null @@ -1,559 +0,0 @@ -/* - * Twidere - Twitter client for Android - * - * Copyright (C) 2012-2014 Mariotaku Lee - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.mariotaku.twidere.util; - -import android.support.annotation.Nullable; -import android.text.TextUtils; - -import org.attoparser.AttoParseException; -import org.attoparser.IAttoHandler; -import org.attoparser.IAttoParser; -import org.attoparser.markup.MarkupAttoParser; -import org.attoparser.markup.html.AbstractStandardNonValidatingHtmlAttoHandler; -import org.attoparser.markup.html.HtmlParsingConfiguration; -import org.attoparser.markup.html.elements.IHtmlElement; -import org.mariotaku.microblog.library.MicroBlogException; -import org.mariotaku.microblog.library.twitter.TwitterOAuth; -import org.mariotaku.restfu.RestAPIFactory; -import org.mariotaku.restfu.RestClient; -import org.mariotaku.restfu.annotation.method.GET; -import org.mariotaku.restfu.annotation.method.POST; -import org.mariotaku.restfu.http.Endpoint; -import org.mariotaku.restfu.http.HttpRequest; -import org.mariotaku.restfu.http.HttpResponse; -import org.mariotaku.restfu.http.MultiValueMap; -import org.mariotaku.restfu.http.RestHttpClient; -import org.mariotaku.restfu.http.mime.FormBody; -import org.mariotaku.restfu.http.mime.SimpleBody; -import org.mariotaku.restfu.oauth.OAuthToken; -import org.mariotaku.restfu.okhttp3.OkHttpRestClient; -import org.mariotaku.twidere.util.net.SimpleCookieJar; - -import java.io.IOException; -import java.io.Reader; -import java.net.URI; -import java.util.Map; - -import okhttp3.HttpUrl; -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Response; - -import static org.mariotaku.twidere.TwidereConstants.OAUTH_CALLBACK_OOB; - -public class OAuthPasswordAuthenticator { - - private static final IAttoParser PARSER = new MarkupAttoParser(); - - private final TwitterOAuth oauth; - private final RestHttpClient client; - private final Endpoint endpoint; - private final LoginVerificationCallback loginVerificationCallback; - private final String userAgent; - - public OAuthPasswordAuthenticator(final TwitterOAuth oauth, - final LoginVerificationCallback loginVerificationCallback, - final String userAgent) { - final RestClient restClient = RestAPIFactory.getRestClient(oauth); - this.oauth = oauth; - this.endpoint = restClient.getEndpoint(); - this.loginVerificationCallback = loginVerificationCallback; - this.userAgent = userAgent; - - final OkHttpClient oldClient = ((OkHttpRestClient) restClient.getRestClient()).getClient(); - final OkHttpClient.Builder builder = oldClient.newBuilder(); - builder.cookieJar(new SimpleCookieJar()); - builder.addNetworkInterceptor(new EndpointInterceptor(endpoint)); - this.client = new OkHttpRestClient(builder.build()); - } - - public OAuthToken getOAuthAccessToken(final String username, final String password) throws AuthenticationException { - final OAuthToken requestToken; - try { - requestToken = oauth.getRequestToken(OAUTH_CALLBACK_OOB); - } catch (final MicroBlogException e) { - if (e.isCausedByNetworkIssue()) throw new AuthenticationException(e); - throw new AuthenticityTokenException(e); - } - try { - final AuthorizeRequestData authorizeRequestData = getAuthorizeRequestData(requestToken); - AuthorizeResponseData authorizeResponseData = getAuthorizeResponseData(requestToken, - authorizeRequestData, username, password); - if (!TextUtils.isEmpty(authorizeResponseData.oauthPin)) { - // Here we got OAuth PIN, just get access token directly - return oauth.getAccessToken(requestToken, authorizeResponseData.oauthPin); - } else if (authorizeResponseData.challenge == null) { - // No OAuth pin, or verification challenge, so treat as wrong password - throw new WrongUserPassException(); - } - // Go to password verification flow - final String challengeType = authorizeResponseData.challenge.challengeType; - final String loginVerification = loginVerificationCallback.getLoginVerification(challengeType); - final AuthorizeRequestData verificationData = getVerificationData(authorizeResponseData, - loginVerification); - authorizeResponseData = getAuthorizeResponseData(requestToken, - verificationData, username, password); - if (TextUtils.isEmpty(authorizeResponseData.oauthPin)) { - throw new LoginVerificationException(); - } - return oauth.getAccessToken(requestToken, authorizeResponseData.oauthPin); - } catch (final IOException | NullPointerException | MicroBlogException e) { - throw new AuthenticationException(e); - } - } - - private AuthorizeRequestData getVerificationData(AuthorizeResponseData authorizeResponseData, - @Nullable String challengeResponse) throws IOException, LoginVerificationException { - HttpResponse response = null; - try { - final AuthorizeRequestData data = new AuthorizeRequestData(); - final MultiValueMap params = new MultiValueMap<>(); - final AuthorizeResponseData.Verification verification = authorizeResponseData.challenge; - params.add("authenticity_token", verification.authenticityToken); - params.add("user_id", verification.userId); - params.add("challenge_id", verification.challengeId); - params.add("challenge_type", verification.challengeType); - params.add("platform", verification.platform); - params.add("redirect_after_login", verification.redirectAfterLogin); - final MultiValueMap requestHeaders = new MultiValueMap<>(); - requestHeaders.add("User-Agent", userAgent); - - if (!TextUtils.isEmpty(challengeResponse)) { - params.add("challenge_response", challengeResponse); - } - final FormBody authorizationResultBody = new FormBody(params); - - final HttpRequest.Builder authorizeResultBuilder = new HttpRequest.Builder(); - authorizeResultBuilder.method(POST.METHOD); - authorizeResultBuilder.url(endpoint.construct("/account/login_verification")); - authorizeResultBuilder.headers(requestHeaders); - authorizeResultBuilder.body(authorizationResultBody); - response = client.newCall(authorizeResultBuilder.build()).execute(); - parseAuthorizeRequestData(response, data); - if (TextUtils.isEmpty(data.authenticityToken)) { - throw new LoginVerificationException(); - } - return data; - } catch (AttoParseException e) { - throw new LoginVerificationException("Login verification challenge failed", e); - } finally { - Utils.closeSilently(response); - } - } - - private void parseAuthorizeRequestData(HttpResponse response, final AuthorizeRequestData data) throws AttoParseException, IOException { - final HtmlParsingConfiguration conf = new HtmlParsingConfiguration(); - final IAttoHandler handler = new AbstractStandardNonValidatingHtmlAttoHandler(conf) { - boolean isOAuthFormOpened; - - @Override - public void handleHtmlStandaloneElement(IHtmlElement element, boolean minimized, - String elementName, Map attributes, - int line, int col) { - handleHtmlOpenElement(element, elementName, attributes, line, col); - handleHtmlCloseElement(element, elementName, line, col); - } - - @Override - public void handleHtmlOpenElement(IHtmlElement element, String elementName, - Map attributes, int line, int col) { - switch (elementName) { - case "form": { - if (attributes != null && "oauth_form".equals(attributes.get("id"))) { - isOAuthFormOpened = true; - } - break; - } - case "input": { - if (isOAuthFormOpened && attributes != null) { - final String name = attributes.get("name"); - if (TextUtils.isEmpty(name)) break; - final String value = attributes.get("value"); - if (name.equals("authenticity_token")) { - data.authenticityToken = value; - } else if (name.equals("redirect_after_login")) { - data.redirectAfterLogin = value; - } - } - break; - } - } - } - - @Override - public void handleHtmlCloseElement(IHtmlElement element, String elementName, int line, int col) { - if ("form".equals(elementName)) { - isOAuthFormOpened = false; - } - } - }; - PARSER.parse(SimpleBody.reader(response.getBody()), handler); - } - - private AuthorizeResponseData getAuthorizeResponseData(OAuthToken requestToken, - AuthorizeRequestData authorizeRequestData, - String username, String password) throws IOException, AuthenticationException { - HttpResponse response = null; - try { - final AuthorizeResponseData data = new AuthorizeResponseData(); - final MultiValueMap params = new MultiValueMap<>(); - params.add("oauth_token", requestToken.getOauthToken()); - params.add("authenticity_token", authorizeRequestData.authenticityToken); - params.add("redirect_after_login", authorizeRequestData.redirectAfterLogin); - if (!TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) { - params.add("session[username_or_email]", username); - params.add("session[password]", password); - } - final FormBody authorizationResultBody = new FormBody(params); - final MultiValueMap requestHeaders = new MultiValueMap<>(); - requestHeaders.add("User-Agent", userAgent); - data.referer = authorizeRequestData.referer; - - final HttpRequest.Builder authorizeResultBuilder = new HttpRequest.Builder(); - authorizeResultBuilder.method(POST.METHOD); - authorizeResultBuilder.url(endpoint.construct("/oauth/authorize")); - authorizeResultBuilder.headers(requestHeaders); - authorizeResultBuilder.body(authorizationResultBody); - response = client.newCall(authorizeResultBuilder.build()).execute(); - final HtmlParsingConfiguration conf = new HtmlParsingConfiguration(); - final IAttoHandler handler = new AbstractStandardNonValidatingHtmlAttoHandler(conf) { - boolean isOAuthPinDivOpened; - boolean isChallengeFormOpened; - - @Override - public void handleHtmlStandaloneElement(IHtmlElement element, boolean minimized, - String elementName, Map attributes, - int line, int col) { - handleHtmlOpenElement(element, elementName, attributes, line, col); - handleHtmlCloseElement(element, elementName, line, col); - } - - @Override - public void handleHtmlCloseElement(IHtmlElement element, String elementName, int line, int col) { - switch (elementName) { - case "div": { - isOAuthPinDivOpened = false; - break; - } - case "form": { - isChallengeFormOpened = false; - break; - } - } - } - - @Override - public void handleHtmlOpenElement(IHtmlElement element, String elementName, - Map attributes, int line, int col) { - switch (elementName) { - case "div": { - if (attributes == null) break; - if ("oauth_pin".equals(attributes.get("id"))) { - isOAuthPinDivOpened = true; - } - - break; - } - case "form": { - if (attributes == null) break; - final String id = attributes.get("id"); - if (id == null) break; - switch (id) { - case "login-verification-form": - case "login-challenge-form": { - isChallengeFormOpened = true; - break; - } - } - break; - } - case "input": - if (attributes == null) break; - if (isChallengeFormOpened) { - final String name = attributes.get("name"); - if (TextUtils.isEmpty(name)) break; - final String value = attributes.get("value"); - switch (name) { - case "authenticity_token": { - ensureVerification(); - data.challenge.authenticityToken = value; - break; - } - case "challenge_id": { - ensureVerification(); - data.challenge.challengeId = value; - break; - } - case "challenge_type": { - ensureVerification(); - data.challenge.challengeType = value; - break; - } - case "platform": { - ensureVerification(); - data.challenge.platform = value; - break; - } - case "user_id": { - ensureVerification(); - data.challenge.userId = value; - break; - } - case "redirect_after_login": { - ensureVerification(); - data.challenge.redirectAfterLogin = value; - break; - } - } - } - break; - } - } - - private void ensureVerification() { - if (data.challenge == null) { - data.challenge = new AuthorizeResponseData.Verification(); - } - } - - @Override - public void handleText(char[] buffer, int offset, int len, int line, int col) throws AttoParseException { - if (isOAuthPinDivOpened) { - final String s = new String(buffer, offset, len); - if (TextUtils.isDigitsOnly(s)) { - data.oauthPin = s; - } - } - } - }; - PARSER.parse(SimpleBody.reader(response.getBody()), handler); - return data; - } catch (AttoParseException e) { - throw new AuthenticationException("Malformed HTML", e); - } finally { - Utils.closeSilently(response); - } - } - - private AuthorizeRequestData getAuthorizeRequestData(OAuthToken requestToken) throws IOException, - AuthenticationException { - HttpResponse response = null; - try { - final AuthorizeRequestData data = new AuthorizeRequestData(); - final HttpRequest.Builder authorizePageBuilder = new HttpRequest.Builder(); - authorizePageBuilder.method(GET.METHOD); - authorizePageBuilder.url(endpoint.construct("/oauth/authorize", new String[]{"oauth_token", - requestToken.getOauthToken()})); - data.referer = Endpoint.constructUrl("https://api.twitter.com/oauth/authorize", - new String[]{"oauth_token", requestToken.getOauthToken()}); - final MultiValueMap requestHeaders = new MultiValueMap<>(); - requestHeaders.add("User-Agent", userAgent); - authorizePageBuilder.headers(requestHeaders); - final HttpRequest authorizePageRequest = authorizePageBuilder.build(); - response = client.newCall(authorizePageRequest).execute(); - parseAuthorizeRequestData(response, data); - if (TextUtils.isEmpty(data.authenticityToken)) { - throw new AuthenticationException(); - } - return data; - } catch (AttoParseException e) { - throw new AuthenticationException("Malformed HTML", e); - } finally { - Utils.closeSilently(response); - } - } - - public static void readOAuthPINFromHtml(Reader reader, final OAuthPinData data) throws AttoParseException, IOException { - final HtmlParsingConfiguration conf = new HtmlParsingConfiguration(); - final IAttoHandler handler = new AbstractStandardNonValidatingHtmlAttoHandler(conf) { - boolean isOAuthPinDivOpened; - - @Override - public void handleHtmlStandaloneElement(IHtmlElement element, boolean minimized, - String elementName, Map attributes, - int line, int col) { - handleHtmlOpenElement(element, elementName, attributes, line, col); - handleHtmlCloseElement(element, elementName, line, col); - } - - @Override - public void handleHtmlOpenElement(IHtmlElement element, String elementName, Map attributes, int line, int col) { - switch (elementName) { - case "div": { - if (attributes != null && "oauth_pin".equals(attributes.get("id"))) { - isOAuthPinDivOpened = true; - } - break; - } - } - } - - @Override - public void handleHtmlCloseElement(IHtmlElement element, String elementName, int line, int col) { - if ("div".equals(elementName)) { - isOAuthPinDivOpened = false; - } - } - - @Override - public void handleText(char[] buffer, int offset, int len, int line, int col) { - if (isOAuthPinDivOpened) { - final String s = new String(buffer, offset, len); - if (TextUtils.isDigitsOnly(s)) { - data.oauthPin = s; - } - } - } - }; - PARSER.parse(reader, handler); - } - - public interface LoginVerificationCallback { - String getLoginVerification(String challengeType); - } - - public static class AuthenticationException extends Exception { - - public AuthenticationException() { - } - - public AuthenticationException(final Exception cause) { - super(cause); - } - - public AuthenticationException(String detailMessage, Throwable throwable) { - super(detailMessage, throwable); - } - - public AuthenticationException(final String message) { - super(message); - } - } - - public static final class AuthenticityTokenException extends AuthenticationException { - - public AuthenticityTokenException(Exception e) { - super(e); - } - } - - public static final class WrongUserPassException extends AuthenticationException { - WrongUserPassException() { - super(); - } - - WrongUserPassException(Exception cause) { - super(cause); - } - - WrongUserPassException(String detailMessage, Throwable throwable) { - super(detailMessage, throwable); - } - - WrongUserPassException(String message) { - super(message); - } - } - - public static final class LoginVerificationException extends AuthenticationException { - LoginVerificationException(String message) { - super(message); - } - - LoginVerificationException(String detailMessage, Throwable throwable) { - super(detailMessage, throwable); - } - - LoginVerificationException(Exception cause) { - super(cause); - } - - LoginVerificationException() { - super(); - } - } - - static class AuthorizeResponseData { - - String referer; - - public String oauthPin; - public Verification challenge; - - static class Verification { - - String authenticityToken; - String challengeId; - String challengeType; - String platform; - String userId; - String redirectAfterLogin; - } - } - - static class AuthorizeRequestData { - public String authenticityToken; - public String redirectAfterLogin; - - public String referer; - } - - public static class OAuthPinData { - - public String oauthPin; - } - - private static class EndpointInterceptor implements Interceptor { - private final Endpoint endpoint; - - public EndpointInterceptor(Endpoint endpoint) { - this.endpoint = endpoint; - } - - @Override - public Response intercept(Chain chain) throws IOException { - final Response response = chain.proceed(chain.request()); - if (!response.isRedirect()) { - return response; - } - final String location = response.header("Location"); - final Response.Builder builder = response.newBuilder(); - if (!TextUtils.isEmpty(location) && !endpoint.checkEndpoint(location)) { - final HttpUrl originalLocation = HttpUrl.get(URI.create("https://api.twitter.com/").resolve(location)); - final HttpUrl.Builder locationBuilder = HttpUrl.parse(endpoint.getUrl()).newBuilder(); - for (String pathSegments : originalLocation.pathSegments()) { - locationBuilder.addPathSegment(pathSegments); - } - for (int i = 0, j = originalLocation.querySize(); i < j; i++) { - final String name = originalLocation.queryParameterName(i); - final String value = originalLocation.queryParameterValue(i); - locationBuilder.addQueryParameter(name, value); - } - final String encodedFragment = originalLocation.encodedFragment(); - if (encodedFragment != null) { - locationBuilder.encodedFragment(encodedFragment); - } - final HttpUrl newLocation = locationBuilder.build(); - builder.header("Location", newLocation.toString()); - } - return builder.build(); - } - } -} diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/activity/SignInActivity.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/activity/SignInActivity.kt index be7c6d0f1..c63cf67dd 100644 --- a/twidere/src/main/kotlin/org/mariotaku/twidere/activity/SignInActivity.kt +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/activity/SignInActivity.kt @@ -542,7 +542,7 @@ class SignInActivity : BaseActivity(), OnClickListener, TextWatcher { class InputLoginVerificationDialogFragment : BaseDialogFragment(), DialogInterface.OnClickListener, DialogInterface.OnShowListener { private var callback: SignInTask.InputLoginVerificationCallback? = null - private var challengeType: String? = null + var challengeType: String? = null internal fun setCallback(callback: SignInTask.InputLoginVerificationCallback) { this.callback = callback @@ -577,34 +577,36 @@ class SignInActivity : BaseActivity(), OnClickListener, TextWatcher { } } - fun setChallengeType(challengeType: String) { - this.challengeType = challengeType - } - override fun onShow(dialog: DialogInterface) { val alertDialog = dialog as AlertDialog val verificationHint = alertDialog.findViewById(R.id.verification_hint) as TextView? val editVerification = alertDialog.findViewById(R.id.edit_verification_code) as EditText? if (verificationHint == null || editVerification == null) return - if ("Push".equals(challengeType!!, ignoreCase = true)) { - verificationHint.setText(R.string.login_verification_push_hint) - editVerification.visibility = View.GONE - } else if ("RetypePhoneNumber".equals(challengeType!!, ignoreCase = true)) { - verificationHint.setText(R.string.login_challenge_retype_phone_hint) - editVerification.inputType = InputType.TYPE_CLASS_PHONE - editVerification.visibility = View.VISIBLE - } else if ("RetypeEmail".equals(challengeType!!, ignoreCase = true)) { - verificationHint.setText(R.string.login_challenge_retype_email_hint) - editVerification.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS - editVerification.visibility = View.VISIBLE - } else if ("Sms".equals(challengeType!!, ignoreCase = true)) { - verificationHint.setText(R.string.login_verification_pin_hint) - editVerification.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD - editVerification.visibility = View.VISIBLE - } else { - verificationHint.text = getString(R.string.unsupported_login_verification_type_name, - challengeType) - editVerification.visibility = View.VISIBLE + when { + "Push".equals(challengeType, ignoreCase = true) -> { + verificationHint.setText(R.string.login_verification_push_hint) + editVerification.visibility = View.GONE + } + "RetypePhoneNumber".equals(challengeType, ignoreCase = true) -> { + verificationHint.setText(R.string.login_challenge_retype_phone_hint) + editVerification.inputType = InputType.TYPE_CLASS_PHONE + editVerification.visibility = View.VISIBLE + } + "RetypeEmail".equals(challengeType, ignoreCase = true) -> { + verificationHint.setText(R.string.login_challenge_retype_email_hint) + editVerification.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + editVerification.visibility = View.VISIBLE + } + "Sms".equals(challengeType, ignoreCase = true) -> { + verificationHint.setText(R.string.login_verification_pin_hint) + editVerification.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + editVerification.visibility = View.VISIBLE + } + else -> { + verificationHint.text = getString(R.string.unsupported_login_verification_type_name, + challengeType) + editVerification.visibility = View.VISIBLE + } } } } @@ -933,7 +935,7 @@ class SignInActivity : BaseActivity(), OnClickListener, TextWatcher { field = value } - override fun getLoginVerification(challengeType: String): String? { + override fun getLoginVerification(challengeType: String?): String? { // Dismiss current progress dialog publishProgress(Runnable { val activity = activityRef.get() ?: return@Runnable @@ -947,7 +949,7 @@ class SignInActivity : BaseActivity(), OnClickListener, TextWatcher { val df = InputLoginVerificationDialogFragment() df.isCancelable = false df.setCallback(this@InputLoginVerificationCallback) - df.setChallengeType(challengeType) + df.challengeType = challengeType df.show(sia.supportFragmentManager, null) Unit } diff --git a/twidere/src/main/kotlin/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.kt b/twidere/src/main/kotlin/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.kt new file mode 100644 index 000000000..4c390f4e9 --- /dev/null +++ b/twidere/src/main/kotlin/org/mariotaku/twidere/util/OAuthPasswordAuthenticator.kt @@ -0,0 +1,504 @@ +/* + * Twidere - Twitter client for Android + * + * Copyright (C) 2012-2014 Mariotaku Lee + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.mariotaku.twidere.util + +import android.text.TextUtils +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import org.attoparser.AttoParseException +import org.attoparser.markup.MarkupAttoParser +import org.attoparser.markup.html.AbstractStandardNonValidatingHtmlAttoHandler +import org.attoparser.markup.html.HtmlParsingConfiguration +import org.attoparser.markup.html.elements.IHtmlElement +import org.mariotaku.microblog.library.MicroBlogException +import org.mariotaku.microblog.library.twitter.TwitterOAuth +import org.mariotaku.restfu.RestAPIFactory +import org.mariotaku.restfu.annotation.method.GET +import org.mariotaku.restfu.annotation.method.POST +import org.mariotaku.restfu.http.* +import org.mariotaku.restfu.http.mime.FormBody +import org.mariotaku.restfu.http.mime.SimpleBody +import org.mariotaku.restfu.oauth.OAuthToken +import org.mariotaku.restfu.okhttp3.OkHttpRestClient +import org.mariotaku.twidere.TwidereConstants.OAUTH_CALLBACK_OOB +import org.mariotaku.twidere.util.net.SimpleCookieJar +import java.io.IOException +import java.io.Reader +import java.net.URI + +class OAuthPasswordAuthenticator( + private val oauth: TwitterOAuth, + private val loginVerificationCallback: OAuthPasswordAuthenticator.LoginVerificationCallback, + private val userAgent: String +) { + private val client: RestHttpClient + private val endpoint: Endpoint + + init { + val restClient = RestAPIFactory.getRestClient(oauth) + this.endpoint = restClient.endpoint + + val oldClient = (restClient.restClient as OkHttpRestClient).client + val builder = oldClient.newBuilder() + builder.cookieJar(SimpleCookieJar()) + builder.addNetworkInterceptor(EndpointInterceptor(endpoint)) + this.client = OkHttpRestClient(builder.build()) + } + + @Throws(AuthenticationException::class) + fun getOAuthAccessToken(username: String, password: String): OAuthToken { + val requestToken: OAuthToken + try { + requestToken = oauth.getRequestToken(OAUTH_CALLBACK_OOB) + } catch (e: MicroBlogException) { + if (e.isCausedByNetworkIssue) throw AuthenticationException(e) + throw AuthenticityTokenException(e) + } + + try { + val authorizeRequestData = getAuthorizeRequestData(requestToken) + var authorizeResponseData = getAuthorizeResponseData(requestToken, + authorizeRequestData, username, password) + if (!TextUtils.isEmpty(authorizeResponseData.oauthPin)) { + // Here we got OAuth PIN, just get access token directly + return oauth.getAccessToken(requestToken, authorizeResponseData.oauthPin) + } else if (authorizeResponseData.challenge == null) { + // No OAuth pin, or verification challenge, so treat as wrong password + throw WrongUserPassException() + } + // Go to password verification flow + val challengeType = authorizeResponseData.challenge!!.challengeType ?: + throw LoginVerificationException() + val loginVerification = loginVerificationCallback.getLoginVerification(challengeType) + val verificationData = getVerificationData(authorizeResponseData, + loginVerification) + authorizeResponseData = getAuthorizeResponseData(requestToken, + verificationData, username, password) + if (TextUtils.isEmpty(authorizeResponseData.oauthPin)) { + throw LoginVerificationException() + } + return oauth.getAccessToken(requestToken, authorizeResponseData.oauthPin) + } catch (e: IOException) { + throw AuthenticationException(e) + } catch (e: NullPointerException) { + throw AuthenticationException(e) + } catch (e: MicroBlogException) { + throw AuthenticationException(e) + } + + } + + @Throws(IOException::class, LoginVerificationException::class) + private fun getVerificationData(authorizeResponseData: AuthorizeResponseData, + challengeResponse: String?): AuthorizeRequestData { + var response: HttpResponse? = null + try { + val data = AuthorizeRequestData() + val params = MultiValueMap() + val verification = authorizeResponseData.challenge!! + params.add("authenticity_token", verification.authenticityToken) + params.add("user_id", verification.userId) + params.add("challenge_id", verification.challengeId) + params.add("challenge_type", verification.challengeType) + params.add("platform", verification.platform) + params.add("redirect_after_login", verification.redirectAfterLogin) + val requestHeaders = MultiValueMap() + requestHeaders.add("User-Agent", userAgent) + + if (!TextUtils.isEmpty(challengeResponse)) { + params.add("challenge_response", challengeResponse) + } + val authorizationResultBody = FormBody(params) + + val authorizeResultBuilder = HttpRequest.Builder() + authorizeResultBuilder.method(POST.METHOD) + authorizeResultBuilder.url(endpoint.construct("/account/login_verification")) + authorizeResultBuilder.headers(requestHeaders) + authorizeResultBuilder.body(authorizationResultBody) + response = client.newCall(authorizeResultBuilder.build()).execute() + parseAuthorizeRequestData(response, data) + if (TextUtils.isEmpty(data.authenticityToken)) { + throw LoginVerificationException() + } + return data + } catch (e: AttoParseException) { + throw LoginVerificationException("Login verification challenge failed", e) + } finally { + Utils.closeSilently(response) + } + } + + @Throws(AttoParseException::class, IOException::class) + private fun parseAuthorizeRequestData(response: HttpResponse, data: AuthorizeRequestData) { + val conf = HtmlParsingConfiguration() + val handler = object : AbstractStandardNonValidatingHtmlAttoHandler(conf) { + internal var isOAuthFormOpened: Boolean = false + + override fun handleHtmlStandaloneElement(element: IHtmlElement?, minimized: Boolean, + elementName: String?, attributes: Map?, + line: Int, col: Int) { + handleHtmlOpenElement(element, elementName, attributes, line, col) + handleHtmlCloseElement(element, elementName, line, col) + } + + override fun handleHtmlOpenElement(element: IHtmlElement?, elementName: String?, + attributes: Map?, line: Int, col: Int) { + when (elementName) { + "form" -> { + if (attributes != null && "oauth_form" == attributes["id"]) { + isOAuthFormOpened = true + } + } + "input" -> { + if (attributes != null && isOAuthFormOpened) { + val name = attributes["name"] + val value = attributes["value"] + if (name == "authenticity_token") { + data.authenticityToken = value + } else if (name == "redirect_after_login") { + data.redirectAfterLogin = value + } + } + } + } + } + + override fun handleHtmlCloseElement(element: IHtmlElement?, elementName: String?, line: Int, col: Int) { + if ("form" == elementName) { + isOAuthFormOpened = false + } + } + } + PARSER.parse(SimpleBody.reader(response.body), handler) + } + + @Throws(IOException::class, AuthenticationException::class) + private fun getAuthorizeResponseData(requestToken: OAuthToken, + authorizeRequestData: AuthorizeRequestData, + username: String, password: String): AuthorizeResponseData { + var response: HttpResponse? = null + try { + val data = AuthorizeResponseData() + val params = MultiValueMap() + params.add("oauth_token", requestToken.oauthToken) + params.add("authenticity_token", authorizeRequestData.authenticityToken) + params.add("redirect_after_login", authorizeRequestData.redirectAfterLogin) + if (!TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) { + params.add("session[username_or_email]", username) + params.add("session[password]", password) + } + val authorizationResultBody = FormBody(params) + val requestHeaders = MultiValueMap() + requestHeaders.add("User-Agent", userAgent) + data.referer = authorizeRequestData.referer + + val authorizeResultBuilder = HttpRequest.Builder() + authorizeResultBuilder.method(POST.METHOD) + authorizeResultBuilder.url(endpoint.construct("/oauth/authorize")) + authorizeResultBuilder.headers(requestHeaders) + authorizeResultBuilder.body(authorizationResultBody) + response = client.newCall(authorizeResultBuilder.build()).execute() + val conf = HtmlParsingConfiguration() + val handler = object : AbstractStandardNonValidatingHtmlAttoHandler(conf) { + internal var isOAuthPinDivOpened: Boolean = false + internal var isChallengeFormOpened: Boolean = false + + override fun handleHtmlStandaloneElement(element: IHtmlElement?, minimized: Boolean, + elementName: String?, attributes: Map?, + line: Int, col: Int) { + handleHtmlOpenElement(element, elementName, attributes, line, col) + handleHtmlCloseElement(element, elementName, line, col) + } + + override fun handleHtmlCloseElement(element: IHtmlElement?, elementName: String?, line: Int, col: Int) { + when (elementName) { + "div" -> { + isOAuthPinDivOpened = false + } + "form" -> { + isChallengeFormOpened = false + } + } + } + + override fun handleHtmlOpenElement(element: IHtmlElement?, elementName: String?, + attributes: Map?, line: Int, col: Int) { + when (elementName) { + "div" -> { + if (attributes != null && "oauth_pin" == attributes["id"]) { + isOAuthPinDivOpened = true + } + } + "form" -> { + if (attributes != null) when (attributes["id"]) { + "login-verification-form", "login-challenge-form" -> { + isChallengeFormOpened = true + } + } + } + "input" -> { + if (attributes != null && isChallengeFormOpened) { + val name = attributes["name"] + val value = attributes["value"] + when (name) { + "authenticity_token" -> { + ensureVerification() + data.challenge!!.authenticityToken = value + } + "challenge_id" -> { + ensureVerification() + data.challenge!!.challengeId = value + } + "challenge_type" -> { + ensureVerification() + data.challenge!!.challengeType = value + } + "platform" -> { + ensureVerification() + data.challenge!!.platform = value + } + "user_id" -> { + ensureVerification() + data.challenge!!.userId = value + } + "redirect_after_login" -> { + ensureVerification() + data.challenge!!.redirectAfterLogin = value + } + } + } + } + } + } + + private fun ensureVerification() { + if (data.challenge == null) { + data.challenge = AuthorizeResponseData.Verification() + } + } + + @Throws(AttoParseException::class) + override fun handleText(buffer: CharArray?, offset: Int, len: Int, line: Int, col: Int) { + if (isOAuthPinDivOpened) { + val s = String(buffer!!, offset, len) + if (TextUtils.isDigitsOnly(s)) { + data.oauthPin = s + } + } + } + } + PARSER.parse(SimpleBody.reader(response!!.body), handler) + return data + } catch (e: AttoParseException) { + throw AuthenticationException("Malformed HTML", e) + } finally { + Utils.closeSilently(response) + } + } + + @Throws(IOException::class, AuthenticationException::class) + private fun getAuthorizeRequestData(requestToken: OAuthToken): AuthorizeRequestData { + var response: HttpResponse? = null + try { + val data = AuthorizeRequestData() + val authorizePageBuilder = HttpRequest.Builder() + authorizePageBuilder.method(GET.METHOD) + authorizePageBuilder.url(endpoint.construct("/oauth/authorize", + arrayOf("oauth_token", requestToken.oauthToken))) + data.referer = Endpoint.constructUrl("https://api.twitter.com/oauth/authorize", + arrayOf("oauth_token", requestToken.oauthToken)) + val requestHeaders = MultiValueMap() + requestHeaders.add("User-Agent", userAgent) + authorizePageBuilder.headers(requestHeaders) + val authorizePageRequest = authorizePageBuilder.build() + response = client.newCall(authorizePageRequest).execute() + parseAuthorizeRequestData(response, data) + if (TextUtils.isEmpty(data.authenticityToken)) { + throw AuthenticationException() + } + return data + } catch (e: AttoParseException) { + throw AuthenticationException("Malformed HTML", e) + } finally { + Utils.closeSilently(response) + } + } + + interface LoginVerificationCallback { + fun getLoginVerification(challengeType: String): String? + } + + open class AuthenticationException : Exception { + + constructor() { + } + + constructor(cause: Exception) : super(cause) { + } + + constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) { + } + + constructor(message: String) : super(message) { + } + } + + class AuthenticityTokenException(e: Exception) : AuthenticationException(e) + + class WrongUserPassException : AuthenticationException { + internal constructor() : super() { + } + + internal constructor(cause: Exception) : super(cause) { + } + + internal constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) { + } + + internal constructor(message: String) : super(message) { + } + } + + class LoginVerificationException : AuthenticationException { + internal constructor(message: String) : super(message) { + } + + internal constructor(detailMessage: String, throwable: Throwable) : super(detailMessage, throwable) { + } + + internal constructor(cause: Exception) : super(cause) { + } + + internal constructor() : super() { + } + } + + internal class AuthorizeResponseData { + + var referer: String? = null + + var oauthPin: String? = null + var challenge: Verification? = null + + internal class Verification { + + var authenticityToken: String? = null + var challengeId: String? = null + var challengeType: String? = null + var platform: String? = null + var userId: String? = null + var redirectAfterLogin: String? = null + } + } + + internal class AuthorizeRequestData { + var authenticityToken: String? = null + var redirectAfterLogin: String? = null + + var referer: String? = null + } + + class OAuthPinData { + + var oauthPin: String? = null + } + + private class EndpointInterceptor(private val endpoint: Endpoint) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (!response.isRedirect) { + return response + } + val location = response.header("Location") + val builder = response.newBuilder() + if (!TextUtils.isEmpty(location) && !endpoint.checkEndpoint(location)) { + val originalLocation = HttpUrl.get(URI.create("https://api.twitter.com/").resolve(location)) + val locationBuilder = HttpUrl.parse(endpoint.url).newBuilder() + for (pathSegments in originalLocation.pathSegments()) { + locationBuilder.addPathSegment(pathSegments) + } + var i = 0 + val j = originalLocation.querySize() + while (i < j) { + val name = originalLocation.queryParameterName(i) + val value = originalLocation.queryParameterValue(i) + locationBuilder.addQueryParameter(name, value) + i++ + } + val encodedFragment = originalLocation.encodedFragment() + if (encodedFragment != null) { + locationBuilder.encodedFragment(encodedFragment) + } + val newLocation = locationBuilder.build() + builder.header("Location", newLocation.toString()) + } + return builder.build() + } + } + + companion object { + + private val PARSER = MarkupAttoParser() + + @Throws(AttoParseException::class, IOException::class) + fun readOAuthPINFromHtml(reader: Reader, data: OAuthPinData) { + val conf = HtmlParsingConfiguration() + val handler = object : AbstractStandardNonValidatingHtmlAttoHandler(conf) { + internal var isOAuthPinDivOpened: Boolean = false + + override fun handleHtmlStandaloneElement(element: IHtmlElement?, minimized: Boolean, + elementName: String?, attributes: Map?, + line: Int, col: Int) { + handleHtmlOpenElement(element, elementName, attributes, line, col) + handleHtmlCloseElement(element, elementName, line, col) + } + + override fun handleHtmlOpenElement(element: IHtmlElement?, elementName: String?, attributes: Map?, line: Int, col: Int) { + when (elementName) { + "div" -> { + if (attributes != null && "oauth_pin" == attributes["id"]) { + isOAuthPinDivOpened = true + } + } + } + } + + override fun handleHtmlCloseElement(element: IHtmlElement?, elementName: String?, line: Int, col: Int) { + if ("div" == elementName) { + isOAuthPinDivOpened = false + } + } + + override fun handleText(buffer: CharArray?, offset: Int, len: Int, line: Int, col: Int) { + if (isOAuthPinDivOpened) { + val s = String(buffer!!, offset, len) + if (TextUtils.isDigitsOnly(s)) { + data.oauthPin = s + } + } + } + } + PARSER.parse(reader, handler) + } + } +}