From fd472fbe1f4fa567f40fa459022b4479039460d6 Mon Sep 17 00:00:00 2001 From: Vavassor Date: Sun, 9 Apr 2017 18:36:55 -0400 Subject: [PATCH 1/3] Possible fix to enable connections using TLS 1.1 and 1.2 on pre-Lollipop android versions. Also expands to the enabled list of cipher suites. --- app/build.gradle | 1 + .../com/keylesspalace/tusky/BaseActivity.java | 25 +-- .../keylesspalace/tusky/LoginActivity.java | 11 +- .../tusky/MyFirebaseInstanceIdService.java | 1 + .../tusky/MyFirebaseMessagingService.java | 2 +- .../com/keylesspalace/tusky/OkHttpUtils.java | 157 ++++++++++++++++++ 6 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java diff --git a/app/build.gradle b/app/build.gradle index b05e88672..86512389a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,6 +38,7 @@ dependencies { compile 'com.mikhaellopez:circularfillableloaders:1.2.0' compile 'com.squareup.retrofit2:retrofit:2.2.0' compile 'com.squareup.retrofit2:converter-gson:2.1.0' + compile 'com.squareup.okhttp3:logging-interceptor:3.6.0' compile 'com.github.chrisbanes:PhotoView:1.3.1' compile 'com.mikepenz:google-material-typeface:3.0.1.0.original@aar' compile 'com.github.arimorty:floatingsearchview:2.0.3' diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index e34744c47..a83bb9e05 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -34,7 +34,10 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import okhttp3.ConnectionSpec; import okhttp3.Dispatcher; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -114,32 +117,13 @@ public class BaseActivity extends AppCompatActivity { protected void createMastodonAPI() { mastodonApiDispatcher = new Dispatcher(); - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .addInterceptor(new Interceptor() { - @Override - public Response intercept(Chain chain) throws IOException { - Request originalRequest = chain.request(); - - Request.Builder builder = originalRequest.newBuilder(); - String accessToken = getAccessToken(); - if (accessToken != null) { - builder.header("Authorization", String.format("Bearer %s", accessToken)); - } - Request newRequest = builder.build(); - - return chain.proceed(newRequest); - } - }) - .dispatcher(mastodonApiDispatcher) - .build(); - Gson gson = new GsonBuilder() .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) .create(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(getBaseUrl()) - .client(okHttpClient) + .client(OkHttpUtils.getCompatibleClient()) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); @@ -149,6 +133,7 @@ public class BaseActivity extends AppCompatActivity { protected void createTuskyAPI() { Retrofit retrofit = new Retrofit.Builder() .baseUrl(getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) .build(); tuskyAPI = retrofit.create(TuskyAPI.class); diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java index 11ede95b7..29712d0f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java @@ -117,6 +117,7 @@ public class LoginActivity extends AppCompatActivity { private MastodonAPI getApiFor(String domain) { Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://" + domain) + .client(OkHttpUtils.getCompatibleClient()) .addConverterFactory(GsonConverterFactory.create()) .build(); @@ -168,8 +169,10 @@ public class LoginActivity extends AppCompatActivity { }; try { - getApiFor(domain).authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), OAUTH_SCOPES, - getString(R.string.app_website)).enqueue(callback); + getApiFor(domain) + .authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), + OAUTH_SCOPES, getString(R.string.app_website)) + .enqueue(callback); } catch (IllegalArgumentException e) { editText.setError(getString(R.string.error_invalid_domain)); } @@ -234,7 +237,7 @@ public class LoginActivity extends AppCompatActivity { } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "The app version was not found. " + e.getMessage()); } - if (preferences.getInt("lastUpdate", 0) != versionCode) { + if (preferences.getInt("lastUpdateVersion", 0) != versionCode) { SharedPreferences.Editor editor = preferences.edit(); if (versionCode == 14) { /* This version switches the order of scheme and host in the OAuth redirect URI. @@ -243,7 +246,7 @@ public class LoginActivity extends AppCompatActivity { * "rememberedVisibility", "loggedInUsername", and "loggedInAccountId". */ editor.clear(); } - editor.putInt("lastUpdate", versionCode); + editor.putInt("lastUpdateVersion", versionCode); editor.apply(); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java index ec3300618..4e963d684 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java +++ b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseInstanceIdService.java @@ -20,6 +20,7 @@ public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService { protected void createTuskyAPI() { Retrofit retrofit = new Retrofit.Builder() .baseUrl(getString(R.string.tusky_api_url)) + .client(OkHttpUtils.getCompatibleClient()) .build(); tuskyAPI = retrofit.create(TuskyAPI.class); diff --git a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java index 0031080ce..2ef29e37d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java +++ b/app/src/main/java/com/keylesspalace/tusky/MyFirebaseMessagingService.java @@ -84,7 +84,7 @@ public class MyFirebaseMessagingService extends FirebaseMessagingService { final String domain = preferences.getString("domain", null); final String accessToken = preferences.getString("accessToken", null); - OkHttpClient okHttpClient = new OkHttpClient.Builder() + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() .addInterceptor(new Interceptor() { @Override public okhttp3.Response intercept(Chain chain) throws IOException { diff --git a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java new file mode 100644 index 000000000..bb25b7161 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java @@ -0,0 +1,157 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is part of Tusky. + * + * Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU + * Lesser General Public License as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * Tusky 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 Lesser + * General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License along with Tusky. If + * not, see . */ + +package com.keylesspalace.tusky; + +import android.os.Build; +import android.support.annotation.NonNull; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.ConnectionSpec; +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; + +class OkHttpUtils { + private static final String TAG = "OkHttpUtils"; // logging tag + + @NonNull + static OkHttpClient.Builder getCompatibleClientBuilder() { + ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .allEnabledCipherSuites() + .supportsTlsExtensions(true) + .build(); + + List specList = new ArrayList<>(); + specList.add(ConnectionSpec.MODERN_TLS); + specList.add(fallback); + specList.add(ConnectionSpec.CLEARTEXT); + + HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(); + loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectionSpecs(specList) + .addInterceptor(loggingInterceptor); + + return enableHigherTlsOnPreLollipop(builder); + } + + @NonNull + static OkHttpClient getCompatibleClient() { + return getCompatibleClientBuilder().build(); + } + + private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) { + if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + + Arrays.toString(trustManagers)); + } + + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] { trustManager }, null); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + builder.sslSocketFactory(new Tls11And12SocketFactory(sslSocketFactory), + trustManager); + } catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) { + Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage()); + } + } + + return builder; + } + + private static class Tls11And12SocketFactory extends SSLSocketFactory { + private static final String[] TLS_VERSIONS = { "TLSv1.1", "TLSv1.2" }; + + final SSLSocketFactory delegate; + + Tls11And12SocketFactory(SSLSocketFactory base) { + this.delegate = base; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) + throws IOException { + return patch(delegate.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException, UnknownHostException { + return patch(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress host, int port) throws IOException { + return patch(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, + int localPort) throws IOException { + return patch(delegate.createSocket(address, port, localAddress, localPort)); + } + + private Socket patch(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + sslSocket.setEnabledProtocols(TLS_VERSIONS); + } + return socket; + } + } +} From 48c9b71f92208e76b16b9441cfb8e0c21ce85071 Mon Sep 17 00:00:00 2001 From: Vavassor Date: Tue, 11 Apr 2017 17:07:56 -0400 Subject: [PATCH 2/3] Finishes handshake-test-2 --- app/build.gradle | 1 - .../com/keylesspalace/tusky/BaseActivity.java | 25 +- .../java/com/keylesspalace/tusky/Log.java | 15 + .../keylesspalace/tusky/LoginActivity.java | 267 +++++++++--------- .../com/keylesspalace/tusky/OkHttpUtils.java | 82 ++++-- app/src/main/res/layout/activity_login.xml | 23 ++ 6 files changed, 263 insertions(+), 150 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 86512389a..b05e88672 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,7 +38,6 @@ dependencies { compile 'com.mikhaellopez:circularfillableloaders:1.2.0' compile 'com.squareup.retrofit2:retrofit:2.2.0' compile 'com.squareup.retrofit2:converter-gson:2.1.0' - compile 'com.squareup.okhttp3:logging-interceptor:3.6.0' compile 'com.github.chrisbanes:PhotoView:1.3.1' compile 'com.mikepenz:google-material-typeface:3.0.1.0.original@aar' compile 'com.github.arimorty:floatingsearchview:2.0.3' diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index a83bb9e05..9ea578a54 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -34,10 +34,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import okhttp3.ConnectionSpec; import okhttp3.Dispatcher; import okhttp3.Interceptor; import okhttp3.OkHttpClient; @@ -121,9 +118,29 @@ public class BaseActivity extends AppCompatActivity { .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) .create(); + OkHttpClient okHttpClient = OkHttpUtils.getCompatibleClientBuilder() + .addInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + + Request.Builder builder = originalRequest.newBuilder(); + String accessToken = getAccessToken(); + if (accessToken != null) { + builder.header("Authorization", String.format("Bearer %s", + accessToken)); + } + Request newRequest = builder.build(); + + return chain.proceed(newRequest); + } + }) + .dispatcher(mastodonApiDispatcher) + .build(); + Retrofit retrofit = new Retrofit.Builder() .baseUrl(getBaseUrl()) - .client(OkHttpUtils.getCompatibleClient()) + .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); diff --git a/app/src/main/java/com/keylesspalace/tusky/Log.java b/app/src/main/java/com/keylesspalace/tusky/Log.java index 16dc393df..982433648 100644 --- a/app/src/main/java/com/keylesspalace/tusky/Log.java +++ b/app/src/main/java/com/keylesspalace/tusky/Log.java @@ -18,10 +18,15 @@ package com.keylesspalace.tusky; /**A wrapper for android.util.Log that allows for disabling logging, such as for release builds.*/ public class Log { private static final boolean LOGGING_ENABLED = BuildConfig.DEBUG; + private static String longBoy; + private static String watchedTag; public static void i(String tag, String string) { if (LOGGING_ENABLED) { android.util.Log.i(tag, string); + if (tag.equals(watchedTag)) { + longBoy += string + '\n'; + } } } @@ -48,4 +53,14 @@ public class Log { android.util.Log.w(tag, string); } } + + static void watchTag(String tag) { + longBoy = ""; + watchedTag = tag; + } + + static String getWatchedMessages() { + watchedTag = null; + return longBoy; + } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java index 29712d0f5..c1f979278 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java @@ -24,6 +24,7 @@ import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.v7.app.AppCompatActivity; import android.text.method.LinkMovementMethod; import android.view.View; @@ -58,126 +59,7 @@ public class LoginActivity extends AppCompatActivity { @BindView(R.id.edit_text_domain) EditText editText; @BindView(R.id.button_login) Button button; @BindView(R.id.whats_an_instance) TextView whatsAnInstance; - - /** - * Chain together the key-value pairs into a query string, for either appending to a URL or - * as the content of an HTTP request. - */ - private static String toQueryString(Map parameters) { - StringBuilder s = new StringBuilder(); - String between = ""; - for (Map.Entry entry : parameters.entrySet()) { - s.append(between); - s.append(Uri.encode(entry.getKey())); - s.append("="); - s.append(Uri.encode(entry.getValue())); - between = "&"; - } - return s.toString(); - } - - /** Make sure the user-entered text is just a fully-qualified domain name. */ - private static String validateDomain(String s) { - // Strip any schemes out. - s = s.replaceFirst("http://", ""); - s = s.replaceFirst("https://", ""); - // If a username was included (e.g. username@example.com), just take what's after the '@'. - int at = s.indexOf('@'); - if (at != -1) { - s = s.substring(at + 1); - } - return s.trim(); - } - - private String getOauthRedirectUri() { - String scheme = getString(R.string.oauth_scheme); - String host = getString(R.string.oauth_redirect_host); - return scheme + "://" + host + "/"; - } - - private void redirectUserToAuthorizeAndLogin(EditText editText) { - /* To authorize this app and log in it's necessary to redirect to the domain given, - * activity_login there, and the server will redirect back to the app with its response. */ - String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE; - String redirectUri = getOauthRedirectUri(); - Map parameters = new HashMap<>(); - parameters.put("client_id", clientId); - parameters.put("redirect_uri", redirectUri); - parameters.put("response_type", "code"); - parameters.put("scope", OAUTH_SCOPES); - String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); - Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - if (viewIntent.resolveActivity(getPackageManager()) != null) { - startActivity(viewIntent); - } else { - editText.setError(getString(R.string.error_no_web_browser_found)); - } - } - - private MastodonAPI getApiFor(String domain) { - Retrofit retrofit = new Retrofit.Builder() - .baseUrl("https://" + domain) - .client(OkHttpUtils.getCompatibleClient()) - .addConverterFactory(GsonConverterFactory.create()) - .build(); - - return retrofit.create(MastodonAPI.class); - } - - /** - * Obtain the oauth client credentials for this app. This is only necessary the first time the - * app is run on a given server instance. So, after the first authentication, they are - * saved in SharedPreferences and every subsequent run they are simply fetched from there. - */ - private void onButtonClick(final EditText editText) { - domain = validateDomain(editText.getText().toString()); - /* Attempt to get client credentials from SharedPreferences, and if not present - * (such as in the case that the domain has never been accessed before) - * authenticate with the server and store the received credentials to use next - * time. */ - String prefClientId = preferences.getString(domain + "/client_id", null); - String prefClientSecret = preferences.getString(domain + "/client_secret", null); - - if (prefClientId != null && prefClientSecret != null) { - clientId = prefClientId; - clientSecret = prefClientSecret; - redirectUserToAuthorizeAndLogin(editText); - } else { - Callback callback = new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (!response.isSuccessful()) { - editText.setError(getString(R.string.error_failed_app_registration)); - Log.e(TAG, "App authentication failed. " + response.message()); - return; - } - AppCredentials credentials = response.body(); - clientId = credentials.clientId; - clientSecret = credentials.clientSecret; - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(domain + "/client_id", clientId); - editor.putString(domain + "/client_secret", clientSecret); - editor.apply(); - redirectUserToAuthorizeAndLogin(editText); - } - - @Override - public void onFailure(Call call, Throwable t) { - editText.setError(getString(R.string.error_failed_app_registration)); - t.printStackTrace(); - } - }; - - try { - getApiFor(domain) - .authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), - OAUTH_SCOPES, getString(R.string.app_website)) - .enqueue(callback); - } catch (IllegalArgumentException e) { - editText.setError(getString(R.string.error_invalid_domain)); - } - } - } + @BindView(R.id.debug_log_display) TextView debugLogDisplay; @Override protected void onCreate(Bundle savedInstanceState) { @@ -259,14 +141,133 @@ public class LoginActivity extends AppCompatActivity { super.onSaveInstanceState(outState); } - private void onLoginSuccess(String accessToken) { - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("domain", domain); - editor.putString("accessToken", accessToken); - editor.commit(); - Intent intent = new Intent(this, MainActivity.class); - startActivity(intent); - finish(); + /** Make sure the user-entered text is just a fully-qualified domain name. */ + @NonNull + private static String validateDomain(String s) { + // Strip any schemes out. + s = s.replaceFirst("http://", ""); + s = s.replaceFirst("https://", ""); + // If a username was included (e.g. username@example.com), just take what's after the '@'. + int at = s.indexOf('@'); + if (at != -1) { + s = s.substring(at + 1); + } + return s.trim(); + } + + private String getOauthRedirectUri() { + String scheme = getString(R.string.oauth_scheme); + String host = getString(R.string.oauth_redirect_host); + return scheme + "://" + host + "/"; + } + + private MastodonAPI getApiFor(String domain) { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + domain) + .client(OkHttpUtils.getCompatibleClient()) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + + return retrofit.create(MastodonAPI.class); + } + + /** + * Obtain the oauth client credentials for this app. This is only necessary the first time the + * app is run on a given server instance. So, after the first authentication, they are + * saved in SharedPreferences and every subsequent run they are simply fetched from there. + */ + private void onButtonClick(final EditText editText) { + domain = validateDomain(editText.getText().toString()); + /* Attempt to get client credentials from SharedPreferences, and if not present + * (such as in the case that the domain has never been accessed before) + * authenticate with the server and store the received credentials to use next + * time. */ + String prefClientId = preferences.getString(domain + "/client_id", null); + String prefClientSecret = preferences.getString(domain + "/client_secret", null); + + if (prefClientId != null && prefClientSecret != null) { + clientId = prefClientId; + clientSecret = prefClientSecret; + redirectUserToAuthorizeAndLogin(editText); + } else { + Log.watchTag(OkHttpUtils.TAG); + + Callback callback = new Callback() { + @Override + public void onResponse(Call call, + Response response) { + if (!response.isSuccessful()) { + debugLogDisplay.setText(Log.getWatchedMessages()); + editText.setError(getString(R.string.error_failed_app_registration)); + Log.e(TAG, "App authentication failed. " + response.message()); + return; + } + AppCredentials credentials = response.body(); + clientId = credentials.clientId; + clientSecret = credentials.clientSecret; + SharedPreferences.Editor editor = preferences.edit(); + editor.putString(domain + "/client_id", clientId); + editor.putString(domain + "/client_secret", clientSecret); + editor.apply(); + Log.watchTag(null); + redirectUserToAuthorizeAndLogin(editText); + } + + @Override + public void onFailure(Call call, Throwable t) { + debugLogDisplay.setText(Log.getWatchedMessages()); + editText.setError(getString(R.string.error_failed_app_registration)); + t.printStackTrace(); + } + }; + + try { + getApiFor(domain) + .authenticateApp(getString(R.string.app_name), getOauthRedirectUri(), + OAUTH_SCOPES, getString(R.string.app_website)) + .enqueue(callback); + } catch (IllegalArgumentException e) { + editText.setError(getString(R.string.error_invalid_domain)); + } + } + } + + + /** + * Chain together the key-value pairs into a query string, for either appending to a URL or + * as the content of an HTTP request. + */ + @NonNull + private static String toQueryString(Map parameters) { + StringBuilder s = new StringBuilder(); + String between = ""; + for (Map.Entry entry : parameters.entrySet()) { + s.append(between); + s.append(Uri.encode(entry.getKey())); + s.append("="); + s.append(Uri.encode(entry.getValue())); + between = "&"; + } + return s.toString(); + } + + private void redirectUserToAuthorizeAndLogin(EditText editText) { + /* To authorize this app and log in it's necessary to redirect to the domain given, + * activity_login there, and the server will redirect back to the app with its response. */ + String endpoint = MastodonAPI.ENDPOINT_AUTHORIZE; + String redirectUri = getOauthRedirectUri(); + Map parameters = new HashMap<>(); + parameters.put("client_id", clientId); + parameters.put("redirect_uri", redirectUri); + parameters.put("response_type", "code"); + parameters.put("scope", OAUTH_SCOPES); + String url = "https://" + domain + endpoint + "?" + toQueryString(parameters); + Intent viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + if (viewIntent.resolveActivity(getPackageManager()) != null) { + startActivity(viewIntent); + } else { + editText.setError(getString(R.string.error_no_web_browser_found)); + } } @Override @@ -350,4 +351,14 @@ public class LoginActivity extends AppCompatActivity { } } } + + private void onLoginSuccess(String accessToken) { + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("domain", domain); + editor.putString("accessToken", accessToken); + editor.commit(); + Intent intent = new Intent(this, MainActivity.class); + startActivity(intent); + finish(); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java index bb25b7161..bfb163990 100644 --- a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java @@ -21,7 +21,6 @@ import android.support.annotation.NonNull; import java.io.IOException; import java.net.InetAddress; import java.net.Socket; -import java.net.UnknownHostException; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -30,6 +29,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import javax.net.ssl.HandshakeCompletedEvent; +import javax.net.ssl.HandshakeCompletedListener; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; @@ -39,11 +40,19 @@ import javax.net.ssl.X509TrustManager; import okhttp3.ConnectionSpec; import okhttp3.OkHttpClient; -import okhttp3.logging.HttpLoggingInterceptor; class OkHttpUtils { - private static final String TAG = "OkHttpUtils"; // logging tag + static final String TAG = "OkHttpUtils"; // logging tag + /** + * Makes a Builder with the maximum range of TLS versions and cipher suites enabled. + * + * It first tries the "approved" list of cipher suites given in OkHttp (the default in + * ConnectionSpec.MODERN_TLS) and if that doesn't work falls back to the set of ALL enabled, + * then falls back to plain http. + * + * TLS 1.1 and 1.2 have to be manually enabled on API levels 16-20. + */ @NonNull static OkHttpClient.Builder getCompatibleClientBuilder() { ConnectionSpec fallback = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) @@ -56,12 +65,8 @@ class OkHttpUtils { specList.add(fallback); specList.add(ConnectionSpec.CLEARTEXT); - HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(); - loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); - OkHttpClient.Builder builder = new OkHttpClient.Builder() - .connectionSpecs(specList) - .addInterceptor(loggingInterceptor); + .connectionSpecs(specList); return enableHigherTlsOnPreLollipop(builder); } @@ -72,7 +77,7 @@ class OkHttpUtils { } private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) { - if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + // if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { try { TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm()); @@ -89,22 +94,23 @@ class OkHttpUtils { sslContext.init(null, new TrustManager[] { trustManager }, null); SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); - builder.sslSocketFactory(new Tls11And12SocketFactory(sslSocketFactory), + builder.sslSocketFactory(new SSLSocketFactoryCompat(sslSocketFactory), trustManager); } catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) { Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage()); } - } + // } return builder; } - private static class Tls11And12SocketFactory extends SSLSocketFactory { - private static final String[] TLS_VERSIONS = { "TLSv1.1", "TLSv1.2" }; + private static class SSLSocketFactoryCompat extends SSLSocketFactory { + private static final String[] DESIRED_TLS_VERSIONS = { "TLSv1", "TLSv1.1", "TLSv1.2", + "TLSv1.3" }; final SSLSocketFactory delegate; - Tls11And12SocketFactory(SSLSocketFactory base) { + SSLSocketFactoryCompat(SSLSocketFactory base) { this.delegate = base; } @@ -125,13 +131,13 @@ class OkHttpUtils { } @Override - public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + public Socket createSocket(String host, int port) throws IOException { return patch(delegate.createSocket(host, port)); } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) - throws IOException, UnknownHostException { + throws IOException { return patch(delegate.createSocket(host, port, localHost, localPort)); } @@ -146,10 +152,52 @@ class OkHttpUtils { return patch(delegate.createSocket(address, port, localAddress, localPort)); } + @NonNull + private static String[] getMatches(String[] wanted, String[] have) { + List a = new ArrayList<>(Arrays.asList(wanted)); + List b = Arrays.asList(have); + a.retainAll(b); + return a.toArray(new String[0]); + } + + @NonNull + private static List getDifferences(String[] wanted, String[] have) { + List a = new ArrayList<>(Arrays.asList(wanted)); + List b = Arrays.asList(have); + a.removeAll(b); + return a; + } + private Socket patch(Socket socket) { if (socket instanceof SSLSocket) { SSLSocket sslSocket = (SSLSocket) socket; - sslSocket.setEnabledProtocols(TLS_VERSIONS); + String[] protocols = getMatches(DESIRED_TLS_VERSIONS, + sslSocket.getSupportedProtocols()); + sslSocket.setEnabledProtocols(protocols); + + // Add a debug listener. + String[] enabledProtocols = sslSocket.getEnabledProtocols(); + List disabledProtocols = getDifferences(sslSocket.getSupportedProtocols(), + enabledProtocols); + String[] enabledSuites = sslSocket.getEnabledCipherSuites(); + List disabledSuites = getDifferences(sslSocket.getSupportedCipherSuites(), + enabledSuites); + Log.i(TAG, "Socket Created-----"); + Log.i(TAG, "enabled protocols: " + Arrays.toString(enabledProtocols)); + Log.i(TAG, "disabled protocols: " + disabledProtocols.toString()); + Log.i(TAG, "enabled cipher suites: " + Arrays.toString(enabledSuites)); + Log.i(TAG, "disabled cipher suites: " + disabledSuites.toString()); + + sslSocket.addHandshakeCompletedListener(new HandshakeCompletedListener() { + @Override + public void handshakeCompleted(HandshakeCompletedEvent event) { + String host = event.getSession().getPeerHost(); + String protocol = event.getSession().getProtocol(); + String cipherSuite = event.getCipherSuite(); + Log.i(TAG, String.format("Handshake: %s %s %s", host, protocol, + cipherSuite)); + } + }); } return socket; } diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 82a87e6e5..9eb444550 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -13,11 +13,34 @@ android:gravity="center" android:layout_height="wrap_content"> + + + + + + + + + + + Date: Wed, 12 Apr 2017 22:26:45 -0400 Subject: [PATCH 3/3] Clears out diagnostic code. --- .../java/com/keylesspalace/tusky/Log.java | 15 -------- .../keylesspalace/tusky/LoginActivity.java | 6 --- .../com/keylesspalace/tusky/OkHttpUtils.java | 38 +------------------ app/src/main/res/layout/activity_login.xml | 23 ----------- 4 files changed, 2 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/Log.java b/app/src/main/java/com/keylesspalace/tusky/Log.java index 982433648..16dc393df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/Log.java +++ b/app/src/main/java/com/keylesspalace/tusky/Log.java @@ -18,15 +18,10 @@ package com.keylesspalace.tusky; /**A wrapper for android.util.Log that allows for disabling logging, such as for release builds.*/ public class Log { private static final boolean LOGGING_ENABLED = BuildConfig.DEBUG; - private static String longBoy; - private static String watchedTag; public static void i(String tag, String string) { if (LOGGING_ENABLED) { android.util.Log.i(tag, string); - if (tag.equals(watchedTag)) { - longBoy += string + '\n'; - } } } @@ -53,14 +48,4 @@ public class Log { android.util.Log.w(tag, string); } } - - static void watchTag(String tag) { - longBoy = ""; - watchedTag = tag; - } - - static String getWatchedMessages() { - watchedTag = null; - return longBoy; - } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java index c1f979278..6974dc714 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/LoginActivity.java @@ -59,7 +59,6 @@ public class LoginActivity extends AppCompatActivity { @BindView(R.id.edit_text_domain) EditText editText; @BindView(R.id.button_login) Button button; @BindView(R.id.whats_an_instance) TextView whatsAnInstance; - @BindView(R.id.debug_log_display) TextView debugLogDisplay; @Override protected void onCreate(Bundle savedInstanceState) { @@ -190,14 +189,11 @@ public class LoginActivity extends AppCompatActivity { clientSecret = prefClientSecret; redirectUserToAuthorizeAndLogin(editText); } else { - Log.watchTag(OkHttpUtils.TAG); - Callback callback = new Callback() { @Override public void onResponse(Call call, Response response) { if (!response.isSuccessful()) { - debugLogDisplay.setText(Log.getWatchedMessages()); editText.setError(getString(R.string.error_failed_app_registration)); Log.e(TAG, "App authentication failed. " + response.message()); return; @@ -209,13 +205,11 @@ public class LoginActivity extends AppCompatActivity { editor.putString(domain + "/client_id", clientId); editor.putString(domain + "/client_secret", clientSecret); editor.apply(); - Log.watchTag(null); redirectUserToAuthorizeAndLogin(editText); } @Override public void onFailure(Call call, Throwable t) { - debugLogDisplay.setText(Log.getWatchedMessages()); editText.setError(getString(R.string.error_failed_app_registration)); t.printStackTrace(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java index bfb163990..58dfb447a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/OkHttpUtils.java @@ -29,8 +29,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import javax.net.ssl.HandshakeCompletedEvent; -import javax.net.ssl.HandshakeCompletedListener; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; @@ -77,7 +75,7 @@ class OkHttpUtils { } private static OkHttpClient.Builder enableHigherTlsOnPreLollipop(OkHttpClient.Builder builder) { - // if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { + if (Build.VERSION.SDK_INT >= 16 && Build.VERSION.SDK_INT < 22) { try { TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm()); @@ -99,7 +97,7 @@ class OkHttpUtils { } catch (NoSuchAlgorithmException|KeyStoreException|KeyManagementException e) { Log.e(TAG, "Failed enabling TLS 1.1 & 1.2. " + e.getMessage()); } - // } + } return builder; } @@ -160,44 +158,12 @@ class OkHttpUtils { return a.toArray(new String[0]); } - @NonNull - private static List getDifferences(String[] wanted, String[] have) { - List a = new ArrayList<>(Arrays.asList(wanted)); - List b = Arrays.asList(have); - a.removeAll(b); - return a; - } - private Socket patch(Socket socket) { if (socket instanceof SSLSocket) { SSLSocket sslSocket = (SSLSocket) socket; String[] protocols = getMatches(DESIRED_TLS_VERSIONS, sslSocket.getSupportedProtocols()); sslSocket.setEnabledProtocols(protocols); - - // Add a debug listener. - String[] enabledProtocols = sslSocket.getEnabledProtocols(); - List disabledProtocols = getDifferences(sslSocket.getSupportedProtocols(), - enabledProtocols); - String[] enabledSuites = sslSocket.getEnabledCipherSuites(); - List disabledSuites = getDifferences(sslSocket.getSupportedCipherSuites(), - enabledSuites); - Log.i(TAG, "Socket Created-----"); - Log.i(TAG, "enabled protocols: " + Arrays.toString(enabledProtocols)); - Log.i(TAG, "disabled protocols: " + disabledProtocols.toString()); - Log.i(TAG, "enabled cipher suites: " + Arrays.toString(enabledSuites)); - Log.i(TAG, "disabled cipher suites: " + disabledSuites.toString()); - - sslSocket.addHandshakeCompletedListener(new HandshakeCompletedListener() { - @Override - public void handshakeCompleted(HandshakeCompletedEvent event) { - String host = event.getSession().getPeerHost(); - String protocol = event.getSession().getProtocol(); - String cipherSuite = event.getCipherSuite(); - Log.i(TAG, String.format("Handshake: %s %s %s", host, protocol, - cipherSuite)); - } - }); } return socket; } diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 9eb444550..82a87e6e5 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -13,34 +13,11 @@ android:gravity="center" android:layout_height="wrap_content"> - - - - - - - - - - -