diff --git a/app/build.gradle b/app/build.gradle index dace8855f73142315e4d13249220fedbd5215f2a..417df1af904a5532c588452b5cfeec4553ed4011 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,7 @@ android { compileSdkVersion 32 buildToolsVersion '32.0.0' + defaultConfig { applicationId "at.bitfire.davdroid" @@ -152,6 +153,8 @@ dependencies { //noinspection GradleDependency implementation "org.apache.commons:commons-text:${versions.commonsText}" + implementation 'net.openid:appauth:0.7.0' + // for tests androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}" kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.hilt}" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99f49fac6d0757c45c4acfa29b39f6fc185a18a1..4fe931f7570e34c513cd2723a8aab397ed6d555a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -150,6 +150,62 @@ android:label="@string/webdav_add_mount_title" android:parentActivityName=".ui.webdav.WebdavMountsActivity" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROVIDERS = Arrays.asList( + GOOGLE); + + public static List getEnabledProviders(Context context) + { + ArrayList providers = new ArrayList<>(); + for (IdentityProvider provider : PROVIDERS) + { + provider.readConfiguration(context); + providers.add(provider); + } + return providers; + } + + @NonNull + public final String name; + + @StringRes + public final int buttonContentDescriptionRes; + + @StringRes + private final int mDiscoveryEndpointRes; + + @StringRes + private final int mAuthEndpointRes; + + @StringRes + private final int mTokenEndpointRes; + + @StringRes + private final int mClientIdRes; + + @StringRes + private final int mClientSecretRes; + + @StringRes + private final int mRedirectUriRes; + + @StringRes + private final int mScopeRes; + + private boolean mConfigurationRead = false; + private Uri mDiscoveryEndpoint; + private Uri mAuthEndpoint; + private Uri mTokenEndpoint; + private String mClientId; + private String mClientSecret; + private Uri mRedirectUri; + private String mScope; + + IdentityProvider( + @NonNull String name, + @StringRes int discoveryEndpointRes, + @StringRes int authEndpointRes, + @StringRes int tokenEndpointRes, + @StringRes int clientIdRes, + @StringRes int clientSecretRes, + @StringRes int redirectUriRes, + @StringRes int scopeRes, + @StringRes int buttonContentDescriptionRes) + { + if (!isSpecified(discoveryEndpointRes) + && !isSpecified(authEndpointRes) + && !isSpecified(tokenEndpointRes)) + { + throw new IllegalArgumentException( + "the discovery endpoint or the auth and token endpoints must be specified"); + } + + this.name = name; + this.mDiscoveryEndpointRes = discoveryEndpointRes; + this.mAuthEndpointRes = authEndpointRes; + this.mTokenEndpointRes = tokenEndpointRes; + this.mClientIdRes = checkSpecified(clientIdRes, "clientIdRes"); + this.mClientSecretRes = clientSecretRes; + this.mRedirectUriRes = checkSpecified(redirectUriRes, "redirectUriRes"); + this.mScopeRes = checkSpecified(scopeRes, "scopeRes"); + this.buttonContentDescriptionRes = + checkSpecified(buttonContentDescriptionRes, "buttonContentDescriptionRes"); + } + + /** + * This must be called before any of the getters will function. + */ + public void readConfiguration(Context context) + { + if (mConfigurationRead) + { + return; + } + + Resources res = context.getResources(); + + mDiscoveryEndpoint = isSpecified(mDiscoveryEndpointRes) + ? getUriResource(res, mDiscoveryEndpointRes, "discoveryEndpointRes") + : null; + mAuthEndpoint = isSpecified(mAuthEndpointRes) + ? getUriResource(res, mAuthEndpointRes, "authEndpointRes") + : null; + mTokenEndpoint = isSpecified(mTokenEndpointRes) + ? getUriResource(res, mTokenEndpointRes, "tokenEndpointRes") + : null; + mClientId = res.getString(mClientIdRes); + mClientSecret = isSpecified(mClientSecretRes) ? res.getString(mClientSecretRes) : null; + mRedirectUri = getUriResource(res, mRedirectUriRes, "mRedirectUriRes"); + mScope = res.getString(mScopeRes); + + mConfigurationRead = true; + } + + private void checkConfigurationRead() + { + if (!mConfigurationRead) + { + throw new IllegalStateException("Configuration not read"); + } + } + + @Nullable + public Uri getDiscoveryEndpoint() + { + checkConfigurationRead(); + return mDiscoveryEndpoint; + } + + @Nullable + public Uri getAuthEndpoint() + { + checkConfigurationRead(); + return mAuthEndpoint; + } + + @Nullable + public Uri getTokenEndpoint() + { + checkConfigurationRead(); + return mTokenEndpoint; + } + + @NonNull + public String getClientId() + { + checkConfigurationRead(); + return mClientId; + } + + @Nullable + public String getClientSecret() + { + checkConfigurationRead(); + return mClientSecret; + } + + @NonNull + public Uri getRedirectUri() + { + checkConfigurationRead(); + return mRedirectUri; + } + + @NonNull + public String getScope() + { + checkConfigurationRead(); + return mScope; + } + + public void retrieveConfig(Context context, + RetrieveConfigurationCallback callback) + { + readConfiguration(context); + if (getDiscoveryEndpoint() != null) + { + AuthorizationServiceConfiguration.fetchFromUrl(mDiscoveryEndpoint, callback); + } + else + { + AuthorizationServiceConfiguration config = + new AuthorizationServiceConfiguration(mAuthEndpoint, mTokenEndpoint, null); + callback.onFetchConfigurationCompleted(config, null); + } + } + + private static boolean isSpecified(int value) + { + return value != NOT_SPECIFIED; + } + + private static int checkSpecified(int value, String valueName) + { + if (value == NOT_SPECIFIED) + { + throw new IllegalArgumentException(valueName + " must be specified"); + } + return value; + } + + private static Uri getUriResource(Resources res, @StringRes int resId, String resName) + { + return Uri.parse(res.getString(resId)); + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt index 2ed99e26f96970406c7a01b99f944a0dcae858bf..b4b80ef3db80a3fa1357dc694e76714c18d62a1e 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt @@ -252,7 +252,6 @@ abstract class AppDatabase: RoomDatabase() { // helpers - fun dump(writer: Writer, ignoreTables: Array) { val db = openHelper.readableDatabase db.beginTransactionNonExclusive() diff --git a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt index f2f4758b1185a3000dd4896e43b2c498ec182ad8..9b43df98c57312a950eaf2e169b64a8d8ba56967 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt @@ -4,15 +4,41 @@ package at.bitfire.davdroid.db +import net.openid.appauth.AuthState +import java.net.URI + + data class Credentials( - val userName: String? = null, - val password: String? = null, - val certificateAlias: String? = null + val userName: String? = null, + val password: String? = null, + val authState: AuthState? = null, + val certificateAlias: String? = null, + val serverUri: URI? = null ) { - override fun toString(): String { - val maskedPassword = "*****".takeIf { password != null } - return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)" + enum class Type { + UsernamePassword, + OAuth, + ClientCertificate } -} \ No newline at end of file + val type: Type + + init { + type = when { + !certificateAlias.isNullOrEmpty() -> + Type.ClientCertificate + !userName.isNullOrEmpty() && (authState != null) -> + Type.OAuth + !userName.isNullOrEmpty() && !password.isNullOrEmpty() -> + Type.UsernamePassword + else -> + throw IllegalArgumentException("Invalid account type/credentials") + } + } + + override fun toString() = + "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)" + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt index 8d414c121ab07cd9c17585c76b5b4fa6f05ef8c1..babbb08443bfd841b451a41db410fbe0d807dad0 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -47,6 +47,7 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.property.Url +import net.openid.appauth.AuthState import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.StringUtils import org.dmfs.tasks.contract.TaskContract @@ -89,6 +90,9 @@ class AccountSettings( const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks" const val KEY_USERNAME = "user_name" + const val KEY_EMAIL_ADDRESS = "email_address" + const val KEY_AUTH_STATE = "auth_state" + const val KEY_CERTIFICATE_ALIAS = "certificate_alias" const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false) @@ -139,10 +143,12 @@ class AccountSettings( bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) if (credentials != null) { + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) if (credentials.userName != null) bundle.putString(KEY_USERNAME, credentials.userName) if (credentials.certificateAlias != null) bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } return bundle @@ -229,16 +235,45 @@ class AccountSettings( // authentication settings - fun credentials() = Credentials( - accountManager.getUserData(account, KEY_USERNAME), - accountManager.getPassword(account), - accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) - ) +// fun credentials() = Credentials( +// +//// accountManager.getUserData(account, KEY_USERNAME), +//// accountManager.getPassword(account), +//// null, +//// accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) +// ) + + fun credentials(): Credentials { + if (accountManager.getUserData(account, KEY_AUTH_STATE).isNullOrEmpty()) { + return Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + null, + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)) + } + else { + return Credentials( + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + AuthState.jsonDeserialize(accountManager.getUserData(account, KEY_AUTH_STATE)), + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)) + } + } + fun credentials(credentials: Credentials) { - accountManager.setUserData(account, KEY_USERNAME, credentials.userName) - accountManager.setPassword(account, credentials.password) - accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + if (credentials.authState == null) { + accountManager.setUserData(account, KEY_USERNAME, credentials.userName) + accountManager.setPassword(account, credentials.password) + accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } + else { + accountManager.setUserData(account, KEY_USERNAME, credentials.userName) + accountManager.setPassword(account, credentials.password) + accountManager.setUserData(account, KEY_AUTH_STATE, credentials.authState.jsonSerializeString()) + accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) + } + } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..57165ec4393b2295c8889851db534732e5f840ba --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,155 @@ +package foundation.e.accountmanager.syncadapter + +/* + * Copyright ECORP SAS 2022 + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.ui.setup.LoginActivity +import at.bitfire.davdroid.R +import at.bitfire.davdroid.settings.AccountSettings +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import java.util.logging.Level +import kotlin.concurrent.thread + +/** + * Account authenticator for the Google account type. + * + * Gets started when a Google account is removed, too, so it also watches for account removals + * and contains the corresponding cleanup code. + */ +class GoogleAccountAuthenticatorService : Service(), OnAccountsUpdateListener { + + companion object { + + fun cleanupAccounts(context: Context) { + Logger.log.info("Cleaning up orphaned accounts") + + val accountManager = AccountManager.get(context) + + val accountNames = HashSet() + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) } + for (account in accounts.toTypedArray()) + accountNames += account.name + + // delete orphaned address book accounts + val addressBookAccounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { addressBookAccounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { addressBookAccounts.add(it) } + addressBookAccounts.map { LocalAddressBook(context, it, null) } + .forEach { + try { + if (!accountNames.contains(it.mainAccount.name)) + it.delete() + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e) + } + } + + // delete orphaned services in DB + val db = AppDatabase.getInstance(context) + val serviceDao = db.serviceDao() + if (accountNames.isEmpty()) + serviceDao.deleteAll() + else + serviceDao.deleteExceptAccounts(accountNames.toTypedArray()) + } + + } + + private lateinit var accountManager: AccountManager + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountManager = AccountManager.get(this) + accountManager.addOnAccountsUpdatedListener(this, null, true) + + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onDestroy() { + super.onDestroy() + accountManager.removeOnAccountsUpdatedListener(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + override fun onAccountsUpdated(accounts: Array?) { + thread { + cleanupAccounts(this) + } + } + + + private class AccountAuthenticator( + val context: Context + ) : AbstractAccountAuthenticator(context) { + + override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle { + val intent = Intent(context, LoginActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE) + val bundle = Bundle(1) + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null + override fun getAuthTokenLabel(p0: String?) = null + override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null + override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun getAuthToken(response: AccountAuthenticatorResponse?, account: Account?, authTokenType: String?, options: Bundle?): Bundle { + val accountManager = AccountManager.get(context) + val authState = AuthState.jsonDeserialize(accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE)) + + if (authState != null) { + if (authState.needsTokenRefresh) { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest) { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountManager.setUserData(account, AccountSettings.KEY_AUTH_STATE, authState.jsonSerializeString()) + val result = Bundle() + result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name) + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) + response?.onResult(result) + } + } + else { + val result = Bundle() + result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name) + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) + return result + } + } + + val result = Bundle() + result.putInt(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION) + return result + } + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} + diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt index b4a58e02fe022d9eadafdacfefc434cbdc519986..3407a6616546f869a3e75e4e6546f856ce885f12 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt @@ -226,14 +226,14 @@ class SettingsActivity: AppCompatActivity() { prefUserName.summary = credentials.userName prefUserName.text = credentials.userName prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName -> - model.updateCredentials(Credentials(newUserName as String, credentials.password, credentials.certificateAlias)) + model.updateCredentials(Credentials(newUserName as String, credentials.password)) false } if (credentials.userName != null) { prefPassword.isVisible = true prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newPassword -> - model.updateCredentials(Credentials(credentials.userName, newPassword as String, credentials.certificateAlias)) + model.updateCredentials(Credentials(credentials.userName, newPassword as String)) false } } else @@ -242,7 +242,7 @@ class SettingsActivity: AppCompatActivity() { prefCertAlias.summary = credentials.certificateAlias ?: getString(R.string.settings_certificate_alias_empty) prefCertAlias.setOnPreferenceClickListener { KeyChain.choosePrivateKeyAlias(requireActivity(), { newAlias -> - model.updateCredentials(Credentials(credentials.userName, credentials.password, newAlias)) + model.updateCredentials(Credentials(certificateAlias = newAlias)) }, null, null, null, -1, credentials.certificateAlias) true } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt index 445079025c282ff85b97e9a500042ca53e3c18fa..ea7522aee2844af0bd8b48fc7d2703ef884d3cc9 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt @@ -179,7 +179,7 @@ class DefaultLoginCredentialsFragment : Fragment() { loginModel.credentials = when { // username/password and client certificate model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == true -> - Credentials(username, password, alias) + Credentials(username, password, null) // user/name password only model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == false -> diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..1dde9909909a525ad4341a491ac26ae14902730d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt @@ -0,0 +1,423 @@ +package at.bitfire.davdroid.ui.setup + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.* +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +import at.bitfire.davdroid.R +import androidx.lifecycle.ViewModelProviders +import net.openid.appauth.* +import org.json.JSONException +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.net.* +import org.json.JSONObject +import java.util.HashMap + +import android.net.ConnectivityManager +import android.text.Layout +import android.text.SpannableString +import android.text.style.AlignmentSpan +import android.widget.Toast +import at.bitfire.davdroid.authorization.IdentityProvider +import at.bitfire.davdroid.databinding.FragmentGoogleAuthenticatorBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +import at.bitfire.davdroid.db.Credentials + +class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenResponseCallback { + + private lateinit var model: GoogleAuthenticatorModel + private lateinit var loginModel: LoginModel + + private val extraAuthServiceDiscovery = "authServiceDiscovery" + private val extraClientSecret = "clientSecret" + + private var authState: AuthState? = null + private var authorizationService: AuthorizationService? = null + + private val bufferSize = 1024 + private var userInfoJson: JSONObject? = null + + private fun isNetworkAvailable(): Boolean { + val connectivityManager = activity!!.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetworkInfo = connectivityManager.activeNetworkInfo + return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + + model = ViewModelProviders.of(this).get(GoogleAuthenticatorModel::class.java) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + // Initialise the authorization service + authorizationService = AuthorizationService(context!!) + + val v = FragmentGoogleAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + activity?.intent?.let { + model.initialize(it) + val builder = MaterialAlertDialogBuilder(context!!, R.style.CustomAlertDialogStyle) + + if (!with(it) { getBooleanExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, false) }) { + val title = SpannableString(getString(R.string.google_alert_title)) + // alert dialog title align center + title.setSpan( + AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), + 0, + title.length, + 0 + ) + + builder.setTitle(title) + builder.setMessage(getString(R.string.google_alert_message)) + builder.setPositiveButton(android.R.string.yes) { dialog, which -> + // Get all the account providers + val providers = IdentityProvider.getEnabledProviders(context) + + // Iterate over the account providers + for (idp in providers) { + val retrieveCallback = AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> + if (ex == null && serviceConfiguration != null) { + makeAuthRequest(serviceConfiguration, idp) + } else if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + } + + if (idp.name == getString(R.string.google_name)) { + // Get configurations for the Google account provider + idp.retrieveConfig(context, retrieveCallback) + } + } + } + builder.setCancelable(false) + + val dialog = builder.create() + dialog.show() + + } + else { + if (authState == null) { + val response = AuthorizationResponse.fromIntent(activity!!.intent) + val ex = AuthorizationException.fromIntent(activity!!.intent) + authState = AuthState(response, ex) + + if (response != null) { + exchangeAuthorizationCode(response) + } else if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + } + } + } + + return v.root + } + + private fun makeAuthRequest( + serviceConfig: AuthorizationServiceConfiguration, + idp: IdentityProvider) { + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val authRequest = AuthorizationRequest.Builder( + serviceConfig, + idp.clientId, + ResponseTypeValues.CODE, + idp.redirectUri) + .setScope(idp.scope) + .build() + + authorizationService?.performAuthorizationRequest( + authRequest, + createPostAuthorizationIntent( + context!!, + authRequest, + serviceConfig.discoveryDoc, + idp.clientSecret), + authorizationService?.createCustomTabsIntentBuilder()!! + .build()) + + requireActivity().setResult(Activity.RESULT_OK) + requireActivity().finish() + } + + private fun createPostAuthorizationIntent( + context: Context, + request: AuthorizationRequest, + discoveryDoc: AuthorizationServiceDiscovery?, + clientSecret: String?): PendingIntent { + val intent = Intent(context, LoginActivity::class.java) + + if (discoveryDoc != null) { + intent.putExtra(extraAuthServiceDiscovery, discoveryDoc.docJson.toString()) + } + + if (clientSecret != null) { + intent.putExtra(extraClientSecret, clientSecret) + } + + intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE) + intent.putExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, true) + + return PendingIntent.getActivity(context, request.hashCode(), intent, 0) + } + + private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val additionalParams = HashMap() + if (getClientSecretFromIntent(activity!!.intent) != null) { + additionalParams["client_secret"] = getClientSecretFromIntent(activity!!.intent) + } + performTokenRequest(authorizationResponse.createTokenExchangeRequest(additionalParams)) + } + + private fun getClientSecretFromIntent(intent: Intent): String? { + return if (!intent.hasExtra(extraClientSecret)) { + null + } + else intent.getStringExtra(extraClientSecret) + } + + + private fun performTokenRequest(request: TokenRequest) { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + authorizationService?.performTokenRequest( + request, this) + } + + override fun onTokenRequestCompleted(response: TokenResponse?, ex: AuthorizationException?) { + authState?.update(response, ex) + + getAccountInfo() + } + + private fun getAccountInfo() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent) + + if (!authState!!.isAuthorized + || discoveryDoc == null + || discoveryDoc.userinfoEndpoint == null) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + else { + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + if (fetchUserInfo()) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + return null + } + }.execute() + } + } + + private fun getDiscoveryDocFromIntent(intent: Intent): AuthorizationServiceDiscovery? { + if (!intent.hasExtra(extraAuthServiceDiscovery)) { + return null + } + val discoveryJson = intent.getStringExtra(extraAuthServiceDiscovery) + try { + return AuthorizationServiceDiscovery(JSONObject(discoveryJson)) + } + catch (ex: JSONException) { + throw IllegalStateException("Malformed JSON in discovery doc") + } + catch (ex: AuthorizationServiceDiscovery.MissingArgumentException) { + throw IllegalStateException("Malformed JSON in discovery doc") + } + + } + + private fun fetchUserInfo(): Boolean { + var error = false + + if (authState!!.authorizationServiceConfiguration == null) { + return true + } + + authState!!.performActionWithFreshTokens(authorizationService!!, AuthState.AuthStateAction { accessToken, _, ex -> + if (ex != null) { + error = true + return@AuthStateAction + } + + val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent) + ?: throw IllegalStateException("no available discovery doc") + + val userInfoEndpoint: URL + try { + userInfoEndpoint = URL(discoveryDoc.userinfoEndpoint!!.toString()) + } + catch (urlEx: MalformedURLException) { + error = true + return@AuthStateAction + } + + var userInfoResponse: InputStream? = null + try { + val conn = userInfoEndpoint.openConnection() as HttpURLConnection + conn.setRequestProperty("Authorization", "Bearer " + accessToken!!) + conn.instanceFollowRedirects = false + userInfoResponse = conn.inputStream + val response = readStream(userInfoResponse) + updateUserInfo(JSONObject(response)) + } + catch (ioEx: IOException) { + error = true + } + catch (jsonEx: JSONException) { + error = true + } + finally { + if (userInfoResponse != null) { + try { + userInfoResponse.close() + } + catch (ioEx: IOException) { + error = true + } + + } + } + }) + + return error + } + + @Throws(IOException::class) + private fun readStream(stream: InputStream?): String { + val br = BufferedReader(InputStreamReader(stream!!)) + val buffer = CharArray(bufferSize) + val sb = StringBuilder() + var readCount = br.read(buffer) + while (readCount != -1) { + sb.append(buffer, 0, readCount) + readCount = br.read(buffer) + } + return sb.toString() + } + + private fun updateUserInfo(jsonObject: JSONObject) { + Handler(Looper.getMainLooper()).post { + userInfoJson = jsonObject + onAccountInfoGotten() + } + } + + private fun onAccountInfoGotten() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + if (userInfoJson != null) { + try { + + var emailAddress = "" + if (userInfoJson!!.has("email")) { + emailAddress = userInfoJson!!.getString("email") + } + + if (validate(emailAddress, authState!!)) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + + } + catch (ex: JSONException) { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + } + else { + Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + } + + private fun validate(emailAddress: String, authState: AuthState): Boolean { + var valid = false + + fun validateUrl() { + model.baseUrlError.value = null + try { + val uri = URI("https://apidata.googleusercontent.com/caldav/v2/$emailAddress/user") + if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { + valid = true + loginModel.baseURI = uri + } else + model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https) + } catch (e: Exception) { + model.baseUrlError.value = e.localizedMessage + } + } + + when { + + model.loginWithUrlAndTokens.value == true -> { + validateUrl() + + model.usernameError.value = null + + if (loginModel.baseURI != null) { + valid = true + loginModel.credentials = Credentials(emailAddress, null, authState, null) + } + } + + } + + return valid + } + + override fun onDestroy() { + super.onDestroy() + authorizationService?.dispose() + } + + +} + diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..6adf20ff60db64985cfb2c5069e4c69fc74b2bda --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt @@ -0,0 +1,49 @@ +package at.bitfire.davdroid.ui.setup + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class GoogleAuthenticatorModel: ViewModel() { + + private var initialized = false + + val loginWithUrlAndTokens = MutableLiveData() + + val baseUrl = MutableLiveData() + val baseUrlError = MutableLiveData() + + val emailAddress = MutableLiveData() + val emailAddressError = MutableLiveData() + + val username = MutableLiveData() + val usernameError = MutableLiveData() + + val password = MutableLiveData() + val passwordError = MutableLiveData() + + val certificateAlias = MutableLiveData() + val certificateAliasError = MutableLiveData() + + init { + loginWithUrlAndTokens.value = true + } + + fun initialize(intent: Intent) { + if (initialized) + return + + // we've got initial login data + val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL) + val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME) + val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD) + + baseUrl.value = givenUrl + + password.value = givenPassword + + initialized = true + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt index 801f827297eb75c14ec9cdb4341dd31620d227ed..fecaf076dfd22f88b82713fa8f8d8d824c949196 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt @@ -47,7 +47,7 @@ class LoginActivity: AppCompatActivity() { const val SETUP_ACCOUNT_PROVIDER_TYPE = "setup_account_provider_type" const val ACCOUNT_PROVIDER_EELO = "eelo" const val ACCOUNT_PROVIDER_GOOGLE = "google" - + const val ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE = "google_auth_complete" } @Inject @@ -78,7 +78,11 @@ class LoginActivity: AppCompatActivity() { .commit() } ACCOUNT_PROVIDER_GOOGLE -> { - Log.i(TAG, "starting google account sign-in") + Log.e(TAG, "starting google account sign-in") + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, GoogleAuthenticatorFragment()) + .commit() + } else -> // first call, add first login fragment diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt b/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt index 87ceb5aa02b09ac90cbf7204e809d8651e69dbb2..647dc2d55547569f3808871a2017aa705766d215 100644 --- a/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt +++ b/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt @@ -43,6 +43,7 @@ class CredentialsStore(context: Context) { return Credentials( prefs.getString(keyName(mountId, USER_NAME), null), prefs.getString(keyName(mountId, PASSWORD), null), + null, prefs.getString(keyName(mountId, CERTIFICATE_ALIAS), null) ) } diff --git a/app/src/main/res/layout/fragment_google_authenticator.xml b/app/src/main/res/layout/fragment_google_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..9880209992282fe6edcdc833c674893136c44f70 --- /dev/null +++ b/app/src/main/res/layout/fragment_google_authenticator.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/account_providers_auth_config.xml b/app/src/main/res/values/account_providers_auth_config.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3b10fdbaa873cc965b9d52e768ab19a89bc1349 --- /dev/null +++ b/app/src/main/res/values/account_providers_auth_config.xml @@ -0,0 +1,18 @@ + + + + + Google + + 100496780587-pbiu5eudcjm6cge2phduc6mt8mgbsmsr.apps.googleusercontent.com + + https://accounts.google.com/.well-known/openid-configuration + + openid profile email https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/calendar https://mail.google.com/ + + + com.googleusercontent.apps.100496780587-pbiu5eudcjm6cge2phduc6mt8mgbsmsr:/oauth2redirect + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eebfb386f865d68bc96a66fb0005e947fc53a8d5..cdcbc91b1691c109858f028c432cc731e82b32eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,9 +8,16 @@ Account does not exist (anymore) bitfire.at.davdroid - Google + Google /e/ bitfire.at.davdroid.google + e.foundation.webdav.eelo + + + WARNING + /e/ will report a fake device model to Google to protect your privacy.\nYou can check which one on Google\'s Device Activity after you log in. + + at.bitfire.davdroid.address_book /e/ diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 75ffbab8903697fcac6c9666d46b4af1b803cd8a..5deef593421bdb0833f2b72b70ac155a535d64af 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -23,6 +23,7 @@ #aee571 #4b830d @android:color/black + @color/primaryTextColor #ff6d00 #ff9e40 @@ -86,4 +87,25 @@ @color/secondaryLightColor + + + + + + diff --git a/gradle.properties b/gradle.properties index c6bf06227a4bf6557135f618573cf62c3cf6fad4..52bbc61602c7ef486b5feb97faed45fd854b79a7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,4 @@ android.useAndroidX=true org.gradle.daemon=true org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M" org.gradle.parallel=true +android.enableJetifier=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 92f06b50fd65b47b5578f5427b9fee66dcaae5ae..02b1c085de35d05284647c274a8111c907fc2bd1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Fri Jul 29 11:01:22 IST 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME