SSO login is now performed in the default browser (#1400) - WIP

Use ChromeCustomTabs to host the SSO web page
This commit is contained in:
Benoit Marty 2020-06-05 01:06:52 +02:00
parent ae7a52cecf
commit d70b19fa93
14 changed files with 228 additions and 58 deletions

View File

@ -10,6 +10,7 @@ Improvements 🙌:
- Hide "X made no changes" event by default in timeline (#1430)
- Hide left rooms in breadcrumbs (#766)
- Correctly handle SSO login redirection
- SSO login is now performed in the default browser, or in Chrome Custom tab if available (#1400)
Bugfix 🐛:
- Switch theme is not fully taken into account without restarting the app

View File

@ -332,6 +332,9 @@ dependencies {
implementation 'com.google.android:flexbox:1.1.1'
implementation "androidx.autofill:autofill:$autofill_version"
// Custom Tab
implementation 'androidx.browser:browser:1.2.0'
// Passphrase strength helper
implementation 'com.nulab-inc:zxcvbn:1.2.7'

View File

@ -48,7 +48,19 @@
<activity android:name=".features.home.HomeActivity" />
<activity
android:name=".features.login.LoginActivity"
android:windowSoftInputMode="adjustResize" />
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<!-- Add intent filter to handle redirection URL after SSO login in external browser -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="riotx" />
<data android:host="riotx" />
</intent-filter>
</activity>
<activity android:name=".features.media.ImageMediaViewerActivity" />
<activity android:name=".features.media.BigImageViewerActivity" />
<activity

View File

@ -59,6 +59,7 @@ import im.vector.riotx.features.login.LoginResetPasswordSuccessFragment
import im.vector.riotx.features.login.LoginServerSelectionFragment
import im.vector.riotx.features.login.LoginServerUrlFormFragment
import im.vector.riotx.features.login.LoginSignUpSignInSelectionFragment
import im.vector.riotx.features.login.LoginSignUpSignInSsoFragment
import im.vector.riotx.features.login.LoginSplashFragment
import im.vector.riotx.features.login.LoginWaitForEmailFragment
import im.vector.riotx.features.login.LoginWebFragment
@ -217,6 +218,11 @@ interface FragmentModule {
@FragmentKey(LoginSignUpSignInSelectionFragment::class)
fun bindLoginSignUpSignInSelectionFragment(fragment: LoginSignUpSignInSelectionFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LoginSignUpSignInSsoFragment::class)
fun bindLoginSignUpSignInSsoFragment(fragment: LoginSignUpSignInSsoFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LoginSplashFragment::class)

View File

@ -21,10 +21,14 @@ import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.provider.Browser
import android.provider.MediaStore
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotx.BuildConfig
@ -64,6 +68,32 @@ fun openUrlInExternalBrowser(context: Context, uri: Uri?) {
}
}
/**
* Open url in custom tab or, if not available, in the default browser
*/
fun openUrlInChromeCustomTab(context: Context, session: CustomTabsSession?, url: String) {
try {
CustomTabsIntent.Builder()
.setToolbarColor(ContextCompat.getColor(context, R.color.riotx_background_light))
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setNavigationBarColor(ContextCompat.getColor(context, R.color.riotx_header_panel_background_light))
}
}
.setNavigationBarColor(ContextCompat.getColor(context, R.color.riotx_background_light))
.setColorScheme(CustomTabsIntent.COLOR_SCHEME_LIGHT)
// Note: setting close button icon does not work
.setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
.setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
.setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
.apply { session?.let { setSession(it) } }
.build()
.launchUrl(context, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(R.string.error_no_external_application_found)
}
}
/**
* Open sound recorder external application
*/

View File

@ -33,6 +33,7 @@ import com.airbnb.mvrx.viewModel
import com.airbnb.mvrx.withState
import im.vector.matrix.android.api.auth.registration.FlowResult
import im.vector.matrix.android.api.auth.registration.Stage
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.POP_BACK_STACK_EXCLUSIVE
@ -155,7 +156,11 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
is LoginViewEvents.OnSignModeSelected -> onSignModeSelected()
is LoginViewEvents.OnLoginFlowRetrieved ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginSignUpSignInSelectionFragment::class.java,
if (loginViewEvents.isSso) {
LoginSignUpSignInSsoFragment::class.java
} else {
LoginSignUpSignInSelectionFragment::class.java
},
option = commonOption)
is LoginViewEvents.OnWebLoginError -> onWebLoginError(loginViewEvents)
is LoginViewEvents.OnForgetPasswordClicked ->
@ -239,16 +244,14 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
SignMode.SignIn -> {
// It depends on the LoginMode
when (state.loginMode) {
LoginMode.Unknown -> error("Developer error")
LoginMode.Unknown,
LoginMode.Sso -> error("Developer error")
LoginMode.Password -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginFragment::class.java,
tag = FRAGMENT_LOGIN_TAG,
option = commonOption)
LoginMode.Sso -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginWebFragment::class.java,
option = commonOption)
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
}
}.exhaustive
}
SignMode.SignInWithMatrixId -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginFragment::class.java,
@ -257,6 +260,17 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
}.exhaustive
}
/**
* Handle the SSO redirection here
*/
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.data
?.let { tryThis { it.getQueryParameter("loginToken") } }
?.let { loginViewModel.handle(LoginAction.LoginWithToken(it)) }
}
private fun onRegistrationStageNotSupported() {
AlertDialog.Builder(this)
.setTitle(R.string.app_name)

View File

@ -113,7 +113,7 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
if (state.loginMode != LoginMode.Unknown) {
// LoginFlow for matrix.org has been retrieved
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso)))
}
}
}

