diff --git a/app/AndroidManifest.xml b/app/AndroidManifest.xml index 6b112a6da..72790c4fb 100644 --- a/app/AndroidManifest.xml +++ b/app/AndroidManifest.xml @@ -196,6 +196,15 @@ android:value=".view.AccountListActivity"/> + + + + { + Request.Builder builder = chain.request().newBuilder(); if (!TextUtils.isEmpty(access_token)) { - return chain.proceed( - chain - .request() - .newBuilder() - .header("Authorization", "Bearer " + access_token) - .build()); + builder.header("Authorization", "Bearer " + access_token); } - return chain.proceed(chain.request()); + if (!TextUtils.isEmpty(csrf_token)) { + builder.header("X-CSRF-Token", csrf_token); + } + return chain.proceed(builder.build()); }) .build(); } @@ -263,17 +375,27 @@ private Status uploadFile(StringWriter writer, String fileBase, String fileExt) MediaType.parse("application/" + fileExt + "+xml"), writer.toString())) .build(); + String uploadUrl = getEndpoint(UPLOAD_URL_PATH); + Request request = new Request.Builder() - .url(url + UPLOAD_URL_PATH) - .addHeader("Content-Type", "application/json") + .url(uploadUrl) .method("POST", requestBody) .build(); - int responseCode; - Response response = client.newCall(request).execute(); - - s = response.isSuccessful() ? Status.OK : Status.ERROR; + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful()) { + s = Status.OK; + } else if (response.code() == 401) { + // clear token + access_token = null; + csrf_token = null; + + s = Status.NEED_REFRESH; + } else { + s = Status.ERROR; + } + } } catch (Exception e) { s = Status.ERROR; } @@ -281,6 +403,48 @@ private Status uploadFile(StringWriter writer, String fileBase, String fileExt) return s; } + @NonNull + @Override + public Status refreshToken() { + if (TextUtils.isEmpty(refresh_token)) { + return getNeedAuthStatus(); + } + + String endpoint = getEndpoint(REFRESH_URL_PATH); + + try { + OkHttpClient client = new OkHttpClient(); + RequestBody body = RequestBody.create(MediaType.parse("application/json"), "{}"); + Request request = new Request.Builder() + .url(endpoint) + .addHeader("X-Client-Type", "mobile") + .addHeader("Authorization", "Bearer " + refresh_token) + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (response.isSuccessful()) { + JSONObject obj = new JSONObject(responseBody); + return parseAuthData(obj); + } + + // clear token if response is not successful + access_token = null; + csrf_token = null; + } + } catch (Exception e) { + Log.e(getName(), "refreshToken: exception during request", e); + + // clear token + access_token = null; + csrf_token = null; + } + + return getNeedAuthStatus(); + } + @Override public boolean checkSupport(Feature f) { switch (f) { diff --git a/app/src/main/org/runnerup/export/SyncManager.java b/app/src/main/org/runnerup/export/SyncManager.java index 1c0ae4679..6dfae5c71 100644 --- a/app/src/main/org/runnerup/export/SyncManager.java +++ b/app/src/main/org/runnerup/export/SyncManager.java @@ -38,6 +38,7 @@ import android.view.View; import android.widget.Button; import android.widget.CheckBox; +import android.widget.EditText; import android.widget.TableRow; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; @@ -78,15 +79,18 @@ public class SyncManager { public static final long ERROR_ACTIVITY_ID = -1L; // Id to identify a permission request. private static final int REQUEST_STORAGE = 3003; + private static final String TAG = "SyncManager"; private final Map synchronizers = new HashMap<>(); private final LongSparseArray synchronizersById = new LongSparseArray<>(); private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); PathSimplifier simplifier; + private SQLiteDatabase mDB = null; private AppCompatActivity mActivity = null; private Context mContext = null; private ProgressDialog mSpinner = null; + private Synchronizer authSynchronizer = null; private Callback authCallback = null; private long mID = 0; @@ -334,10 +338,12 @@ private Status handleRefreshComplete(final Synchronizer synchronizer, final Stat } private void handleAuth(Callback callback, final Synchronizer l, AuthMethod authMethod) { + Log.d(TAG, "handleAuth: " + l.getName() + ", method=" + authMethod); authSynchronizer = l; authCallback = callback; switch (authMethod) { case OAUTH2: + Log.d(TAG, "handleAuth: starting OAUTH2 activity"); mActivity.startActivityForResult(l.getAuthIntent(mActivity), CONFIGURE_REQUEST); return; case USER_PASS: @@ -347,6 +353,22 @@ private void handleAuth(Callback callback, final Synchronizer l, AuthMethod auth case FILEPERMISSION: checkStoragePermissions(mActivity); askFileUrl(l); + return; + case MFA: + askMfaCode(l); + return; + case NONE: + // Handle unexpected NONE state, likely error or rate limit + if (mSpinner != null && mSpinner.isShowing()) { + mSpinner.dismiss(); + } + // Maybe show an error dialog? + new AlertDialog.Builder(mActivity) + .setTitle(getResources().getString(org.runnerup.common.R.string.Error)) + .setMessage(getResources().getString(org.runnerup.common.R.string.auth_failed_try_again_later)) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; } } @@ -354,7 +376,8 @@ private void handleAuthComplete(Synchronizer synchronizer, Status s) { Callback cb = authCallback; authCallback = null; authSynchronizer = null; - if (s == Status.OK) { + + if (s == Status.OK || s == Status.NEED_AUTH) { ContentValues tmp = new ContentValues(); tmp.put("_id", synchronizer.getId()); tmp.put(DB.ACCOUNT.AUTH_CONFIG, synchronizer.getAuthConfig()); @@ -367,21 +390,71 @@ private void handleAuthComplete(Synchronizer synchronizer, Status s) { s = Status.ERROR; } } - if (s != Status.OK) { + + if (s != Status.OK && s != Status.NEED_AUTH) { synchronizer.reset(); } - cb.run(synchronizer.getName(), s); + + if (s == Status.NEED_AUTH && cb != null) { + + if (mActivity != null) { + final Callback originalCallback = cb; + final Status authStatus = s; + authCallback = originalCallback; + authSynchronizer = synchronizer; + + mSpinner.show(); + + executor.execute(() -> { + mActivity.runOnUiThread(() -> + handleAuth(originalCallback, synchronizer, authStatus.authMethod) + ); + }); + + return; + } + } + + if (cb != null) { + cb.run(synchronizer.getName(), s); + } } private JSONObject newObj(String str) { try { return new JSONObject(str); } catch (JSONException e) { - e.printStackTrace(); + Log.e(getClass().getName(), "JSON issue", e); } return null; } + private void askMfaCode(final Synchronizer sync) { + final EditText input = new EditText(mActivity); + input.setInputType(InputType.TYPE_CLASS_NUMBER); + + new AlertDialog.Builder(mActivity) + .setTitle("MFA Verification") + .setMessage("Enter the 6-digit code from your authenticator app") + .setView(input) + .setPositiveButton(org.runnerup.common.R.string.OK, (dialog, which) -> { + String code = input.getText().toString(); + String authConfigStr = sync.getAuthConfig(); + JSONObject authConfig = newObj(authConfigStr); + if (authConfig == null) authConfig = new JSONObject(); + + try { + authConfig.put("mfa_code", code); + } catch (JSONException e) { + Log.e(getClass().getName(), "JSON issue", e); + } + + testUserPass(sync, authConfig); + }) + .setNegativeButton(org.runnerup.common.R.string.Cancel, (dialog, which) -> handleAuthComplete(sync, Status.CANCEL)) + .show(); + } + private void askUsernamePassword(final Synchronizer sync, final AuthMethod authMethod) { final View view = View.inflate(mActivity, R.layout.userpass, null); final CheckBox cb = view.findViewById(R.id.showpass); @@ -439,7 +512,31 @@ private void askUsernamePassword(final Synchronizer sync, final AuthMethod authM } testUserPass(sync, authConfig); }) - .setNeutralButton("Skip", (dialog, which) -> handleAuthComplete(sync, Status.SKIP)) + .setNeutralButton( + (authSynchronizer.getName().equals(EndurainSynchronizer.NAME)) + ? "Web Login" : "Skip", + (dialog, which) -> + { + if (authSynchronizer.getName().equals(EndurainSynchronizer.NAME)) { + try { + //noinspection ConstantConditions + authConfig.remove("username"); + authConfig.remove("password"); + authConfig.remove("access_token"); + authConfig.remove("refresh_token"); + authConfig.remove("csrf_token"); + + if (authMethod == AuthMethod.USER_PASS_URL) { + authConfig.put(DB.ACCOUNT.URL, urlInput.getText()); + } + } catch (JSONException e) { + e.printStackTrace(); + } + testUserPass(sync, authConfig); + } else { + handleAuthComplete(sync, Status.SKIP); + } + }) .setNegativeButton( org.runnerup.common.R.string.Cancel, (dialog, which) -> handleAuthComplete(sync, Status.SKIP)) diff --git a/app/src/main/org/runnerup/export/Synchronizer.java b/app/src/main/org/runnerup/export/Synchronizer.java index cf54fc696..7eeb2ff33 100644 --- a/app/src/main/org/runnerup/export/Synchronizer.java +++ b/app/src/main/org/runnerup/export/Synchronizer.java @@ -45,7 +45,8 @@ enum AuthMethod { OAUTH2, USER_PASS, FILEPERMISSION, - USER_PASS_URL + USER_PASS_URL, + MFA } enum Status { diff --git a/app/src/main/org/runnerup/export/util/PKCEUtil.java b/app/src/main/org/runnerup/export/util/PKCEUtil.java new file mode 100644 index 000000000..aee34f6a8 --- /dev/null +++ b/app/src/main/org/runnerup/export/util/PKCEUtil.java @@ -0,0 +1,25 @@ +package org.runnerup.export.util; + +import android.util.Base64; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.io.UnsupportedEncodingException; +import java.security.NoSuchAlgorithmException; + +public class PKCEUtil { + + public static String generateCodeVerifier() { + SecureRandom sr = new SecureRandom(); + byte[] code = new byte[64]; + sr.nextBytes(code); + return Base64.encodeToString(code, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } + + public static String generateCodeChallenge(String verifier) throws NoSuchAlgorithmException, UnsupportedEncodingException { + byte[] bytes = verifier.getBytes("US-ASCII"); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bytes, 0, bytes.length); + byte[] digest = md.digest(); + return Base64.encodeToString(digest, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } +} diff --git a/app/src/main/org/runnerup/view/EndurainLoginActivity.java b/app/src/main/org/runnerup/view/EndurainLoginActivity.java new file mode 100644 index 000000000..ed5e15b7b --- /dev/null +++ b/app/src/main/org/runnerup/view/EndurainLoginActivity.java @@ -0,0 +1,298 @@ +package org.runnerup.view; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONException; +import org.json.JSONObject; +import org.runnerup.export.util.PKCEUtil; + +public class EndurainLoginActivity extends AppCompatActivity { + + public static final String EXTRA_URL = "url"; + public static final String EXTRA_ACCESS_TOKEN = "access_token"; + public static final String EXTRA_REFRESH_TOKEN = "refresh_token"; + public static final String EXTRA_CSRF_TOKEN = "csrf_token"; + private static final String KEY_VERIFIER = "pkce_verifier"; + + private String verifier; + private String instanceUrl; + private WebView webView; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private boolean isFinishing = false; + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + webView = new WebView(this); + setContentView(webView); + + instanceUrl = getIntent().getStringExtra(EXTRA_URL); + if (instanceUrl == null) { + finish(); + return; + } + + if (instanceUrl.endsWith("/")) { + instanceUrl = instanceUrl.substring(0, instanceUrl.length() - 1); + } + if (instanceUrl.endsWith("/login")) { + instanceUrl = instanceUrl.substring(0, instanceUrl.length() - 6); + } + + webView.getSettings().setJavaScriptEnabled(true); + webView.getSettings().setDomStorageEnabled(true); + + if (savedInstanceState != null) { + verifier = savedInstanceState.getString(KEY_VERIFIER); + webView.restoreState(savedInstanceState); + } + + if (verifier == null) { + verifier = PKCEUtil.generateCodeVerifier(); + } + + if (savedInstanceState == null) { + fetchIdpsAndLoad(); + } else { + setupWebViewClient(); + } + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putString(KEY_VERIFIER, verifier); + webView.saveState(outState); + } + + private void fetchIdpsAndLoad() { + if (executor.isShutdown()) return; + executor.execute(() -> { + runOnUiThread(() -> { + setupWebViewClient(); + loadWebView(instanceUrl + "/login"); + }); + }); + } + + private void setupWebViewClient() { + try { + final String challenge = PKCEUtil.generateCodeChallenge(verifier); + + webView.setWebViewClient(new WebViewClient() { + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + checkUrlAndExtractTokens(view, url, challenge); + } + + @Override + public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) { + checkUrlAndExtractTokens(view, url, challenge); + super.doUpdateVisitedHistory(view, url, isReload); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + String url = request.getUrl().toString(); + return handleOverride(view, url, challenge); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return handleOverride(view, url, challenge); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private boolean handleOverride(WebView view, String url, String challenge) { + if (isFinishing) return true; + + if (url.contains("sso=success") && url.contains("session_id=")) { + Uri uri = Uri.parse(url); + String sessionId = uri.getQueryParameter("session_id"); + + if (sessionId == null || sessionId.trim().isEmpty()) { + Log.i("EndurainLoginActivity", "SSO redirect missing valid session_id"); + setResult(Activity.RESULT_CANCELED); + finish(); + return true; + } + + exchangeSessionForTokens(sessionId); + return true; + } + + if (url.contains("/api/v1/public/idp/login/")) { + if (!url.contains("code_challenge=" + challenge)) { + + Uri baseUri = Uri.parse(url); + Uri.Builder newUri = baseUri.buildUpon(); + + newUri.clearQuery(); + + newUri.appendQueryParameter("code_challenge", challenge); + newUri.appendQueryParameter("code_challenge_method", "S256"); + + view.loadUrl(newUri.build().toString()); + return true; + } + } + return false; + } + + private void checkUrlAndExtractTokens(WebView view, String url, String challenge) { + if (isFinishing) return; + + if (url.contains("sso=success") && url.contains("session_id=")) { + view.stopLoading(); + Uri uri = Uri.parse(url); + String sessionId = uri.getQueryParameter("session_id"); + exchangeSessionForTokens(sessionId); + return; + } + + if (url.contains("/api/v1/public/idp/login/")) { + if (!url.contains("code_challenge=" + challenge)) { + Log.i("EndurainLoginActivity", "Intercepted SSO login load, reloading with Android PKCE"); + view.stopLoading(); + + Uri baseUri = Uri.parse(url); + Uri.Builder newUri = baseUri.buildUpon(); + newUri.clearQuery(); + newUri.appendQueryParameter("code_challenge", challenge); + newUri.appendQueryParameter("code_challenge_method", "S256"); + + view.loadUrl(newUri.build().toString()); + return; + } + } + + if (url.equals(instanceUrl + "/") || url.equals(instanceUrl) || (url.startsWith(instanceUrl) && !url.contains("/login") && !url.contains("/api/"))) { + Log.i("EndurainLogin", "Detected authenticated page load: " + url); + + view.evaluateJavascript( + "(function() { return localStorage.getItem('access_token'); })();", + value -> { + if (value != null && !value.equals("null")) { + String token = value.replaceAll("^\"|\"$", ""); + view.evaluateJavascript( + "(function() { return localStorage.getItem('refresh_token'); })();", + refreshValue -> { + String refreshToken = null; + if (refreshValue != null && !refreshValue.equals("null")) { + refreshToken = refreshValue.replaceAll("^\"|\"$", ""); + } + finishWithSuccess(token, refreshToken, null); + } + ); + } + } + ); + } + } + + private void loadWebView(String url) { + webView.loadUrl(url); + } + + private void finishWithSuccess(String accessToken, String refreshToken, String csrfToken) { + if (isFinishing) return; + isFinishing = true; + + Intent result = new Intent(); + if (accessToken != null) result.putExtra(EXTRA_ACCESS_TOKEN, accessToken); + if (refreshToken != null) result.putExtra(EXTRA_REFRESH_TOKEN, refreshToken); + if (csrfToken != null) result.putExtra(EXTRA_CSRF_TOKEN, csrfToken); + + setResult(Activity.RESULT_OK, result); + finish(); + } + + @Override + protected void onDestroy() { + isFinishing = true; + + if (webView != null) { + webView.stopLoading(); + webView.setWebViewClient(null); + webView.destroy(); + webView = null; + } + + executor.shutdown(); + super.onDestroy(); + } + + private void exchangeSessionForTokens(String sessionId) { + if (executor.isShutdown()) return; + executor.execute(() -> { + boolean success = false; + OkHttpClient client = new OkHttpClient(); + MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + JSONObject jsonBody = new JSONObject(); + try { + jsonBody.put("code_verifier", verifier); + } catch (JSONException e) { + Log.i("EndurainLoginActivity", "JSON issue", e); + } + + RequestBody body = RequestBody.create(JSON, jsonBody.toString()); + Request request = new Request.Builder() + .url(instanceUrl + "/api/v1/public/idp/session/" + sessionId + "/tokens") + .addHeader("X-Client-Type", "mobile") + .post(body) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + String responseString = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseString); + + String accessToken = jsonResponse.optString("access_token", null); + String refreshToken = jsonResponse.optString("refresh_token", null); + String csrfToken = jsonResponse.optString("csrf_token", null); + + runOnUiThread(() -> finishWithSuccess(accessToken, refreshToken, csrfToken)); + success = true; + } else { + String errorBody = response.body() != null ? response.body().string() : ""; + Log.i("EndurainLoginActivity", "Token exchange failed: " + response.code() + " " + response.message() + " Body: " + errorBody); + } + } catch (IOException | JSONException e) { + Log.i("EndurainLoginActivity", "Token exchange failed", e); + } + + if (!success) { + runOnUiThread(() -> { + if (!isFinishing) { + setResult(Activity.RESULT_CANCELED); + finish(); + } + }); + } + }); + } +} \ No newline at end of file diff --git a/common/src/main/res/values-de/strings.xml b/common/src/main/res/values-de/strings.xml index 2e27ffc68..860f5b1a1 100644 --- a/common/src/main/res/values-de/strings.xml +++ b/common/src/main/res/values-de/strings.xml @@ -367,4 +367,7 @@ Für die Wartung der Daten kann die Datei runnerup.db.export von/nach %1$s im-/e Laufen Radfahren Andere + Authentifizierung fehlgeschlagen oder Rate-Limit überschritten. Bitte versuchen Sie es später noch einmal. + + diff --git a/common/src/main/res/values-fr/strings.xml b/common/src/main/res/values-fr/strings.xml index 46921b6ca..ffa1839e5 100644 --- a/common/src/main/res/values-fr/strings.xml +++ b/common/src/main/res/values-fr/strings.xml @@ -359,4 +359,5 @@ Course Vélo Autre + Échec de l\'authentification ou limite de débit dépassée. Veuillez réessayer plus tard. diff --git a/common/src/main/res/values-it/strings.xml b/common/src/main/res/values-it/strings.xml index 24bea05cb..8c30064bb 100644 --- a/common/src/main/res/values-it/strings.xml +++ b/common/src/main/res/values-it/strings.xml @@ -355,4 +355,5 @@ Corsa Bici Altro + Autenticazione fallita o limite di frequenza superato. Si prega di riprovare più tardi. diff --git a/common/src/main/res/values-pt/strings.xml b/common/src/main/res/values-pt/strings.xml index 3b3199471..8415c655a 100644 --- a/common/src/main/res/values-pt/strings.xml +++ b/common/src/main/res/values-pt/strings.xml @@ -353,4 +353,5 @@ Registar temperatura Obter a velocidade a partir do sensor GPS em vez de a calcular a partir das posições GPS Definir o período de cálculo da média móvel para informar sobre o objetivo e o período de carência para os avisos de áudio + Falha na autenticação ou limite de taxa excedido. Por favor, tente novamente mais tarde. diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index a037ce2d5..e16d506c7 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -380,4 +380,6 @@ Import cancelled Export cancelled Selected file is not a valid RunnerUp database! + + Authentication failed or rate limit exceeded. Please try again later.