diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt index 3327ef4443..4aa327c085 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsAdapter.kt @@ -135,6 +135,11 @@ class ManageAccountsAdapter( setImageResource(R.drawable.ic_clean_account) setOnClickListener { accountListener.cleanAccountLocalStorage(account) } } + /// bind listener to manage the client certificate (mTLS) of the account + holder.binding.mtlsAccountButton.apply { + setImageResource(R.drawable.ic_lock) + setOnClickListener { accountListener.manageClientCertificate(account, it) } + } /// bind listener to remove account holder.binding.removeButton.apply { setImageResource(R.drawable.ic_action_delete_grey) @@ -240,6 +245,7 @@ class ManageAccountsAdapter( fun cleanAccountLocalStorage(account: Account) fun createAccount() fun switchAccount(position: Int) + fun manageClientCertificate(account: Account, anchor: View) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt index 6bc7a06546..d471b22b20 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/accounts/ManageAccountsDialogFragment.kt @@ -29,11 +29,14 @@ import android.app.AlertDialog import android.app.Dialog import android.content.Intent import android.os.Bundle +import android.security.KeyChain import android.view.ContextThemeWrapper import android.view.View import android.widget.ImageView import android.widget.LinearLayout +import android.widget.PopupMenu import android.widget.ProgressBar +import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.LinearLayoutManager @@ -44,6 +47,8 @@ import eu.opencloud.android.domain.user.model.UserQuota import eu.opencloud.android.extensions.avoidScreenshotsIfNeeded import eu.opencloud.android.extensions.collectLatestLifecycleFlow import eu.opencloud.android.extensions.showErrorInSnackbar +import eu.opencloud.android.lib.common.SingleSessionManager +import eu.opencloud.android.lib.common.accounts.AccountUtils as LibAccountUtils import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.presentation.common.UIResult import eu.opencloud.android.ui.activity.FileActivity @@ -145,6 +150,60 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc dialog.show() } + /** + * Lets the user set, change or remove the client certificate (mTLS) presented for [account]. + * The chosen alias is stored in the account's userData and the cached HTTP clients are + * invalidated so the next request uses the updated certificate. + */ + override fun manageClientCertificate(account: Account, anchor: View) { + val hasCert = !LibAccountUtils.getClientCertAliasForAccount(requireContext(), account).isNullOrBlank() + PopupMenu(requireContext(), anchor).apply { + menu.add(0, MENU_SET_CERT, 0, getString(R.string.account_mtls_set_cert)) + if (hasCert) { + menu.add(0, MENU_CLEAR_CERT, 1, getString(R.string.account_mtls_clear_cert)) + } + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_SET_CERT -> { + launchClientCertPicker(account) + true + } + MENU_CLEAR_CERT -> { + setAccountClientCertAlias(account, null) + true + } + else -> { + false + } + } + } + show() + } + } + + private fun launchClientCertPicker(account: Account) { + val currentAlias = LibAccountUtils.getClientCertAliasForAccount(requireContext(), account) + KeyChain.choosePrivateKeyAlias( + requireActivity(), + // Null = user cancelled; preserve the current selection. + { alias -> activity?.runOnUiThread { alias?.let { setAccountClientCertAlias(account, it) } } }, + null, null, null, KEYCHAIN_NO_PORT, currentAlias + ) + } + + private fun setAccountClientCertAlias(account: Account, alias: String?) { + AccountManager.get(requireContext().applicationContext) + .setUserData(account, LibAccountUtils.Constants.KEY_MTLS_CERT_ALIAS, alias?.takeIf { it.isNotBlank() }) + // Drop cached clients so the next request renegotiates TLS with the updated certificate. + SingleSessionManager.getDefaultSingleton().invalidateAllClients() + val message = if (alias.isNullOrBlank()) { + getString(R.string.account_mtls_cert_cleared) + } else { + getString(R.string.account_mtls_cert_selected, alias) + } + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() + } + override fun createAccount() { val accountManager = AccountManager.get(MainApp.appContext) accountManager.addAccount( @@ -277,6 +336,12 @@ class ManageAccountsDialogFragment : DialogFragment(), ManageAccountsAdapter.Acc const val MANAGE_ACCOUNTS_DIALOG = "MANAGE_ACCOUNTS_DIALOG" const val KEY_CURRENT_ACCOUNT = "KEY_CURRENT_ACCOUNT" + private const val MENU_SET_CERT = 1 + private const val MENU_CLEAR_CERT = 2 + + // KeyChain.choosePrivateKeyAlias: -1 means no port constraint on the host hint. + private const val KEYCHAIN_NO_PORT = -1 + fun newInstance(currentAccount: Account?): ManageAccountsDialogFragment { val args = Bundle().apply { putParcelable(KEY_CURRENT_ACCOUNT, currentAccount) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index ea440310d9..f470fa426d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -38,6 +38,7 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle +import android.security.KeyChain import android.view.View.INVISIBLE import android.view.WindowManager.LayoutParams.FLAG_SECURE import androidx.appcompat.app.AppCompatActivity @@ -49,6 +50,7 @@ import androidx.core.widget.doAfterTextChanged import eu.opencloud.android.BuildConfig import eu.opencloud.android.MainApp.Companion.accountType import eu.opencloud.android.R +import eu.opencloud.android.data.ClientManager import eu.opencloud.android.data.authentication.KEY_OIDC_ISSUER import eu.opencloud.android.data.authentication.KEY_PREFERRED_USERNAME import eu.opencloud.android.data.authentication.KEY_USER_ID @@ -107,12 +109,20 @@ private const val KEY_AUTH_SERVER_BASE_URL = "KEY_AUTH_SERVER_BASE_URL" private const val KEY_AUTH_OIDC_SUPPORTED = "KEY_AUTH_OIDC_SUPPORTED" private const val KEY_AUTH_LOGIN_ACTION = "KEY_AUTH_LOGIN_ACTION" private const val KEY_AUTH_USER_ACCOUNT = "KEY_AUTH_USER_ACCOUNT" +private const val KEY_AUTH_MTLS_CERT_ALIAS = "KEY_AUTH_MTLS_CERT_ALIAS" + +// KeyChain.choosePrivateKeyAlias: -1 means no port constraint on the host hint. +private const val KEYCHAIN_NO_PORT = -1 class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced { private val authenticationViewModel by viewModel() private val contextProvider by inject() private val mdmProvider by inject() + private val clientManager by inject() + + // Alias (from the Android KeyChain) of the client certificate to present for mTLS during login. + private var clientCertAlias: String? = null private var loginAction: Byte = ACTION_CREATE private var authTokenType: String? = null @@ -213,12 +223,16 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted binding.root.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(this@LoginActivity) + restoreClientCertAlias(savedInstanceState) + initBrandableOptionsUI() binding.thumbnail.setOnClickListener { checkOcServer() } binding.embeddedCheckServerButton.setOnClickListener { checkOcServer() } + binding.mtlsSelectCertButton.setOnClickListener { launchClientCertPicker() } + binding.loginButton.setOnClickListener { if (AccountTypeUtils.getAuthTokenTypeAccessToken(accountType) != authTokenType) { // Basic authenticationViewModel.loginBasic( @@ -258,6 +272,24 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted // getServerInfo() completes (process death recovery flow). } + /** + * Restores the mTLS client certificate alias: from saved state on recreate, from the existing + * account on re-login, or none on a fresh login. Keeps [clientManager]'s transient alias in sync + * so the login connection (server check + login) presents the right certificate before the + * account exists. + */ + private fun restoreClientCertAlias(savedInstanceState: Bundle?) { + clientCertAlias = when { + savedInstanceState != null -> savedInstanceState.getString(KEY_AUTH_MTLS_CERT_ALIAS) + loginAction != ACTION_CREATE -> userAccount?.let { + AccountManager.get(this).getUserData(it, AccountUtils.Constants.KEY_MTLS_CERT_ALIAS) + } + else -> null + } + clientManager.loginClientCertAlias = clientCertAlias + updateClientCertStatus() + } + /** * If this onCreate is an OAuth redirect, either forward it to the existing instance * (when not task root) or let it proceed. Otherwise, track this instance so the @@ -626,6 +658,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted am.setUserData(account, KEY_OIDC_ISSUER, serverInfo.oidcServerConfiguration.issuer) } + // Persist the mTLS client certificate chosen during login to the account, then clear the + // login-time transient so it does not leak into later anonymous clients. + am.setUserData(account, AccountUtils.Constants.KEY_MTLS_CERT_ALIAS, clientCertAlias?.takeIf { it.isNotBlank() }) + clientManager.loginClientCertAlias = null + authenticationViewModel.discoverAccount(accountName = accountName, discoveryNeeded = loginAction == ACTION_CREATE) clearAuthState() } @@ -1084,6 +1121,39 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } } + /** + * Opens the Android KeyChain picker so the user can choose a client certificate for mTLS, + * defaulting to the currently selected alias. The chosen alias is applied to the login client + * immediately so the next server check / login presents it. + */ + private fun launchClientCertPicker() { + KeyChain.choosePrivateKeyAlias( + this, + { alias -> runOnUiThread { onClientCertPicked(alias) } }, + null, null, null, KEYCHAIN_NO_PORT, clientCertAlias + ) + } + + private fun onClientCertPicked(alias: String?) { + // Null = user cancelled the picker; keep the current selection. + if (alias == null) return + clientCertAlias = alias + clientManager.loginClientCertAlias = alias + updateClientCertStatus() + } + + private fun updateClientCertStatus() { + binding.mtlsStatusText.run { + val alias = clientCertAlias + if (alias.isNullOrBlank()) { + isVisible = false + } else { + text = getString(R.string.auth_mtls_cert_selected, alias) + isVisible = true + } + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_AUTH_TOKEN_TYPE, authTokenType) @@ -1094,6 +1164,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) + outState.putString(KEY_AUTH_MTLS_CERT_ALIAS, clientCertAlias) } override fun finish() { diff --git a/opencloudApp/src/main/res/layout-land/account_setup.xml b/opencloudApp/src/main/res/layout-land/account_setup.xml index 81c7251221..0dc6fa824a 100644 --- a/opencloudApp/src/main/res/layout-land/account_setup.xml +++ b/opencloudApp/src/main/res/layout-land/account_setup.xml @@ -200,6 +200,28 @@ android:visibility="gone" /> +