Merge pull request #12922 from nextcloud/feature/login_flow_v2

Login Flow v2
This commit is contained in:
Tobias Kaminsky 2024-04-29 14:30:10 +02:00 committed by GitHub
commit 3755df0e5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 262 additions and 36 deletions

View File

@ -17,6 +17,7 @@ import android.Manifest;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@ -33,29 +34,42 @@ import android.os.IBinder;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.AndroidRuntimeException;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.TextView.OnEditorActionListener;
import android.widget.Toast;
import com.blikoon.qrcodescanner.QrCodeActivity;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.snackbar.Snackbar;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.nextcloud.android.common.ui.color.ColorUtil;
import com.nextcloud.android.common.ui.theme.utils.ColorRole;
import com.nextcloud.client.account.User;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.device.DeviceInfo;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.network.ClientFactory;
import com.nextcloud.client.onboarding.FirstRunActivity;
import com.nextcloud.client.onboarding.OnboardingService;
import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.common.PlainClient;
import com.nextcloud.operations.PostMethod;
import com.nextcloud.utils.extensions.BundleExtensionsKt;
import com.owncloud.android.MainApp;
import com.owncloud.android.R;
@ -100,6 +114,8 @@ import com.owncloud.android.utils.WebViewUtil;
import com.owncloud.android.utils.theme.CapabilityUtils;
import com.owncloud.android.utils.theme.ViewThemeUtils;
import org.json.JSONObject;
import java.io.InputStream;
import java.net.URLDecoder;
import java.util.HashMap;
@ -107,9 +123,13 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -125,6 +145,8 @@ import de.cotech.hw.fido.ui.FidoDialogOptions;
import de.cotech.hw.fido2.WebViewWebauthnBridge;
import de.cotech.hw.fido2.ui.WebauthnDialogOptions;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import okhttp3.FormBody;
import okhttp3.RequestBody;
import static com.owncloud.android.utils.PermissionUtil.PERMISSIONS_CAMERA;
@ -162,7 +184,17 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
private static final String KEY_USERNAME = "USERNAME";
private static final String KEY_PASSWORD = "PASSWORD";
private static final String KEY_ASYNC_TASK_IN_PROGRESS = "AUTH_IN_PROGRESS";
public static final String WEB_LOGIN = "/index.php/login/flow";
/**
* Login Flow v1
*/
// public static final String WEB_LOGIN = "/index.php/login/flow";
/**
* Login Flow v2
*/
public static final String WEB_LOGIN = "/index.php/login/v2";
public static final String PROTOCOL_SUFFIX = "://";
public static final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":";
public static final String HTTPS_PROTOCOL = "https://";
@ -171,7 +203,6 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
public static final int NO_ICON = 0;
public static final String EMPTY_STRING = "";
private static final int REQUEST_CODE_QR_SCAN = 101;
public static final int REQUEST_CODE_FIRST_RUN = 102;
/// parameters from EXTRAs in starter Intent
@ -218,6 +249,9 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
@Inject PassCodeManager passCodeManager;
@Inject ViewThemeUtils.Factory viewThemeUtilsFactory;
@Inject ColorUtil colorUtil;
@Inject ClientFactory clientFactory;
private String token;
private boolean onlyAdd = false;
@SuppressLint("ResourceAsColor") @ColorInt
@ -242,7 +276,7 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
viewThemeUtils = viewThemeUtilsFactory.withPrimaryAsBackground();
viewThemeUtils.platform.themeStatusBar(this, ColorRole.PRIMARY);
WebViewUtil webViewUtil = new WebViewUtil(this);
// WebViewUtil webViewUtil = new WebViewUtil(this);
Uri data = getIntent().getData();
boolean directLogin = data != null && data.toString().startsWith(getString(R.string.login_data_own_scheme));
@ -298,7 +332,8 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
if (webViewLoginMethod) {
accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater());
setContentView(accountSetupWebviewBinding.getRoot());
initWebViewLogin(webloginUrl, false);
anonymouslyPostLoginRequest(webloginUrl);
// initWebViewLogin(webloginUrl, false);
} else {
accountSetupBinding = AccountSetupBinding.inflate(getLayoutInflater());
setContentView(accountSetupBinding.getRoot());
@ -314,7 +349,7 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
initServerPreFragment(savedInstanceState);
webViewUtil.checkWebViewVersion();
// webViewUtil.checkWebViewVersion();
}
private void deleteCookies() {
@ -326,11 +361,57 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
}
}
private String baseUrl;
/**
* This function facilitates the login process by anonymously posting a login request to a specified URL.
* After posting the request, it retrieves the login URL for completing the login flow.
* The login flow version used is v2.
*
* @param url The URL where the login request is to be anonymously posted.
* This URL should handle the login request and return the login URL.
* It's typically the entry point for the login process.
* Example: "https://example.com/index.php/login/v2"
*/
private void anonymouslyPostLoginRequest(String url) {
baseUrl = url;
Thread thread = new Thread(() -> {
PostMethod post = new PostMethod(baseUrl, false, new FormBody.Builder().build());
PlainClient client = clientFactory.createPlainClient();
post.execute(client);
String response = post.getResponseBodyAsString();
JsonObject jsonObject = JsonParser.parseString(response).getAsJsonObject();
String login = jsonObject.get("login").getAsString();
if (login == null) {
login = getResources().getString(R.string.webview_login_url);
}
String loginUrl = login;
runOnUiThread(() -> {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl));
loginFlowResultLauncher.launch(intent);
});
token = jsonObject.getAsJsonObject("poll").get("token").getAsString();
});
thread.start();
}
private final ActivityResultLauncher<Intent> loginFlowResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), result -> poolLogin(clientFactory.createPlainClient()));
private static String getWebLoginUserAgent() {
return Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) + " " + Build.MODEL + " (Android)";
}
/**
* @Deprecated This function is deprecated. Please use the {@link #anonymouslyPostLoginRequest(String)} method instead, which utilizes the improved login flow v2.
*/
@Deprecated
@SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT")
@SuppressLint("SetJavaScriptEnabled")
private void initWebViewLogin(String baseURL, boolean useGenericUserAgent) {
@ -680,7 +761,8 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
if (intent.getBooleanExtra(EXTRA_USE_PROVIDER_AS_WEBLOGIN, false)) {
accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater());
setContentView(accountSetupWebviewBinding.getRoot());
initWebViewLogin(getString(R.string.provider_registration_server), true);
anonymouslyPostLoginRequest(getString(R.string.provider_registration_server));
// initWebViewLogin(getString(R.string.provider_registration_server), true);
}
}
@ -915,7 +997,16 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater());
setContentView(accountSetupWebviewBinding.getRoot());
initWebViewLogin(mServerInfo.mBaseUrl + WEB_LOGIN, false);
if (!isLoginProcessCompleted) {
if (!isRedirectedToTheDefaultBrowser) {
anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN);
isRedirectedToTheDefaultBrowser = true;
} else {
initLoginInfoView();
}
// initWebViewLogin(mServerInfo.mBaseUrl + WEB_LOGIN, false);
}
}
} else {
updateServerStatusIconAndText(result);
@ -928,6 +1019,19 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
}
}
// region LoginInfoView
private void initLoginInfoView() {
LinearLayout loginFlowLayout = accountSetupWebviewBinding.loginFlowV2.getRoot();
MaterialButton cancelButton = accountSetupWebviewBinding.loginFlowV2.cancelButton;
loginFlowLayout.setVisibility(View.VISIBLE);
cancelButton.setOnClickListener(v -> {
loginFlowExecutorService.shutdown();
recreate();
});
}
// endregion
/**
* Chooses the right icon and text to show to the user for the received operation result.
*
@ -1169,7 +1273,8 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
} else { // authorization fail due to client side - probably wrong credentials
if (accountSetupWebviewBinding != null) {
initWebViewLogin(mServerInfo.mBaseUrl + WEB_LOGIN, false);
anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN);
// initWebViewLogin(mServerInfo.mBaseUrl + WEB_LOGIN, false);
DisplayUtils.showSnackMessage(this,
accountSetupWebviewBinding.loginWebview, R.string.auth_access_failed,
result.getLogMessage());
@ -1340,9 +1445,37 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
private void startQRScanner() {
Intent intent = new Intent(this, QrCodeActivity.class);
startActivityForResult(intent, REQUEST_CODE_QR_SCAN);
qrScanResultLauncher.launch(intent);
}
private final ActivityResultLauncher<Intent> qrScanResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data == null) {
return;
}
String resultData = data.getStringExtra("com.blikoon.qrcodescanner.got_qr_scan_relult");
if (resultData == null || !resultData.startsWith(getString(R.string.login_data_own_scheme))) {
mServerStatusIcon = R.drawable.ic_alert;
mServerStatusText = "QR Code could not be read!";
showServerStatus();
return;
}
if (!getResources().getBoolean(R.bool.multiaccount_support) &&
accountManager.getAccounts().length == 1) {
Toast.makeText(this, R.string.no_mutliple_accounts_allowed, Toast.LENGTH_LONG).show();
} else {
parseAndLoginFromWebView(resultData);
}
}
});
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@ -1362,7 +1495,9 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
* server.
*/
private void showServerStatus() {
if (accountSetupBinding == null) return;
if (accountSetupBinding == null) {
return;
}
if (mServerStatusIcon == NO_ICON && EMPTY_STRING.equals(mServerStatusText)) {
accountSetupBinding.serverStatusText.setVisibility(View.INVISIBLE);
@ -1486,30 +1621,67 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_QR_SCAN) {
if (data == null) {
return;
private final ScheduledExecutorService loginFlowExecutorService = Executors.newSingleThreadScheduledExecutor();
private boolean isLoginProcessCompleted = false;
private boolean isRedirectedToTheDefaultBrowser = false;
private void poolLogin(PlainClient client) {
loginFlowExecutorService.scheduleAtFixedRate(() -> {
if (!isLoginProcessCompleted) {
performLoginFlowV2(client);
}
}, 0, 30, TimeUnit.SECONDS);
}
String result = data.getStringExtra("com.blikoon.qrcodescanner.got_qr_scan_relult");
private void performLoginFlowV2(PlainClient client) {
String postRequestUrl = baseUrl + "/poll";
if (result == null || !result.startsWith(getString(R.string.login_data_own_scheme))) {
RequestBody requestBody = new FormBody.Builder()
.add("token", token)
.build();
PostMethod post = new PostMethod(postRequestUrl, false, requestBody);
int status = post.execute(client);
String response = post.getResponseBodyAsString();
Log_OC.d(TAG, "performLoginFlowV2 status: " + status);
Log_OC.d(TAG, "performLoginFlowV2 response: " + response);
if (!response.isEmpty()) {
runOnUiThread(() -> completeLoginFlow(response, status));
}
}
private void completeLoginFlow(String response, int status) {
try {
JSONObject jsonObject = new JSONObject(response);
String server = jsonObject.getString("server");
String loginName = jsonObject.getString("loginName");
String appPassword = jsonObject.getString("appPassword");
LoginUrlInfo loginUrlInfo = new LoginUrlInfo();
loginUrlInfo.serverAddress = server;
loginUrlInfo.username = loginName;
loginUrlInfo.password = appPassword;
isLoginProcessCompleted = (status == 200 && !server.isEmpty() && !loginName.isEmpty() && !appPassword.isEmpty());
if (accountSetupBinding != null) {
accountSetupBinding.hostUrlInput.setText("");
}
mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.serverAddress);
webViewUser = loginUrlInfo.username;
webViewPassword = loginUrlInfo.password;
} catch (Exception e) {
Log_OC.d(TAG, "Error caught at completeLoginFlow: " + e);
mServerStatusIcon = R.drawable.ic_alert;
mServerStatusText = "QR Code could not be read!";
mServerStatusText = getString(R.string.qr_could_not_be_read);
showServerStatus();
return;
}
if (!getResources().getBoolean(R.bool.multiaccount_support) &&
accountManager.getAccounts().length == 1) {
Toast.makeText(this, R.string.no_mutliple_accounts_allowed, Toast.LENGTH_LONG).show();
} else {
parseAndLoginFromWebView(result);
}
}
checkOcServer();
loginFlowExecutorService.shutdown();
}
/**

View File

@ -7,21 +7,31 @@
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<!-- Login Flow V1 -->
<WebView
android:visibility="gone"
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/login_webview">
</WebView>
<!-- Login Flow V2 -->
<include
tools:visibility="visible"
android:visibility="gone"
android:id="@+id/login_flow_v2"
layout="@layout/login_flow_info_layout_v2" />
<ProgressBar
android:id="@+id/login_webview_progress_bar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"/>
android:indeterminate="true" />
</FrameLayout>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud - Android Client
~
~ SPDX-FileCopyrightText: 2024 Your Name <your@email.com>
~ SPDX-License-Identifier: AGPL-3.0-or-later
-->
<LinearLayout
android:id="@+id/login_flow_info_v2"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical"
android:gravity="center"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:text="@string/authenticator_activity_please_complete_login_process"
android:layout_width="match_parent"
android:gravity="center"
android:textColor="@color/white"
android:textSize="@dimen/splash_text_size"
android:layout_marginBottom="@dimen/standard_double_margin"
android:layout_height="wrap_content"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel_button"
app:backgroundTint="@color/white"
android:textColor="@color/black"
android:theme="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="300dp"
android:layout_gravity="center"
android:gravity="center"
app:strokeColor="@color/white"
android:layout_marginBottom="@dimen/standard_double_margin"
android:layout_height="wrap_content"
app:cornerRadius="@dimen/button_corner_radius"
android:text="@string/authenticator_activity_cancel_login" />
</LinearLayout>

View File

@ -334,6 +334,9 @@
<string name="auth_account_does_not_exist">The account is not added on this device yet</string>
<string name="auth_access_failed">Access failed: %1$s</string>
<string name="authenticator_activity_cancel_login">Cancel Login</string>
<string name="authenticator_activity_please_complete_login_process">Please complete login process in your browser</string>
<string name="favorite">Add to favorites</string>
<string name="unset_favorite">Remove from favourites</string>
<string name="encrypted">Set as encrypted</string>