From 901034fa2c1100ce40766f97437829d522bd3c95 Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 19 Jun 2023 11:53:24 +0200 Subject: [PATCH] [RFR-265] Switch to OAuth (#54) * [RFR-469] Add draft for oauth authentication * Upgrade SDK to test version * Remove unused code * Attempt to merge TokenActivity into MainActivity * Add debug messages * WIP: fresh token is provided * [RFR-519] Fix logout * Add dummy implementation * Update build.gradle * [RFR-563] Inject oauth config * Last working state for rfr UI * Cleanup * Fix build * Disable Notification test * Upgrade SDK to 7.8.0 --- .github/workflows/gradle_build.yml | 5 +- .github/workflows/gradle_connected-tests.yml | 5 +- README.adoc | 2 +- backend | 2 +- build.gradle | 4 +- gradle.properties.template | 29 +- ui/cyface/build.gradle | 29 +- .../cyface/app/CapturingNotificationTest.kt | 2 + ui/cyface/src/main/AndroidManifest.xml | 13 +- .../kotlin/de/cyface/app/CapturingFragment.kt | 3 +- .../kotlin/de/cyface/app/LoginActivity.kt | 452 ------------------ .../main/kotlin/de/cyface/app/MainActivity.kt | 167 ++++++- .../kotlin/de/cyface/app/MeasuringClient.kt | 2 +- .../de/cyface/app/RegistrationActivity.kt | 375 --------------- .../de/cyface/app/auth/LoginActivity.kt | 428 +++++++++++++++++ .../de/cyface/app/capturing/MenuProvider.kt | 19 +- .../src/main/res/layout/activity_login.xml | 186 +++---- .../main/res/layout/activity_registration.xml | 173 ------- ui/cyface/src/main/res/menu/capturing.xml | 5 +- .../de/cyface/app/ui/LoginActivityTest.java | 94 ---- ui/r4r/build.gradle | 30 +- ui/r4r/src/main/AndroidManifest.xml | 12 +- .../kotlin/de/cyface/app/r4r/Application.kt | 1 + .../kotlin/de/cyface/app/r4r/LoginActivity.kt | 439 ----------------- .../kotlin/de/cyface/app/r4r/MainActivity.kt | 160 ++++++- .../de/cyface/app/r4r/RegistrationActivity.kt | 410 ---------------- .../de/cyface/app/r4r/TermsOfUseActivity.kt | 1 + .../de/cyface/app/r4r/auth/LoginActivity.kt | 428 +++++++++++++++++ .../app/r4r/capturing/CapturingFragment.kt | 5 +- .../cyface/app/r4r/capturing/MenuProvider.kt | 27 +- .../r4r/capturing/marker/MarkerFragment.kt | 4 +- ui/r4r/src/main/res/layout/activity_login.xml | 186 +++---- .../main/res/layout/activity_registration.xml | 180 ------- .../res/layout/browser_selector_layout.xml | 23 + ui/r4r/src/main/res/menu/capturing.xml | 5 +- ui/r4r/src/main/res/values/strings.xml | 3 + utils/build.gradle | 3 + .../de/cyface/app/utils/ServiceProvider.kt | 4 +- .../cyface/app/utils/trips/TripsFragment.kt | 75 +-- .../app/utils/trips/incentives/Incentives.kt | 153 ++---- 40 files changed, 1562 insertions(+), 2582 deletions(-) delete mode 100644 ui/cyface/src/main/kotlin/de/cyface/app/LoginActivity.kt delete mode 100644 ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt create mode 100644 ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt delete mode 100644 ui/cyface/src/main/res/layout/activity_registration.xml delete mode 100644 ui/cyface/src/test/kotlin/de/cyface/app/ui/LoginActivityTest.java delete mode 100644 ui/r4r/src/main/kotlin/de/cyface/app/r4r/LoginActivity.kt delete mode 100644 ui/r4r/src/main/kotlin/de/cyface/app/r4r/RegistrationActivity.kt create mode 100644 ui/r4r/src/main/kotlin/de/cyface/app/r4r/auth/LoginActivity.kt delete mode 100644 ui/r4r/src/main/res/layout/activity_registration.xml create mode 100644 ui/r4r/src/main/res/layout/browser_selector_layout.xml diff --git a/.github/workflows/gradle_build.yml b/.github/workflows/gradle_build.yml index 782e7a53..57a43c33 100644 --- a/.github/workflows/gradle_build.yml +++ b/.github/workflows/gradle_build.yml @@ -48,9 +48,12 @@ jobs: echo "githubToken=${{ secrets.GH_READ_TOKEN }}" >> gradle.properties # This mock API accepts all credentials and allows the UI test to skip the login echo "cyface.staging_api=https://demo.cyface.de/api/v2" >> gradle.properties - echo "cyface.staging_auth_api=https://demo.cyface.de/api/v2" >> gradle.properties + echo "cyface.staging_incentives_api=https://demo.cyface.de/incentives" >> gradle.properties + echo "cyface.staging_oauth_discovery=https://demo.cyface.de/auth" >> gradle.properties echo "cyface.staging_user=guestLogin" >> gradle.properties echo "cyface.staging_password=guestPassword" >> gradle.properties + echo "cyface.oauth_redirect=de.cyface.app:/oauth2redirect" >> gradle.properties + echo "cyface.oauth_redirect.r4r=de.cyface.app.r4r:/oauth2redirect" >> gradle.properties # Executing build here on Ubuntu stack (1/10th costs of MacOS stack) # Not using "gradle build" as we don't want to run the tests of all dependencies (e.g. backend) diff --git a/.github/workflows/gradle_connected-tests.yml b/.github/workflows/gradle_connected-tests.yml index e236937b..60561ac0 100644 --- a/.github/workflows/gradle_connected-tests.yml +++ b/.github/workflows/gradle_connected-tests.yml @@ -64,9 +64,12 @@ jobs: run: | # This mock API accepts all credentials and allows the UI test to skip the login echo "cyface.staging_api=https://demo.cyface.de/api/v2" >> gradle.properties - echo "cyface.staging_auth_api=https://demo.cyface.de/api/v2" >> gradle.properties + echo "cyface.staging_incentives_api=https://demo.cyface.de/incentives" >> gradle.properties + echo "cyface.staging_oauth_discovery=https://demo.cyface.de/auth" >> gradle.properties echo "cyface.staging_user=guestLogin" >> gradle.properties echo "cyface.staging_password=guestPassword" >> gradle.properties + echo "cyface.oauth_redirect=de.cyface.app:/oauth2redirect" >> gradle.properties + echo "cyface.oauth_redirect.r4r=de.cyface.app.r4r:/oauth2redirect" >> gradle.properties # Not executing build here on MacOS stack (10x costs, if private repository) # Not using "gradle build" as we don't want to run the tests of all dependencies (e.g. backend) diff --git a/README.adoc b/README.adoc index 05e94344..1f16ee9c 100644 --- a/README.adoc +++ b/README.adoc @@ -29,7 +29,7 @@ To download the Cyface libraries (SDK, Energy Settings, Camera Service): [arabic] * You need a Github account with read-access to these Github repositories -* Create a https://github.com/settings/tokens[personal access token on Github] with "write:packages" permissions +* Create a https://github.com/settings/tokens[personal access token on Github] with `read:packages` and `repo` permissions to download the sub-modules and `android-publish` dependency. * Copy `gradle.properties.template` to `gradle.properties` and adjust: + .... diff --git a/backend b/backend index f8d9d822..6bf2903a 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit f8d9d822fe64c8bd69fe140539ba1e6f6c8cc39e +Subproject commit 6bf2903a0978c3737267695a29fe4b8d22369e1e diff --git a/build.gradle b/build.gradle index 1c12e7a7..1f7876d5 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,7 @@ ext { */ // Cyface dependencies - cyfaceAndroidBackendVersion = "7.7.2" // Also update submodule commit ref + cyfaceAndroidBackendVersion = "7.8.0" // Also update submodule commit ref cyfaceUtilsVersion = "3.3.7" cyfaceEnergySettingsVersion = "3.3.3" // Also update submodule commit ref cyfaceCameraServiceVersion = "4.1.11" // Also update submodule commit ref @@ -87,6 +87,8 @@ ext { materialDialogsVersion = "3.3.0" chartVersion = "v3.1.0" hCaptchaVersion = "3.8.1" + // Can't be a SDK dependency as `appAuthRedirectScheme` needs to be defined at build-time + appAuthVersion = "0.11.1" // Can't move to uploader lib because of dependency issues // Testing junitVersion = "1.1.5" diff --git a/gradle.properties.template b/gradle.properties.template index 079df35b..47538f3d 100644 --- a/gradle.properties.template +++ b/gradle.properties.template @@ -31,28 +31,41 @@ android.nonTransitiveRClass=true android.builder.sdkDownload=true android.enableJetifier=false +android.defaults.buildfeatures.buildconfig=true +android.nonFinalResIds=false githubUser= githubToken= -cyface.api= -cyface.auth_api= -cyface.incentives_api= - google.maps_api_key= google.maps-api_key.r4r= hCaptcha.key= hCaptcha.key.r4r= -cyface.demo_api= -cyface.local_api= -cyface.emulator_api= +cyface.oauth_redirect= +cyface.oauth_redirect.r4r= + +cyface.api= +cyface.incentives_api= +cyface.oauth_discovery= cyface.staging_api= -cyface.staging_auth_api= cyface.staging_incentives_api= +cyface.staging_oauth_discovery= cyface.staging_user= cyface.staging_password= +cyface.demo_api= +cyface.demo_incentives_api= +cyface.demo_oauth_discovery= + +cyface.local_api= +cyface.local_incentives_api= +cyface.local_oauth_discovery= + +cyface.emulator_api= +cyface.emulator_incentives_api= +cyface.emulator_oauth_discovery= + # keep an empty line at the end for the CI to inject new lines easily diff --git a/ui/cyface/build.gradle b/ui/cyface/build.gradle index 26fc0473..d1465671 100644 --- a/ui/cyface/build.gradle +++ b/ui/cyface/build.gradle @@ -70,8 +70,19 @@ android { missingDimensionStrategy 'project', 'cyface' missingDimensionStrategy 'mode', 'full' - // Load Google Maps API key - manifestPlaceholders = [ googleMapsApiKey:"${project.findProperty('google.maps_api_key')}"] + // Placeholders for AndroidManifest.xml + manifestPlaceholders = [ + // Load Google Maps API key + googleMapsApiKey:"${project.findProperty('google.maps_api_key')}", + + // Define app link scheme for AppAuth redirect + // Ensure this is consistent with the redirect URI defined below in `oauthRedirect` + // or specify additional redirect URIs in AndroidManifest.xml + 'appAuthRedirectScheme': 'de.cyface.app' + ] + + // oauth redirect uri + buildConfigField "String", "oauthRedirect", "\"${project.findProperty('cyface.oauth_redirect')}\"" // Load hCaptcha API key buildConfigField "String", "hCaptchaKey", "\"${project.findProperty('hCaptcha.key')}\"" @@ -86,26 +97,32 @@ android { // Phone - to local collector - ! only if iptables allow connection from outside //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.local_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.local_oauth_discovery')}\"" //manifestPlaceholders = [usesCleartextTraffic:"true"] // Phone - to local production - ! only if iptables allow connection from outside // CertPathValidatorException: Trust anchor for certification path not found. //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.local_production_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.local_oauth_discovery')}\"" //manifestPlaceholders = [usesCleartextTraffic:"false"] // EMULATOR - to local collector //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.emulator_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.emulator_oauth_discovery')}\"" //manifestPlaceholders = [usesCleartextTraffic:"true"] // for local collector testing // Staging buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.staging_api')}\"" - buildConfigField "String", "authServer", "\"${project.findProperty('cyface.staging_auth_api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.staging_incentives_api')}\"" + buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.staging_oauth_discovery')}\"" buildConfigField "String", "testLogin", "\"${project.findProperty('cyface.staging_user')}\"" buildConfigField "String", "testPassword", "\"${project.findProperty('cyface.staging_password')}\"" manifestPlaceholders = [usesCleartextTraffic:"false"] // MOCK-API - only supports login - used by UI test on CI //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.demo_api')}\"" + //buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.demo_incentives_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.demo_oauth_discovery')}\"" //manifestPlaceholders = [usesCleartextTraffic:"false"] // for local collector testing } release { @@ -116,7 +133,8 @@ android { // signingConfig is set by the CI buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.api')}\"" - buildConfigField "String", "authServer", "\"${project.findProperty('cyface.auth_api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.incentives_api')}\"" + buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.oauth_discovery')}\"" manifestPlaceholders = [usesCleartextTraffic:"false"] } } @@ -205,6 +223,9 @@ dependencies { // HCaptcha implementation "com.github.hcaptcha:hcaptcha-android-sdk:$rootProject.ext.hCaptchaVersion" + // OAuth 2.0 with OpenID Connect + implementation "net.openid:appauth:$rootProject.ext.appAuthVersion" // Move to uploader [RFR-581] + // Cyface dependencies implementation "de.cyface:android-utils:$rootProject.ext.cyfaceUtilsVersion" implementation project(':datacapturing') diff --git a/ui/cyface/src/androidTest/kotlin/de/cyface/app/CapturingNotificationTest.kt b/ui/cyface/src/androidTest/kotlin/de/cyface/app/CapturingNotificationTest.kt index 246c516f..a835c5c1 100644 --- a/ui/cyface/src/androidTest/kotlin/de/cyface/app/CapturingNotificationTest.kt +++ b/ui/cyface/src/androidTest/kotlin/de/cyface/app/CapturingNotificationTest.kt @@ -34,6 +34,7 @@ import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -104,6 +105,7 @@ class CapturingNotificationTest { * This test is flaky on the Bitrise CI. */ @Test + @Ignore("OAuth needs to be skipped [RFR-587]") fun test() { val context = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/ui/cyface/src/main/AndroidManifest.xml b/ui/cyface/src/main/AndroidManifest.xml index 2de0bc90..06ace4fc 100644 --- a/ui/cyface/src/main/AndroidManifest.xml +++ b/ui/cyface/src/main/AndroidManifest.xml @@ -45,6 +45,7 @@ + @@ -81,22 +83,21 @@ + - - - . - */ -package de.cyface.app - -import android.accounts.Account -import android.accounts.AccountAuthenticatorActivity -import android.accounts.AccountManager -import android.content.ContentResolver -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.Bundle -import android.preference.PreferenceManager -import android.util.Log -import android.util.Patterns -import android.view.View -import android.widget.Button -import android.widget.ProgressBar -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textview.MaterialTextView -import de.cyface.app.BuildConfig -import de.cyface.app.MeasuringClient.Companion.errorHandler -import de.cyface.app.R -import de.cyface.app.RegistrationActivity -import de.cyface.app.utils.Constants -import de.cyface.app.utils.Constants.ACCOUNT_TYPE -import de.cyface.app.utils.Constants.TAG -import de.cyface.app.utils.SharedConstants -import de.cyface.synchronization.CyfaceAuthenticator -import de.cyface.synchronization.ErrorHandler -import de.cyface.synchronization.ErrorHandler.ErrorCode -import de.cyface.synchronization.SyncService.AUTH_ENDPOINT_URL_SETTINGS_KEY -import de.cyface.uploader.DefaultAuthenticator -import de.cyface.utils.Validate -import io.sentry.Sentry -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.lang.ref.WeakReference -import java.util.regex.Pattern - -/** - * A login screen that offers login via email/password. - * - * @author Armin Schnabel - * @version 3.3.0 - * @since 1.0.0 - */ -class LoginActivity : AccountAuthenticatorActivity() { - private lateinit var context: WeakReference - private var preferences: SharedPreferences? = null - - // UI references - private var progressBar: ProgressBar? = null - - /** - * A `Button` which is used to confirm the entered credentials. - */ - private var loginButton: Button? = null - - /** - * Needs to be resettable for testing. That's the only way to mock a single method of Android's Activity's - */ - @JvmField - var loginInput: TextInputEditText? = null - - @JvmField - var passwordInput: TextInputEditText? = null - - /** - * Intent for opening the registration activity when the user clicks the link. - */ - private var callRegistrationActivityIntent: Intent? = null - - /** - * Needs to be nullable and resettable for testing as Android's `Patterns` is null in unit test. - * - * That's the only way to mock a single method of Android's Activity's. - */ - @JvmField - var eMailPattern: Pattern? = Patterns.EMAIL_ADDRESS - - private val errorListener = ErrorHandler.ErrorListener { errorCode, errorMessage -> - if (errorCode == ErrorCode.UNAUTHORIZED) { - passwordInput!!.error = errorMessage - passwordInput!!.requestFocus() - - // All other errors are shown as toast by the MeasuringClient - } - } - - @Deprecated("Deprecated in Java") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_login) - context = WeakReference(this) - preferences = PreferenceManager.getDefaultSharedPreferences(this) - setServerUrl() // TODO [CY-3735]: via Android's settings - - // Set up the login form - loginInput = findViewById(R.id.input_login) - passwordInput = findViewById(R.id.input_password) - loginButton = findViewById(R.id.login_button) - loginButton!!.setOnClickListener { attemptLogin() } - progressBar = findViewById(R.id.login_progress_bar) - callRegistrationActivityIntent = Intent(this, RegistrationActivity::class.java) - registerRegistrationLink() - errorHandler!!.addListener(errorListener) - } - - override fun onResume() { - // Show message from intent - val registered = intent.getBooleanExtra(RegistrationActivity.REGISTERED_EXTRA, false) - val messageView = findViewById(R.id.login_message) - if (registered) { - messageView.text = getString(de.cyface.app.utils.R.string.registration_successful) - messageView.visibility = View.VISIBLE - } - - super.onResume() - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - // Required to get the latest intent in `onResume`, when using `singleTask` launch mode - setIntent(intent) - } - - override fun onDestroy() { - errorHandler!!.removeListener(errorListener) - super.onDestroy() - } - - /** - * Attempts to sign in with the account specified by the login form. - * - * If there are form errors (invalid email, missing fields, etc.), the - * errors are presented and no actual login attempt is made. - */ - private fun attemptLogin() { - // Update view - loginInput!!.error = null - passwordInput!!.error = null - loginButton!!.isEnabled = false - - // Check for valid credentials - Validate.notNull(loginInput!!.text) - Validate.notNull(passwordInput!!.text) - val login = loginInput!!.text.toString() - val password = passwordInput!!.text.toString() - if (!credentialsAreValid(login, password, loginMustBeAnEmailAddress)) { - loginButton!!.isEnabled = true - return - } - - // Update view - progressBar!!.isIndeterminate = true - progressBar!!.visibility = View.VISIBLE - - // The CyfaceAuthenticator reads the credentials from the account so we store them there - updateAccount(this, login, password) - - // Send async login attempt - GlobalScope.launch { - val account: Account = getAccount(context.get()!!) - - // Load authUrl - val preferences = PreferenceManager.getDefaultSharedPreferences(context.get()) - val url = preferences.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) - ?: throw IllegalStateException( - "Server url not available. Please set the applications server url preference." - ) - - // Explicitly calling CyfaceAuthenticator.getAuthToken(), see its documentation - val cyfaceAuthenticator = - CyfaceAuthenticator(context.get()!!, DefaultAuthenticator(url)) - val authToken = try { - // AsyncTask because this is blocking but only for a short time - cyfaceAuthenticator.getAuthToken( - null, - account, - de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE, - null - ) - .getString(AccountManager.KEY_AUTHTOKEN)!! - } catch (e: Exception) { - // Using ErrorHandler to show soft error like "account not activated" instead - // when (e) { is LoginFailed -> { when (e.cause) { - - reportError(e) - runOnUiThread { - progressBar!!.visibility = View.GONE - // Clean up if the getAuthToken failed, else the LoginActivity is probably not shown - deleteAccount(context.get()!!, account) - loginButton!!.isEnabled = true - } - return@launch - } - Validate.notNull(authToken) - Log.d(TAG, "Setting auth token to: **" + authToken.substring(authToken.length - 7)) - val accountManager = AccountManager.get(context.get()) - accountManager.setAuthToken( - account, - de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE, - authToken - ) - //return@async AuthTokenRequestParams(account, true) - - // Equals tutorial's "finishLogin()" - val intent = Intent() - intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name) - intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE) - intent.putExtra( - AccountManager.KEY_AUTHTOKEN, - de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE - ) - - // Return the information back to the Authenticator - setAccountAuthenticatorResult(intent.extras) - setResult(RESULT_OK, intent) - - // Hide the keyboard or else it can overlap the permission request - runOnUiThread { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - loginButton!!.windowInsetsController!!.hide(WindowInsetsCompat.Type.ime()) - } else { - ViewCompat.getWindowInsetsController(loginButton!!)!! - .hide(WindowInsetsCompat.Type.ime()) - } - progressBar!!.visibility = View.GONE - finish() - } - } - } - - private fun reportError(e: Exception) { - // We cannot capture the exceptions in CyfaceAuthenticator as it's part of the SDK. - // We also don't want to capture the errors in the error handler as we don't have the stacktrace there - val reportingEnabled = preferences!!.getBoolean(SharedConstants.ACCEPTED_REPORTING_KEY, false) - if (reportingEnabled) { - Sentry.captureException(e) - } - // "the authenticator could not honor the request due to a network error" - Log.d(TAG, "Login failed - removing account to allow new login.", e) - } - - /** - * Returns the Cyface account. Throws a Runtime Exception if no or more than one account exists. - * - * @param context the Context to load the AccountManager - * @return the only existing account - */ - private fun getAccount(context: Context): Account { - val accountManager = AccountManager.get(context) - val existingAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE) - Validate.isTrue(existingAccounts.size < 2, "More than one account exists.") - Validate.isTrue(existingAccounts.isNotEmpty(), "No account exists.") - return existingAccounts[0] - } - - /** - * Checks if the format of the credentials provided is valid, i.e. has the allowed length and - * is not empty and, if requested, checks if the login is an email address. - * - * @param login The login string - * @param password The password string - * @param loginMustBeAnEmailAddress True if the login should be checked to be a valid email address - * @return true is the credentials are in a valid format - */ - fun credentialsAreValid( - login: String?, - password: String?, - loginMustBeAnEmailAddress: Boolean - ): Boolean { - var valid = true - if (login.isNullOrEmpty()) { - loginInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_field_required) - loginInput!!.requestFocus() - valid = false - } else if (login.length < 4) { - loginInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_login_too_short) - loginInput!!.requestFocus() - valid = false - } else if (loginMustBeAnEmailAddress && !eMailPattern!!.matcher(login).matches()) { - loginInput!!.error = getString(de.cyface.app.utils.R.string.error_message_invalid_email) - loginInput!!.requestFocus() - valid = false - } - if (password.isNullOrEmpty()) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_field_required) - passwordInput!!.requestFocus() - valid = false - } else if (password.length < 6) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_password_too_short) - passwordInput!!.requestFocus() - valid = false - } else if (password.length > 20) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_password_too_short) - passwordInput!!.requestFocus() - valid = false - } - return valid - } - - /** - * As long as the server URL is hardcoded we want to reset it when it's different from the - * default URL set in the [BuildConfig]. If not, hardcoded updates would not have an - * effect. - */ - private fun setServerUrl() { - val storedServer = preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) - val server = BuildConfig.authServer - @Suppress("KotlinConstantConditions") - Validate.isTrue(server != "null") - if (storedServer == null || storedServer != server) { - Log.d( - TAG, - "Updating Cyface Auth API URL from " + storedServer + "to" + server - ) - val editor = preferences!!.edit() - editor.putString(AUTH_ENDPOINT_URL_SETTINGS_KEY, server) - editor.apply() - } - } - - private fun registerRegistrationLink() { - val registrationLink = findViewById(R.id.login_link_registration) - // just open registration on top (don't finish) so we can return to login activity - registrationLink.setOnClickListener { v: View? -> - startActivity( - callRegistrationActivityIntent - ) - } - } - - companion object { - // True if the login must match the email pattern TODO [CY-4099]: Needs to be configurable from outside - private const val loginMustBeAnEmailAddress = false - - /** - * Updates the credentials - * - * @param context The [Context] required to add an [Account] - * @param login The username of the account - * @param password The password of the account - */ - private fun updateAccount(context: Context, login: String, password: String) { - Validate.notEmpty(login) - Validate.notEmpty(password) - val accountManager = AccountManager.get(context) - val account = Account(login, ACCOUNT_TYPE) - - // Update credentials if the account already exists - var accountUpdated = false - val existingAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE) - for (existingAccount in existingAccounts) { - if (existingAccount == account) { - accountManager.setPassword(account, password) - accountUpdated = true - Log.d(TAG, "Updated existing account.") - } - } - - // Add new account when it does not yet exist - if (!accountUpdated) { - - // Delete unused Cyface accounts - for (existingAccount in existingAccounts) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - accountManager.removeAccountExplicitly(existingAccount) - } else { - accountManager.removeAccount(account, null, null) - } - Log.d(TAG, "Removed existing account: $existingAccount") - } - createAccount(context, login, password) - } - Validate.isTrue(accountManager.getAccountsByType(ACCOUNT_TYPE).size == 1) - } - - /** - * Creates a temporary `Account` which can only be used to check the credentials. - * - * **ATTENTION:** If the login is successful you need to use `WiFiSurveyor.makeAccountSyncable` - * to ensure the `WifiSurveyor` works as expected. We cannot inject the `WiFiSurveyor` as the - * [LoginActivity] is called by Android. - * - * @param context The current Android context (i.e. Activity or Service). - * @param username The username of the account to be created. - * @param password The password of the account to be created. May be null if a custom [CyfaceAuthenticator] is - * used instead of a LoginActivity to return tokens as in `MovebisDataCapturingService`. - */ - private fun createAccount( - context: Context, username: String, - password: String - ) { - val accountManager = AccountManager.get(context) - val newAccount = Account(username, ACCOUNT_TYPE) - Validate.isTrue(accountManager.addAccountExplicitly(newAccount, password, Bundle.EMPTY)) - Validate.isTrue(accountManager.getAccountsByType(ACCOUNT_TYPE).size == 1) - Log.v(de.cyface.synchronization.Constants.TAG, "New account added") - ContentResolver.setSyncAutomatically(newAccount, Constants.AUTHORITY, false) - // Synchronization can be disabled via {@link CyfaceDataCapturingService#setSyncEnabled} - ContentResolver.setIsSyncable(newAccount, Constants.AUTHORITY, 1) - // Do not use validateAccountFlags in production code as periodicSync flags are set async - - // PeriodicSync and syncAutomatically is set dynamically by the {@link WifiSurveyor} - } - - /** - * This method removes the existing account. This is useful as we add a temporary account to check - * the credentials but we have to remove it when the credentials are incorrect. - * - * This static method must be implemented as in the non-static `WiFiSurveyor.deleteAccount`. - * We cannot inject the `WiFiSurveyor` as the [LoginActivity] is called by Android. - * - * @param context The [Context] to get the [AccountManager] - * @param account the `Account` to be removed - */ - private fun deleteAccount(context: Context, account: Account) { - ContentResolver.removePeriodicSync(account, Constants.AUTHORITY, Bundle.EMPTY) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { - AccountManager.get(context).removeAccount(account, null, null) - } else { - AccountManager.get(context).removeAccountExplicitly(account) - } - } - } -} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt index 427b8878..6682d959 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt @@ -24,6 +24,8 @@ import android.accounts.AccountManager import android.accounts.AccountManagerFuture import android.accounts.AuthenticatorException import android.accounts.OperationCanceledException +import android.app.Activity +import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Bundle @@ -31,6 +33,8 @@ import android.os.Handler import android.os.Message import android.preference.PreferenceManager import android.util.Log +import android.widget.Toast +import androidx.annotation.MainThread import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat @@ -40,13 +44,13 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController -import de.cyface.app.BuildConfig -import de.cyface.app.CameraServiceProvider -import de.cyface.app.R +import de.cyface.app.auth.LoginActivity import de.cyface.app.databinding.ActivityMainBinding import de.cyface.app.notification.CameraEventHandler import de.cyface.app.notification.DataCapturingEventHandler import de.cyface.app.utils.Constants +import de.cyface.app.utils.Constants.ACCOUNT_TYPE +import de.cyface.app.utils.Constants.AUTHORITY import de.cyface.app.utils.ServiceProvider import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY import de.cyface.app.utils.SharedConstants.DEFAULT_SENSOR_FREQUENCY @@ -64,11 +68,16 @@ import de.cyface.energy_settings.TrackingSettings.showGnssWarningDialog import de.cyface.energy_settings.TrackingSettings.showProblematicManufacturerDialog import de.cyface.energy_settings.TrackingSettings.showRestrictedBackgroundProcessingWarningDialog import de.cyface.persistence.model.ParcelableGeoLocation +import de.cyface.synchronization.OAuth2 +import de.cyface.synchronization.OAuth2.Companion.END_SESSION_REQUEST_CODE import de.cyface.synchronization.WiFiSurveyor import de.cyface.uploader.exception.SynchronisationException import de.cyface.utils.DiskConsumption import de.cyface.utils.Validate import io.sentry.Sentry +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.TokenResponse import java.io.IOException import java.lang.ref.WeakReference @@ -76,9 +85,13 @@ import java.lang.ref.WeakReference * The base `Activity` for the actual Cyface measurement client. It's called by the [TermsOfUseActivity] * class. * + * It calls the [de.cyface.app.auth.LoginActivity] if the user is unauthorized and uses the + * outcome of the OAuth 2 authorization flow to negotiate the final authorized state. This is done + * by performing the "authorization code exchange" if required. + * * @author Klemens Muthmann * @author Armin Schnabel - * @version 3.3.1 + * @version 4.0.0 * @since 1.0.0 */ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider { @@ -108,6 +121,11 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider */ private var preferences: SharedPreferences? = null + /** + * The authorization. + */ + override lateinit var auth: OAuth2 + /** * Instead of registering the `DataCapturingButton/CapturingFragment` here, the `CapturingFragment` * just registers and unregisters itself. @@ -178,17 +196,17 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider try { capturing = CyfaceDataCapturingService( this.applicationContext, - Constants.AUTHORITY, - Constants.ACCOUNT_TYPE, + AUTHORITY, + ACCOUNT_TYPE, BuildConfig.cyfaceServer, - BuildConfig.authServer, + OAuth2.Companion.oauthConfig(BuildConfig.oauthRedirect, BuildConfig.oauthDiscovery), DataCapturingEventHandler(), unInterestedListener, // here was the capturing button but it registers itself, too sensorFrequency ) // Needs to be called after new CyfaceDataCapturingService() for the SDK to check and throw // a specific exception when the LOGIN_ACTIVITY was not set from the SDK using app. - startSynchronization() + // startSynchronization() // This is done in onAuthorized() instead // TODO: dataCapturingService!!.addConnectionStatusListener(this) cameraService = CameraService( this.applicationContext, @@ -199,6 +217,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider throw IllegalStateException(e) } + /****************************************************************************************/ // Crashes with RuntimeException: capturing not initialized when this is at the top of `onCreate` super.onCreate(savedInstanceState) @@ -229,6 +248,49 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider // Not showing manufacturer warning on each resume to increase likelihood that it's read showProblematicManufacturerDialog(this, false, Constants.SUPPORT_EMAIL) + + // Authorization + auth = OAuth2(applicationContext) + } + + override fun onStart() { + super.onStart() + + // All good, user is authorized + if (auth.isAuthorized()) { + onAuthorized("onStart") + return + } + + // the stored AuthState is incomplete, so check if we are currently receiving the result of + // the authorization flow from the browser. + val response = AuthorizationResponse.fromIntent(intent) + val ex = AuthorizationException.fromIntent(intent) + if (response != null || ex != null) { + auth.updateAfterAuthorization(response, ex) + } + if (response?.authorizationCode != null) { + // authorization code exchange is required + auth.updateAfterAuthorization(response, ex) + exchangeAuthorizationCode(response) + } else if (ex != null) { + onUnauthorized("Auth flow failed: " + ex.message) + } else { + // The user is not logged in / logged out -> LoginActivity is called + onUnauthorized("No auth state retained - re-auth required", false) + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Authorization + if (requestCode == END_SESSION_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + Handler().postDelayed({ signOut(true); finish() }, 2000) + } else { + show("Sign out canceled") + } } /** @@ -247,6 +309,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider override fun onDestroy() { super.onDestroy() + // Clean up CyfaceDataCapturingService try { // As the WifiSurveyor WiFiSurveyor.startSurveillance() tells us to @@ -259,6 +322,9 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider } Log.w(TAG, "Failed to shut down CyfaceDataCapturingService. ", e) } + + // Authorization + auth.dispose() } /** @@ -281,10 +347,10 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider return } - // The LoginActivity is called by Android which handles the account creation + // The LoginActivity is called by Android which handles the account creation (authentication) Log.d(TAG, "startSynchronization: No validAccountExists, requesting LoginActivity") accountManager.addAccount( - Constants.ACCOUNT_TYPE, + ACCOUNT_TYPE, de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE, null, null, @@ -297,7 +363,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider // The LoginActivity created a temporary account which cannot be used for synchronization. // As the login was successful we now register the account correctly: - val account = accountManager1.getAccountsByType(Constants.ACCOUNT_TYPE)[0] + val account = accountManager1.getAccountsByType(ACCOUNT_TYPE)[0] Validate.notNull(account) // Set synchronizationEnabled to the current user preferences @@ -315,7 +381,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider capturing.startWifiSurveyor() } catch (e: OperationCanceledException) { // Remove temp account when LoginActivity is closed during login [CY-5087] - val accounts = accountManager1.getAccountsByType(Constants.ACCOUNT_TYPE) + val accounts = accountManager1.getAccountsByType(ACCOUNT_TYPE) if (accounts.isNotEmpty()) { val account = accounts[0] accountManager1.removeAccount(account, null, null) @@ -334,6 +400,81 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider ) } + @MainThread + private fun onUnauthorized(explanation: String, explain: Boolean = true) { + runOnUiThread { + if (explain) { + show("unauthorized, logging out ... ($explanation)") + Handler().postDelayed({ signOut(false) }, 2000) + } else { + signOut(false) + } + } + } + + @MainThread + private fun onAuthorized(message: String) { + runOnUiThread { + Log.d(TAG, "authorized ($message)") + startSynchronization() + } + } + + @MainThread + private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { + Log.d(TAG, "Exchanging authorization code") + val requestSuccessful = auth.performTokenRequest( + authorizationResponse.createTokenExchangeRequest() + ) { tokenResponse: TokenResponse?, authException: AuthorizationException? -> + val authSuccessful = auth.handleCodeExchangeResponse( + tokenResponse, + authException, + ACCOUNT_TYPE, + applicationContext, + AUTHORITY + ) + if (authSuccessful) { + onAuthorized("code exchanged, account updated") + } else { + onUnauthorized( + ("Authorization Code exchange failed" + + if (authException != null) authException.error else "") + ) + } + } + if (!requestSuccessful) { + onUnauthorized("Client authentication method is unsupported") + } + } + + /** + * When the user explicitly wants to sign out, we need to send an `endSession` request to the + * auth server, see `MenuProvider.logout`. + * + * Here we only want to clear the local data about the current session, when something changes. + */ + @MainThread + private fun signOut(removeAccount: Boolean = false) { + auth.signOut() + + // E.g. `MainActivity.onStart()` calls `signOut()` when the user is already signed out + // so there is no account to be removed. + if (removeAccount) { + // Also remove account from account manager + capturing.removeAccount(capturing.wiFiSurveyor.account.name) + } + + val mainIntent = Intent(this, LoginActivity::class.java) + mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(mainIntent) + finish() + } + + @MainThread + private fun show(message: String) { + Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() + } + /** * Handles incoming inter process communication messages from services used by this application. * This is required to update the UI based on changes within those services (e.g. status). @@ -396,7 +537,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider */ @JvmStatic fun accountWithTokenExists(accountManager: AccountManager): Boolean { - val existingAccounts = accountManager.getAccountsByType(Constants.ACCOUNT_TYPE) + val existingAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE) Validate.isTrue(existingAccounts.size < 2, "More than one account exists.") return existingAccounts.isNotEmpty() } diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt b/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt index c80c33eb..f81e5a91 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt @@ -24,7 +24,7 @@ import android.content.SharedPreferences import android.preference.PreferenceManager import android.widget.Toast import androidx.localbroadcastmanager.content.LocalBroadcastManager -import de.cyface.app.LoginActivity +import de.cyface.app.auth.LoginActivity import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY import de.cyface.synchronization.CyfaceAuthenticator import de.cyface.synchronization.ErrorHandler diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt deleted file mode 100644 index 84963c85..00000000 --- a/ui/cyface/src/main/kotlin/de/cyface/app/RegistrationActivity.kt +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright 2023 Cyface GmbH - * - * This file is part of the Cyface App for Android. - * - * The Cyface App for Android is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Cyface App for Android 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with the Cyface App for Android. If not, see . - */ -package de.cyface.app - -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Bundle -import android.preference.PreferenceManager -import android.util.Log -import android.util.Patterns -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.widget.Button -import android.widget.ProgressBar -import androidx.fragment.app.FragmentActivity -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textview.MaterialTextView -import com.hcaptcha.sdk.HCaptcha -import com.hcaptcha.sdk.HCaptchaConfig -import com.hcaptcha.sdk.HCaptchaException -import com.hcaptcha.sdk.HCaptchaSize -import com.hcaptcha.sdk.HCaptchaTheme -import com.hcaptcha.sdk.HCaptchaTokenResponse -import de.cyface.app.LoginActivity -import de.cyface.app.utils.Constants.TAG -import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY -import de.cyface.model.Activation -import de.cyface.synchronization.SyncService.AUTH_ENDPOINT_URL_SETTINGS_KEY -import de.cyface.uploader.DefaultAuthenticator -import de.cyface.uploader.Result -import de.cyface.uploader.exception.ConflictException -import de.cyface.uploader.exception.RegistrationFailed -import de.cyface.utils.Validate -import io.sentry.Sentry -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.lang.ref.WeakReference -import java.util.regex.Pattern - -/** - * A registration screen that offers registration via email/password and captcha. - * - * @author Armin Schnabel - * @version 1.0.0 - * @since 3.3.0 - */ -class RegistrationActivity : FragmentActivity() /* HCaptcha requires FragmentActivity */ { - - private lateinit var context: WeakReference - private var preferences: SharedPreferences? = null - - // UI references - private var progressBar: ProgressBar? = null - - /** - * A `Button` which is used to confirm the entered credentials. - */ - private var registrationButton: Button? = null - - /** - * Needs to be resettable for testing. That's the only way to mock a single method of Android's Activity's - */ - private var emailInput: TextInputEditText? = null - private var passwordInput: TextInputEditText? = null - private var passwordConfirmationInput: TextInputEditText? = null - private var messageView: MaterialTextView? = null - - /** - * Intent for switching back to the login activity when the user clicks the link. - */ - private var callLoginActivityIntent: Intent? = null - - /** - * Needs to be resettable for testing. That's the only way to mock a single method of Android's Activity's - */ - private var eMailPattern: Pattern = Patterns.EMAIL_ADDRESS - - private lateinit var hCaptcha: HCaptcha - private var tokenResponse: HCaptchaTokenResponse? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_registration) - context = WeakReference(this) - preferences = PreferenceManager.getDefaultSharedPreferences(this) - setServerUrl() // TODO [CY-3735]: via Android's settings - - // Set up the login form - emailInput = findViewById(R.id.input_email) - passwordInput = findViewById(R.id.input_password) - passwordConfirmationInput = findViewById(R.id.input_password_confirmation) - messageView = findViewById(R.id.registration_message) - registrationButton = findViewById(R.id.registration_button) - registrationButton!!.setOnClickListener { attemptRegistration() } - progressBar = findViewById(R.id.registration_progress_bar) - callLoginActivityIntent = Intent(this, LoginActivity::class.java) - registerLoginLink() - - // HCaptcha - hCaptcha = HCaptcha.getClient(context.get()!!).setup(hCaptchaConfig()) - setupHCaptcha(hCaptcha) - } - - override fun onDestroy() { - super.onDestroy() - } - - private fun hCaptchaConfig(): HCaptchaConfig { - return HCaptchaConfig.builder() - .siteKey(BuildConfig.hCaptchaKey) - .size(HCaptchaSize.NORMAL) - .theme(HCaptchaTheme.LIGHT) - .build() - } - - private fun setupHCaptcha(hCaptcha: HCaptcha) { - hCaptcha - .addOnSuccessListener { response: HCaptchaTokenResponse -> - tokenResponse = response - // As we directly use the token we don't listen to the token timeout event - // but instead mark the token instantly as used, to prevent "Captcha failed" error - // after the correct error is shown in the Registration UI (and some time passed) - response.markUsed() - register(response.tokenResult) - } - .addOnFailureListener { e: HCaptchaException -> - progressBar!!.visibility = GONE - registrationButton!!.isEnabled = true - Log.d(TAG, "hCaptcha failed: " + e.message + "(" + e.statusCode + ")") - messageView!!.text = getString(de.cyface.app.utils.R.string.captcha_failed) - messageView!!.visibility = VISIBLE - tokenResponse = null - } - .addOnOpenListener { - messageView!!.text = "" - messageView!!.visibility = GONE - } - } - - /** - * Attempts to sign up with the data specified by the registration form. - * - * If there are form errors (invalid email, missing fields, etc.), the - * errors are presented and no actual registration attempt is made. - */ - private fun attemptRegistration() { - // Update view - emailInput!!.error = null - passwordInput!!.error = null - passwordConfirmationInput!!.error = null - messageView!!.visibility = GONE - messageView!!.text = "" - registrationButton!!.isEnabled = false - - // Check for valid credentials - Validate.notNull(emailInput!!.text) - Validate.notNull(passwordInput!!.text) - Validate.notNull(passwordConfirmationInput!!.text) - val email = emailInput!!.text.toString() - val password = passwordInput!!.text.toString() - val passwordConfirmation = passwordConfirmationInput!!.text.toString() - if (!credentialsAreValid(email, password, passwordConfirmation)) { - registrationButton!!.isEnabled = true - return - } - - // Update view - progressBar!!.isIndeterminate = true - progressBar!!.visibility = VISIBLE - hCaptcha.verifyWithHCaptcha() // calls register() on success - } - - private fun register(captcha: String) { - - val email = emailInput!!.text.toString() - val password = passwordInput!!.text.toString() - GlobalScope.launch { - // Load authUrl - val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) - val url = - preferences.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) - ?: throw IllegalStateException("Auth server url not available.") - - try { - val authenticator = DefaultAuthenticator(url) - - // Try to send the request and handle expected errors - // `CyfaceAuthenticator` can only throw `NetworkErrorException` but here we don't - // have this limitation and don't need to use `sendErrorIntent()`. - val response = authenticator.register( - email, - password, - captcha, - Activation.CYFACE_ANDROID - ) - Log.d(TAG, "Response $response") - - when (response) { - Result.UPLOAD_SUCCESSFUL -> { // 201 created - runOnUiThread { - progressBar!!.visibility = GONE - } - returnToLogin(true) - } - - else -> { - error("Unexpected response ($response), API only defines the above.") - } - } - } catch (e: Exception) { - - when (e) { - is RegistrationFailed -> { - when (e.cause) { - is ConflictException -> { - runOnUiThread { - emailInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_email_taken) - emailInput!!.requestFocus() - } - } - - // TODO: Show message for these expected exceptions in UI - /*is SynchronisationException, - is ForbiddenException, - is NetworkUnavailableException, - is TooManyRequestsException, - is HostUnresolvable, - is ServerUnavailableException, - is UnexpectedResponseCode, - is InternalServerErrorException*/ - else -> { - /*ErrorHandler.sendErrorIntent( - context.get(), - ErrorCode.SERVER_UNAVAILABLE.code, - e.message - )*/ - reportError(e) - } - } - } - else -> { - reportError(e) - } - } - runOnUiThread { - progressBar!!.visibility = GONE - // Clean up if the getAuthToken failed, else the LoginActivity is probably not shown - registrationButton!!.isEnabled = true - messageView!!.visibility = VISIBLE - messageView!!.text = getString(de.cyface.app.utils.R.string.registration_failed) - } - } - } - } - - private fun reportError(e: Exception) { - val reportingEnabled = preferences!!.getBoolean(ACCEPTED_REPORTING_KEY, false) - if (reportingEnabled) { - Sentry.captureException(e) - } - Log.e(TAG, "Registration failed with exception", e) - } - - /** - * Checks if the format of the credentials provided is valid, i.e. has the allowed length and - * is not empty and, if requested, checks if the login is an email address. - * - * @param email The login string - * @param password The password string - * @param passwordConfirmation The password confirmation string - * @return true is the credentials are in a valid format - */ - private fun credentialsAreValid( - email: String?, - password: String?, - passwordConfirmation: String? - ): Boolean { - var valid = true - if (email.isNullOrEmpty()) { - emailInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_field_required) - emailInput!!.requestFocus() - valid = false - } else if (!eMailPattern.matcher(email).matches()) { - emailInput!!.error = getString(de.cyface.app.utils.R.string.error_message_invalid_email) - emailInput!!.requestFocus() - valid = false - } - if (password.isNullOrEmpty()) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_field_required) - passwordInput!!.requestFocus() - valid = false - } else if (password.length < 6) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_password_too_short) - passwordInput!!.requestFocus() - valid = false - } else if (password.length > 20) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_password_too_short) - passwordInput!!.requestFocus() - valid = false - } - if (passwordConfirmation.isNullOrEmpty()) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_field_required) - passwordInput!!.requestFocus() - valid = false - } - if (!passwordConfirmation.equals(password)) { - passwordInput!!.error = - getString(de.cyface.app.utils.R.string.error_message_passwords_do_not_match) - passwordInput!!.requestFocus() - valid = false - } - return valid - } - - /** - * As long as the server URL is hardcoded we want to reset it when it's different from the - * default URL set in the [BuildConfig]. If not, hardcoded updates would not have an - * effect. - */ - private fun setServerUrl() { - val stored = - preferences!!.getString(AUTH_ENDPOINT_URL_SETTINGS_KEY, null) - val currentUrl = BuildConfig.authServer - @Suppress("KotlinConstantConditions") - Validate.isTrue(currentUrl != "null") - if (stored == null || stored != currentUrl) { - Log.d(TAG, "Updating Auth API URL from $stored to $currentUrl") - val editor = preferences!!.edit() - editor.putString(AUTH_ENDPOINT_URL_SETTINGS_KEY, currentUrl) - editor.apply() - } - } - - private fun registerLoginLink() { - val loginLink = findViewById(R.id.registration_link_login) - loginLink.setOnClickListener { v: View? -> returnToLogin(false) } - } - - private fun returnToLogin(registrationSuccessful: Boolean) { - callLoginActivityIntent!!.putExtra( - REGISTERED_EXTRA, - registrationSuccessful - ) - startActivity(callLoginActivityIntent) - finish() - } - - companion object { - const val REGISTERED_EXTRA = "de.cyface.registration.successful" - } -} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt new file mode 100644 index 00000000..56986ca5 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt @@ -0,0 +1,428 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.auth + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.View.VISIBLE +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.AnyThread +import androidx.annotation.ColorRes +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import androidx.browser.customtabs.CustomTabsIntent +import de.cyface.app.MainActivity +import de.cyface.app.R +import de.cyface.synchronization.AuthStateManager +import de.cyface.synchronization.Configuration +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ClientSecretBasic +import net.openid.appauth.RegistrationRequest +import net.openid.appauth.RegistrationResponse +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.browser.AnyBrowserMatcher +import net.openid.appauth.browser.BrowserMatcher +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +/** + * A login screen that offers login via email/password. + * + * *ATTENTION*: + * The browser page opened by this activity stops working on the emulator after some time. + * Use a real device to test the auth workflow for now. + * + * @author Armin Schnabel + * @version 4.0.0 + * @since 1.0.0 + */ +class LoginActivity : AppCompatActivity() { + + private val TAG = "de.cyface.app.r4r.login" + private val EXTRA_FAILED = "failed" + private val RC_AUTH = 100 + + private var mAuthService: AuthorizationService? = null + private lateinit var mAuthStateManager: AuthStateManager + private lateinit var mConfiguration: Configuration + + private val mClientId = AtomicReference() + private val mAuthRequest = AtomicReference() + private val mAuthIntent = AtomicReference() + private var mAuthIntentLatch = CountDownLatch(1) + private lateinit var mExecutor: ExecutorService + + private var mUsePendingIntents = false + + private var mBrowserMatcher: BrowserMatcher = AnyBrowserMatcher.INSTANCE + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mExecutor = Executors.newSingleThreadExecutor() + mAuthStateManager = AuthStateManager.getInstance(this) + mConfiguration = Configuration.getInstance(this) + + // Already authorized + if (mAuthStateManager.current.isAuthorized + && !mConfiguration.hasConfigurationChanged() + ) { + // As the LoginActivity should only be shown when the user is not authenticated + // we can't forward to `MainActivity` just like that as this would lead to a loop. + //Log.i(TAG, "User is already authenticated, processing to token activity") + Log.e(TAG, "User is already authenticated") + show("User is already authenticated") + finish() + return + } + + setContentView(R.layout.activity_login) + findViewById(R.id.retry).setOnClickListener { + mExecutor.submit { initializeAppAuth() } + } + findViewById(R.id.start_auth).setOnClickListener { startAuth() } + if (!mConfiguration.isValid) { + displayError(mConfiguration.configurationError!!, false) + return + } + if (mConfiguration.hasConfigurationChanged()) { + // discard any existing authorization state due to the change of configuration + Log.i(TAG, "Configuration change detected, discarding old state") + mAuthStateManager.replace(AuthState()) + mConfiguration.acceptConfiguration() + } + if (intent.getBooleanExtra(EXTRA_FAILED, false)) { + show("Authorization canceled") + } + displayLoading("Initializing") + mExecutor.submit { initializeAppAuth() } + } + + override fun onStart() { + super.onStart() + if (mExecutor.isShutdown) { + mExecutor = Executors.newSingleThreadExecutor() + } + } + + override fun onStop() { + super.onStop() + mExecutor.shutdownNow() + } + + override fun onDestroy() { + super.onDestroy() + if (mAuthService != null) { + mAuthService!!.dispose() + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + displayAuthOptions() + if (resultCode == RESULT_CANCELED) { + show("Authorization canceled") + } else { + show("Logging in ...") + //Toast.makeText(applicationContext, "Logging in ...", Toast.LENGTH_SHORT).show() + val intent = Intent(this, MainActivity::class.java) + intent.putExtras(data!!.extras!!) + startActivity(intent) + finish() // added because of our workflow where MainActivity calls LoginActivity + } + } + + @MainThread + fun startAuth() { + displayLoading("Making authorization request") + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + mExecutor.submit { doAuth() } + } + + /** + * Initializes the authorization service configuration if necessary, either from the local + * static values or by retrieving an OpenID discovery document. + */ + @WorkerThread + private fun initializeAppAuth() { + Log.i(TAG, "Initializing AppAuth") + recreateAuthorizationService() + if (mAuthStateManager.current.authorizationServiceConfiguration != null) { + // configuration is already created, skip to client initialization + Log.i(TAG, "auth config already established") + initializeClient() + return + } + + // if we are not using discovery, build the authorization service configuration directly + // from the static configuration values. + if (mConfiguration.discoveryUri == null) { + Log.i(TAG, "Creating auth config") + val config = AuthorizationServiceConfiguration( + mConfiguration.authEndpointUri!!, + mConfiguration.tokenEndpointUri!!, + mConfiguration.registrationEndpointUri, + mConfiguration.endSessionEndpoint + ) + mAuthStateManager.replace(AuthState(config)) + initializeClient() + return + } + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + runOnUiThread { displayLoading("Retrieving discovery document") } + Log.i(TAG, "Retrieving OpenID discovery doc") + AuthorizationServiceConfiguration.fetchFromUrl( + mConfiguration.discoveryUri!!, + { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? -> + handleConfigurationRetrievalResult( + config, + ex + ) + }, + mConfiguration.connectionBuilder + ) + } + + @MainThread + private fun handleConfigurationRetrievalResult( + config: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { + if (config == null) { + Log.i(TAG, "Failed to retrieve discovery document", ex) + displayError("Failed to retrieve discovery document: " + ex!!.message, true) + return + } + Log.i(TAG, "Discovery document retrieved") + mAuthStateManager.replace(AuthState(config)) + mExecutor.submit { initializeClient() } + } + + /** + * Initiates a dynamic registration request if a client ID is not provided by the static + * configuration. + */ + @WorkerThread + private fun initializeClient() { + if (mConfiguration.clientId != null) { + Log.i(TAG, "Using static client ID: " + mConfiguration.clientId) + // use a statically configured client ID + mClientId.set(mConfiguration.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + val lastResponse = mAuthStateManager.current.lastRegistrationResponse + if (lastResponse != null) { + Log.i(TAG, "Using dynamic client ID: " + lastResponse.clientId) + // already dynamically registered a client ID + mClientId.set(lastResponse.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + runOnUiThread { displayLoading("Dynamically registering client") } + Log.i(TAG, "Dynamically registering client") + val registrationRequest = RegistrationRequest.Builder( + mAuthStateManager.current.authorizationServiceConfiguration!!, + listOf(mConfiguration.redirectUri) + ) + .setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME) + .build() + mAuthService!!.performRegistrationRequest( + registrationRequest + ) { response: RegistrationResponse?, ex: AuthorizationException? -> + handleRegistrationResponse( + response, + ex + ) + } + } + + @MainThread + private fun handleRegistrationResponse( + response: RegistrationResponse?, + ex: AuthorizationException? + ) { + mAuthStateManager.updateAfterRegistration(response, ex) + if (response == null) { + Log.i(TAG, "Failed to dynamically register client", ex) + displayErrorLater("Failed to register client: " + ex!!.message, true) + return + } + Log.i(TAG, "Dynamically registered client: " + response.clientId) + mClientId.set(response.clientId) + initializeAuthRequest() + } + + /** + * Performs the authorization request, using the browser selected in the spinner, + * and a user-provided `login_hint` if available. + */ + @WorkerThread + private fun doAuth() { + try { + mAuthIntentLatch.await() + } catch (ex: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for auth intent") + } + if (mUsePendingIntents) { // We currently always use the other option below + val completionIntent = Intent(this, MainActivity::class.java) + val cancelIntent = Intent(this, LoginActivity::class.java) + cancelIntent.putExtra(EXTRA_FAILED, true) + cancelIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + var flags = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + mAuthService!!.performAuthorizationRequest( + mAuthRequest.get()!!, + PendingIntent.getActivity(this, 0, completionIntent, flags), + PendingIntent.getActivity(this, 0, cancelIntent, flags), + mAuthIntent.get()!! + ) + } else { + val intent = mAuthService!!.getAuthorizationRequestIntent( + mAuthRequest.get()!!, + mAuthIntent.get()!! + ) + startActivityForResult(intent, RC_AUTH) + } + } + + private fun recreateAuthorizationService() { + if (mAuthService != null) { + Log.i(TAG, "Discarding existing AuthService instance") + mAuthService!!.dispose() + } + mAuthService = createAuthorizationService() + mAuthRequest.set(null) + mAuthIntent.set(null) + } + + private fun createAuthorizationService(): AuthorizationService { + Log.i(TAG, "Creating authorization service") + val builder = AppAuthConfiguration.Builder() + builder.setBrowserMatcher(mBrowserMatcher) + builder.setConnectionBuilder(mConfiguration.connectionBuilder) + return AuthorizationService(this, builder.build()) + } + + @MainThread + private fun displayLoading(loadingMessage: String) { + findViewById(R.id.loading_container).visibility = VISIBLE + findViewById(R.id.auth_container).visibility = View.GONE + findViewById(R.id.error_container).visibility = View.GONE + (findViewById(R.id.loading_description) as TextView).text = + loadingMessage + } + + @MainThread + private fun displayError(error: String, recoverable: Boolean) { + findViewById(R.id.error_container).visibility = VISIBLE + findViewById(R.id.loading_container).visibility = View.GONE + findViewById(R.id.auth_container).visibility = View.GONE + (findViewById(R.id.error_description) as TextView).text = error + findViewById(R.id.retry).visibility = if (recoverable) VISIBLE else View.GONE + } + + // WrongThread inference is incorrect in this case + @AnyThread + private fun displayErrorLater( + error: String, + @Suppress("SameParameterValue") recoverable: Boolean + ) { + runOnUiThread { displayError(error, recoverable) } + } + + @MainThread + private fun initializeAuthRequest() { + createAuthRequest(this@LoginActivity) + warmUpBrowser(this@LoginActivity) + displayAuthOptions() + } + + @MainThread + private fun displayAuthOptions() { + findViewById(R.id.auth_container).visibility = VISIBLE + findViewById(R.id.loading_container).visibility = View.GONE + findViewById(R.id.error_container).visibility = View.GONE + } + + @Suppress("SameParameterValue") + @MainThread + private fun show(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + @TargetApi(Build.VERSION_CODES.M) + private fun getColorCompat(@ColorRes color: Int): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + getColor(color) + } else { + resources.getColor(color) + } + } + + companion object { + private fun warmUpBrowser(loginActivity: LoginActivity) { + loginActivity.mAuthIntentLatch = CountDownLatch(1) + loginActivity.mExecutor.execute { + Log.i(loginActivity.TAG, "Warming up browser instance for auth request") + val intentBuilder = loginActivity.mAuthService!!.createCustomTabsIntentBuilder( + loginActivity.mAuthRequest.get()!!.toUri() + ) + intentBuilder.setToolbarColor(loginActivity.getColorCompat(de.cyface.app.utils.R.color.defaultPrimary)) + loginActivity.mAuthIntent.set(intentBuilder.build()) + loginActivity.mAuthIntentLatch.countDown() + } + } + + private fun createAuthRequest(loginActivity: LoginActivity) { + Log.i(loginActivity.TAG, "Creating auth request") + val authRequestBuilder = AuthorizationRequest.Builder( + loginActivity.mAuthStateManager.current.authorizationServiceConfiguration!!, + loginActivity.mClientId.get(), + ResponseTypeValues.CODE, + loginActivity.mConfiguration.redirectUri + ) + .setScope(loginActivity.mConfiguration.scope) + loginActivity.mAuthRequest.set(authRequestBuilder.build()) + } + } +} diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/MenuProvider.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/MenuProvider.kt index 24ffd588..c5fb1129 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/MenuProvider.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/MenuProvider.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.widget.Toast import androidx.fragment.app.FragmentActivity import androidx.navigation.NavController import de.cyface.app.R @@ -37,12 +38,11 @@ import de.cyface.uploader.exception.SynchronisationException * options are shown in the action bar at the top right. * * @author Armin Schnabel - * @version 1.0.0 + * @version 2.0.0 * @since 3.2.0 */ class MenuProvider( - private val capturingService: CyfaceDataCapturingService, - private val activity: FragmentActivity, + private val activity: MainActivity, private val navController: NavController ) : androidx.core.view.MenuProvider { @@ -97,16 +97,21 @@ class MenuProvider( navController.navigate(action) true } - R.id.logout_item -> { + /*R.id.logout_item -> { try { - capturingService.removeAccount(capturingService.wiFiSurveyor.account.name) + Toast.makeText(activity.applicationContext, "Logging out ...", Toast.LENGTH_SHORT).show() + // This inform the auth server that the user wants to end its session + activity.auth.endSession(activity) + //signOut() // instead of `endSession()` to sign out softly for testing + activity.capturing.removeAccount(activity.capturing.wiFiSurveyor.account.name) } catch (e: SynchronisationException) { throw IllegalStateException(e) } // Show login screen - (activity as MainActivity).startSynchronization() + // This is done by MainActivity.onActivityResult -> signOut() + //(activity as MainActivity).startSynchronization() true - } + }*/ else -> { false } diff --git a/ui/cyface/src/main/res/layout/activity_login.xml b/ui/cyface/src/main/res/layout/activity_login.xml index db36d8bf..65c957db 100644 --- a/ui/cyface/src/main/res/layout/activity_login.xml +++ b/ui/cyface/src/main/res/layout/activity_login.xml @@ -1,157 +1,101 @@ - + android:fitsSystemWindows="true" > - - - - - - - - - - - - + android:layout_height="match_parent" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - - - + + + + + + android:orientation="vertical" + android:layout_marginTop="16dp" + android:gravity="center"> - - - - - + android:layout_height="wrap_content"/> - - + android:indeterminate="true"/> - - + - - + - - + +