Login screens: Captcha step for registration

This commit is contained in:
Benoit Marty 2019-11-19 13:30:15 +01:00
parent 95fc20dca0
commit dfbf448bb7
15 changed files with 358 additions and 89 deletions

View file

@ -18,15 +18,58 @@ package im.vector.matrix.android.internal.auth.registration
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
/**
* Open class, parent to all possible authentication parameters
*/
@JsonClass(generateAdapter = true)
open class AuthParams(
internal data class AuthParams(
@Json(name = "type")
val type: String,
@Json(name = "session")
val session: String
val session: String,
/**
* parameter for "m.login.recaptcha" type
*/
@Json(name = "response")
val captchaResponse: String? = null,
/**
* parameter for "m.login.email.identity" type
*/
@Json(name = "threepid_creds")
val threePidCredentials: ThreePidCredentials? = null
) {
companion object {
fun createForCaptcha(session: String, captchaResponse: String): AuthParams {
return AuthParams(
type = LoginFlowTypes.RECAPTCHA,
session = session,
captchaResponse = captchaResponse
)
}
fun createForEmailIdentity(session: String, threePidCredentials: ThreePidCredentials): AuthParams {
return AuthParams(
type = LoginFlowTypes.EMAIL_IDENTITY,
session = session,
threePidCredentials = threePidCredentials
)
}
}
}
data class ThreePidCredentials(
@Json(name = "client_secret")
val clientSecret: String? = null,
@Json(name = "id_server")
val idServer: String? = null,
val sid: String? = null
)

View file

@ -1,30 +0,0 @@
/*
* Copyright 2018 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.matrix.android.internal.auth.registration
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
/**
* Class to define the authentication parameters for "m.login.recaptcha" type
*/
@JsonClass(generateAdapter = true)
class AuthParamsCaptcha(session: String,
@Json(name = "response")
val response: String)
: AuthParams(LoginFlowTypes.RECAPTCHA, session)

View file

@ -1,40 +0,0 @@
/*
* Copyright 2018 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.matrix.android.internal.auth.registration
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
/**
* Class to define the authentication parameters for "m.login.email.identity" type
*/
@JsonClass(generateAdapter = true)
class AuthParamsEmailIdentity(session: String,
@Json(name = "threepid_creds")
val threePidCredentials: ThreePidCredentials)
: AuthParams(LoginFlowTypes.EMAIL_IDENTITY, session)
data class ThreePidCredentials(
@Json(name = "client_secret")
val clientSecret: String? = null,
@Json(name = "id_server")
val idServer: String? = null,
val sid: String? = null
)

View file

@ -70,9 +70,7 @@ internal class DefaultRegistrationWizard(private val homeServerConnectionConfig:
return performRegistrationRequest(
RegistrationParams(
auth = AuthParamsCaptcha(
session = safeSession,
response = response)
auth = AuthParams.createForCaptcha(safeSession, response)
), callback)
}

View file

@ -53,7 +53,16 @@ data class RegistrationFlowResponse(
* For example, the public key of reCAPTCHA stage could be given here.
*/
@Json(name = "params")
var params: JsonDict? = null
var params: JsonDict? = null,
/**
* The two MatrixError fields can also be present here in case of error when validating a stage
*/
@Json(name = "errcode")
var code: String? = null,
@Json(name = "error")
var message: String? = null
)
/**

View file

@ -24,7 +24,7 @@ import com.squareup.moshi.JsonClass
* Class to pass parameters to the different registration types for /register.
*/
@JsonClass(generateAdapter = true)
data class RegistrationParams(
internal data class RegistrationParams(
// authentication parameters
@Json(name = "auth")
val auth: AuthParams? = null,

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
var verifyCallback = function(response) {
var iframe = document.createElement('iframe');
iframe.setAttribute('src', 'js:' + JSON.stringify({'action': 'verifyCallback', 'response': response}));
document.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
iframe = null;
};
var onloadCallback = function() {
grecaptcha.render('recaptcha_widget', { 'sitekey' : '%s', 'callback': verifyCallback });
};
</script>
</head>
<body>
<div id="recaptcha_widget"></div>
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>
</body>
</html>

View file

@ -114,6 +114,11 @@ interface FragmentModule {
@FragmentKey(LoginFragment::class)
fun bindLoginFragment(fragment: LoginFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LoginCaptchaFragment::class)
fun bindLoginCaptchaFragment(fragment: LoginCaptchaFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LoginServerUrlFormFragment::class)

View file

@ -29,6 +29,7 @@ import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.EmojiCompatWrapper
import im.vector.riotx.VectorApplication
import im.vector.riotx.core.pushers.PushersManager
import im.vector.riotx.core.utils.AssetReader
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.configuration.VectorConfiguration
import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler
@ -70,6 +71,8 @@ interface VectorComponent {
fun resources(): Resources
fun assetReader(): AssetReader
fun dimensionConverter(): DimensionConverter
fun vectorConfiguration(): VectorConfiguration

View file

@ -0,0 +1,62 @@
/*
* Copyright 2018 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.core.utils
import android.content.Context
import timber.log.Timber
import javax.inject.Inject
/**
* Read asset files
*/
class AssetReader @Inject constructor(private val context: Context) {
/* ==========================================================================================
* CACHE
* ========================================================================================== */
private val cache = mutableMapOf<String, String?>()
/**
* Read an asset from resource and return a String or null in case of error.
*
* @param assetFilename Asset filename
* @return the content of the asset file, or null in case of error
*/
fun readAssetFile(assetFilename: String): String? {
return cache.getOrPut(assetFilename, {
return try {
context.assets.open(assetFilename)
.use { asset ->
buildString {
var ch = asset.read()
while (ch != -1) {
append(ch.toChar())
ch = asset.read()
}
}
}
} catch (e: Exception) {
Timber.e(e, "## readAssetFile() failed")
null
}
})
}
fun clearCache() {
cache.clear()
}
}

View file

@ -35,7 +35,7 @@ sealed class LoginAction : VectorViewModelAction {
data class AddEmail(val email: String) : RegisterAction()
data class AddMsisdn(val msisdn: String) : RegisterAction()
data class ConfirmMsisdn(val code: String) : RegisterAction()
data class PerformCaptcha(val captcha: String /* TODO Add other params */) : RegisterAction()
data class CaptchaDone(val captchaResponse: String) : RegisterAction()
// Reset actions
open class ResetAction : LoginAction()

View file

@ -201,12 +201,14 @@ class LoginActivity : VectorBaseActivity() {
private fun doStage(stage: Stage) {
when (stage) {
is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginCaptchaFragment::class.java)
is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginCaptchaFragment::class.java, LoginCaptchaFragmentArgument(stage.publicKey))
is Stage.Email -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginGenericTextInputFormFragment::class.java,
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory))
is Stage.Msisdn
-> addFragmentToBackstack(R.id.loginFragmentContainer, LoginGenericTextInputFormFragment::class.java,
-> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginGenericTextInputFormFragment::class.java,
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory))
is Stage.Terms
-> TODO()

View file

@ -16,17 +16,165 @@
package im.vector.riotx.features.login
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.KeyEvent
import android.view.View
import android.webkit.*
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.args
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.riotx.R
import im.vector.riotx.core.utils.AssetReader
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_login_captcha.*
import timber.log.Timber
import java.net.URLDecoder
import java.util.*
import javax.inject.Inject
@Parcelize
data class LoginCaptchaFragmentArgument(
val siteKey: String
) : Parcelable
@JsonClass(generateAdapter = true)
data class JavascriptResponse(
@Json(name = "action")
val action: String? = null,
@Json(name = "response")
val response: String? = null
)
/**
* In this screen, the user is asked to confirm he is not a robot
*/
class LoginCaptchaFragment @Inject constructor() : AbstractLoginFragment() {
class LoginCaptchaFragment @Inject constructor(private val assetReader: AssetReader) : AbstractLoginFragment() {
override fun getLayoutResId() = R.layout.fragment_login_captcha
// TODO
private val params: LoginCaptchaFragmentArgument by args()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupWebView()
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
loginCaptchaWevView.settings.javaScriptEnabled = true
val reCaptchaPage = assetReader.readAssetFile("reCaptchaPage.html") ?: error("missing asset reCaptchaPage.html")
val html = Formatter().format(reCaptchaPage, params.siteKey).toString()
val mime = "text/html"
val encoding = "utf-8"
val homeServerUrl = loginViewModel.getHomeServerUrl()
loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null)
loginCaptchaWevView.requestLayout()
loginCaptchaWevView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
// TODO Hide loader
}
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
Timber.d("## onReceivedSslError() : " + error.certificate)
if (!isAdded) {
return
}
AlertDialog.Builder(requireActivity())
.setMessage(R.string.ssl_could_not_verify)
.setPositiveButton(R.string.ssl_trust) { _, _ ->
Timber.d("## onReceivedSslError() : the user trusted")
handler.proceed()
}
.setNegativeButton(R.string.ssl_do_not_trust) { _, _ ->
Timber.d("## onReceivedSslError() : the user did not trust")
handler.cancel()
}
.setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
handler.cancel()
Timber.d("## onReceivedSslError() : the user dismisses the trust dialog.")
dialog.dismiss()
return@OnKeyListener true
}
false
})
.setCancelable(false)
.show()
}
// common error message
private fun onError(errorMessage: String) {
Timber.e("## onError() : $errorMessage")
// TODO
// Toast.makeText(this@AccountCreationCaptchaActivity, errorMessage, Toast.LENGTH_LONG).show()
// on error case, close this activity
// runOnUiThread(Runnable { finish() })
}
@SuppressLint("NewApi")
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
super.onReceivedHttpError(view, request, errorResponse)
if (request.url.toString().endsWith("favicon.ico")) {
// Ignore this error
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
onError(errorResponse.reasonPhrase)
} else {
onError(errorResponse.toString())
}
}
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
@Suppress("DEPRECATION")
super.onReceivedError(view, errorCode, description, failingUrl)
onError(description)
}
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
if (url?.startsWith("js:") == true) {
var json = url.substring(3)
var parameters: JavascriptResponse? = null
try {
// URL decode
json = URLDecoder.decode(json, "UTF-8")
parameters = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java).fromJson(json)
} catch (e: Exception) {
Timber.e(e, "## shouldOverrideUrlLoading(): failed")
}
val response = parameters?.response
if (parameters?.action == "verifyCallback" && response != null) {
loginViewModel.handle(LoginAction.CaptchaDone(response))
}
}
return true
}
}
}
override fun resetViewModel() {
// Nothing to do

View file

@ -99,6 +99,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
private fun handleRegisterAction(action: LoginAction.RegisterAction) {
when (action) {
is LoginAction.RegisterWith -> handleRegisterWith(action)
is LoginAction.CaptchaDone -> handleCaptchaDone(action)
// TODO Add other actions here
}
}
@ -114,6 +115,44 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
override fun onSuccess(data: RegistrationResult) {
isPasswordSent = true
setState {
copy(
asyncRegistration = Success(data)
)
}
when (data) {
is RegistrationResult.Success -> onSessionCreated(data.session)
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
}
}
override fun onFailure(failure: Throwable) {
// TODO Handled JobCancellationException
setState {
copy(
asyncRegistration = Fail(failure)
)
}
}
})
}
private fun handleCaptchaDone(action: LoginAction.CaptchaDone) {
setState {
copy(
asyncRegistration = Loading()
)
}
currentTask = registrationWizard?.performReCaptcha(action.captchaResponse, object : MatrixCallback<RegistrationResult> {
override fun onSuccess(data: RegistrationResult) {
setState {
copy(
asyncRegistration = Success(data)
)
}
when (data) {
is RegistrationResult.Success -> onSessionCreated(data.session)
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
@ -284,6 +323,12 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
currentTask = registrationWizard?.getRegistrationFlow(object : MatrixCallback<RegistrationResult> {
override fun onSuccess(data: RegistrationResult) {
setState {
copy(
asyncRegistration = Success(data)
)
}
when (data) {
is RegistrationResult.Success -> onSessionCreated(data.session)
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
@ -305,12 +350,6 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
private fun onFlowResponse(flowResult: FlowResult) {
// Notify the user
_viewEvents.post(LoginViewEvents.RegistrationFlowResult(flowResult))
setState {
copy(
asyncRegistration = Uninitialized
)
}
}

View file

@ -22,11 +22,19 @@
style="@style/LoginTopIcon"
android:layout_gravity="center_horizontal" />
<TextView
style="@style/TextAppearance.Vector.Login.Text.Small"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
android:text="@string/auth_recaptcha_message" />
<WebView
android:id="@+id/loginCaptchaWevView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="22dp" />
android:layout_marginTop="8dp" />
</LinearLayout>