View File

@ -124,7 +124,7 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment()
if (state.loginMode != LoginMode.Unknown) {
// The home server url is valid
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso)))
}
}
}

View File

@ -26,13 +26,11 @@ import javax.inject.Inject
/**
* In this screen, the user is asked to sign up or to sign in to the homeserver
*/
class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() {
open class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() {
override fun getLayoutResId() = R.layout.fragment_login_signup_signin_selection
private var isSsoSignIn: Boolean = false
private fun setupUi(state: LoginViewState) {
protected fun setupUi(state: LoginViewState) {
when (state.serverType) {
ServerType.MatrixOrg -> {
loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
@ -54,25 +52,14 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
}
}
private fun setupButtons(state: LoginViewState) {
isSsoSignIn = state.loginMode == LoginMode.Sso
if (isSsoSignIn) {
loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
loginSignupSigninSignIn.isVisible = false
} else {
loginSignupSigninSubmit.text = getString(R.string.login_signup)
loginSignupSigninSignIn.isVisible = true
}
private fun setupButtons() {
loginSignupSigninSubmit.text = getString(R.string.login_signup)
loginSignupSigninSignIn.isVisible = true
}
@OnClick(R.id.loginSignupSigninSubmit)
fun signUp() {
if (isSsoSignIn) {
signIn()
} else {
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp))
}
open fun submit() {
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp))
}
@OnClick(R.id.loginSignupSigninSignIn)
@ -86,6 +73,6 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
override fun updateWithState(state: LoginViewState) {
setupUi(state)
setupButtons(state)
setupButtons()
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.login
import android.content.ComponentName
import android.net.Uri
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.view.isVisible
import im.vector.riotx.R
import im.vector.riotx.core.utils.openUrlInChromeCustomTab
import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.*
import javax.inject.Inject
/**
* In this screen, the user is asked to sign up or to sign in using SSO
* This Fragment binds a CustomTabsServiceConnection if available, then prefetch the SSO url, as it will be likely to be opened.
*/
open class LoginSignUpSignInSsoFragment @Inject constructor() : LoginSignUpSignInSelectionFragment() {
private var ssoUrl: String? = null
private var customTabsServiceConnection: CustomTabsServiceConnection? = null
private var customTabsClient: CustomTabsClient? = null
private var customTabsSession: CustomTabsSession? = null
override fun onStart() {
super.onStart()
val packageName = CustomTabsClient.getPackageName(requireContext(), null)
if (packageName != null) {
customTabsServiceConnection = object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
customTabsClient = client
.also { it.warmup(0L) }
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
.also {
CustomTabsClient.bindCustomTabsService(
requireContext(),
// Despite the API, packageName cannot be null
packageName,
it
)
}
}
}
private fun prefetchUrl(url: String) {
if (ssoUrl != null) return
ssoUrl = url
if (customTabsSession == null) {
customTabsSession = customTabsClient?.newSession(null)
}
customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null)
}
override fun onStop() {
super.onStop()
customTabsServiceConnection?.let { requireContext().unbindService(it) }
customTabsServiceConnection = null
}
private fun setupButtons() {
loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
loginSignupSigninSignIn.isVisible = false
}
override fun submit() {
ssoUrl?.let { openUrlInChromeCustomTab(requireContext(), customTabsSession, it) }
}
override fun updateWithState(state: LoginViewState) {
setupUi(state)
setupButtons()
prefetchUrl(state.getSsoUrl())
}
}

View File

@ -34,7 +34,7 @@ sealed class LoginViewEvents : VectorViewEvents {
object OpenServerSelection : LoginViewEvents()
object OnServerSelectionDone : LoginViewEvents()
object OnLoginFlowRetrieved : LoginViewEvents()
data class OnLoginFlowRetrieved(val isSso: Boolean) : LoginViewEvents()
object OnSignModeSelected : LoginViewEvents()
object OnForgetPasswordClicked : LoginViewEvents()
object OnResetPasswordSendThreePidDone : LoginViewEvents()

View File

@ -22,6 +22,9 @@ import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.PersistState
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.auth.SSO_FALLBACK_PATH
import im.vector.matrix.android.api.auth.SSO_REDIRECT_URL_PARAM
import im.vector.riotx.core.extensions.appendParamToUrl
data class LoginViewState(
val asyncLoginAction: Async<Unit> = Uninitialized,
@ -64,4 +67,22 @@ data class LoginViewState(
fun isUserLogged(): Boolean {
return asyncLoginAction is Success
}
fun getSsoUrl(): String {
return buildString {
append(homeServerUrl?.trim { it == '/' })
append(SSO_FALLBACK_PATH)
// Set a redirect url we will intercept later
appendParamToUrl(SSO_REDIRECT_URL_PARAM, RIOTX_REDIRECT_URL)
deviceId?.takeIf { it.isNotBlank() }?.let {
// But https://github.com/matrix-org/synapse/issues/5755
appendParamToUrl("device_id", it)
}
}
}
companion object {
// Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string
private const val RIOTX_REDIRECT_URL = "riotx://riotx"
}
}

View File

@ -21,7 +21,6 @@ package im.vector.riotx.features.login
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
@ -34,10 +33,7 @@ import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.activityViewModel
import im.vector.matrix.android.api.auth.LOGIN_FALLBACK_PATH
import im.vector.matrix.android.api.auth.REGISTER_FALLBACK_PATH
import im.vector.matrix.android.api.auth.SSO_FALLBACK_PATH
import im.vector.matrix.android.api.auth.SSO_REDIRECT_URL_PARAM
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.riotx.R
import im.vector.riotx.core.extensions.appendParamToUrl
@ -50,18 +46,13 @@ import java.net.URLDecoder
import javax.inject.Inject
/**
* This screen is displayed for SSO login and also when the application does not support login flow or registration flow
* This screen is displayed when the application does not support login flow or registration flow
* of the homeserver, as a fallback to login or to create an account
*/
class LoginWebFragment @Inject constructor(
private val assetReader: AssetReader
) : AbstractLoginFragment() {
companion object {
// Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string
private const val REDIRECT_URL = "riotx://riotx"
}
override fun getLayoutResId() = R.layout.fragment_login_web
private var isWebViewLoaded = false
@ -135,13 +126,7 @@ class LoginWebFragment @Inject constructor(
val url = buildString {
append(state.homeServerUrl?.trim { it == '/' })
if (state.signMode == SignMode.SignIn) {
if (state.loginMode == LoginMode.Sso) {
append(SSO_FALLBACK_PATH)
// Set a redirect url we will intercept later
appendParamToUrl(SSO_REDIRECT_URL_PARAM, REDIRECT_URL)
} else {
append(LOGIN_FALLBACK_PATH)
}
append(LOGIN_FALLBACK_PATH)
state.deviceId?.takeIf { it.isNotBlank() }?.let {
// But https://github.com/matrix-org/synapse/issues/5755
appendParamToUrl("device_id", it)
@ -261,8 +246,6 @@ class LoginWebFragment @Inject constructor(
}
}
return true
} else if (url.startsWith(REDIRECT_URL)) {
return handleSsoLoginSuccess(url)
}
return super.shouldOverrideUrlLoading(view, url)
@ -270,14 +253,6 @@ class LoginWebFragment @Inject constructor(
}
}
private fun handleSsoLoginSuccess(url: String): Boolean {
val uri = Uri.parse(url)
val loginToken = tryThis { uri.getQueryParameter("loginToken") } ?: return false
loginViewModel.handle(LoginAction.LoginWithToken(loginToken))
return true
}
private fun notifyViewModel(credentials: Credentials) {
if (isForSessionRecovery) {
val softLogoutViewModel: SoftLogoutViewModel by activityViewModel()

View File

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M20,12H4"
android:strokeWidth="2"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"
android:strokeLineJoin="round"
tools:strokeColor="#00F000" />
<path
android:fillColor="#00000000"
android:pathData="M10,18L4,12L10,6"
android:strokeWidth="2"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"
android:strokeLineJoin="round"
tools:strokeColor="#00F000" />
</vector>