Allow for captcha solving for reg v2.

This commit is contained in:
Nicholas Tinsley
2024-05-07 18:10:10 -04:00
committed by Alex Hart
parent ba4cdea75d
commit ca14ed9b2c
12 changed files with 247 additions and 66 deletions

View File

@@ -1,11 +1,11 @@
package org.thoughtcrime.securesms.registration.fragments;
final class RegistrationConstants {
public final class RegistrationConstants {
private RegistrationConstants() {
}
static final String TERMS_AND_CONDITIONS_URL = "https://signal.org/legal";
static final String SIGNAL_CAPTCHA_SCHEME = "signalcaptcha://";
public static final String TERMS_AND_CONDITIONS_URL = "https://signal.org/legal";
public static final String SIGNAL_CAPTCHA_SCHEME = "signalcaptcha://";
}

View File

@@ -265,33 +265,31 @@ object RegistrationRepository {
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val result = (
if (fcmToken == null) {
api.createRegistrationSession(null, mcc, mnc)
} else {
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
).then { session ->
val sessionId = session.body.id
SignalStore.registrationValues().sessionId = sessionId
SignalStore.registrationValues().sessionE164 = e164
if (!session.body.allowedToRequestCode) {
val challenges = session.body.requestedInformation.joinToString()
Log.w(TAG, "Not allowed to request code! Remaining challenges: $challenges")
// TODO [regv2]: actually handle challenges
}
// TODO [regv2]: support other verification code [Mode] options
if (mode == Mode.PHONE_CALL) {
// TODO [regv2]
val notImplementedError = NotImplementedError()
Log.w(TAG, "Not yet implemented!", notImplementedError)
NetworkResult.ApplicationError(notImplementedError)
} else {
api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
}
val registrationSessionResult = if (fcmToken == null) {
api.createRegistrationSession(null, mcc, mnc)
} else {
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
val session = registrationSessionResult.successOrThrow()
val sessionId = session.body.id
SignalStore.registrationValues().sessionId = sessionId
SignalStore.registrationValues().sessionE164 = e164
if (!session.body.allowedToRequestCode) {
val challenges = session.body.requestedInformation.joinToString()
Log.w(TAG, "Not allowed to request code! Remaining challenges: $challenges")
return@withContext VerificationCodeRequestResult.from(registrationSessionResult)
}
// TODO [regv2]: support other verification code [Mode] options
if (mode == Mode.PHONE_CALL) {
// TODO [regv2]
val notImplementedError = NotImplementedError()
Log.w(TAG, "Not yet implemented!", notImplementedError)
NetworkResult.ApplicationError(notImplementedError)
} else {
api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
}
return@withContext VerificationCodeRequestResult.from(result)
return@withContext VerificationCodeRequestResult.from(registrationSessionResult)
}
/**
@@ -300,7 +298,17 @@ object RegistrationRepository {
suspend fun submitVerificationCode(context: Context, e164: String, password: String, sessionId: String, registrationData: RegistrationData): NetworkResult<RegistrationSessionMetadataResponse> =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
api.verifyAccount(registrationData.code, sessionId)
api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code)
}
/**
* Submits the solved captcha token to the service.
*/
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String) =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult)
}
/**

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
import org.signal.core.util.logging.Log
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
sealed class SubmitCaptchaResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
private val TAG = Log.tag(SubmitCaptchaResult::class.java)
fun from(networkResult: NetworkResult<RegistrationSessionMetadataResponse>): SubmitCaptchaResult {
return when (networkResult) {
is NetworkResult.Success -> Success()
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
is NetworkResult.NetworkError -> UnknownError(networkResult.exception)
is NetworkResult.StatusCodeError -> UnknownError(networkResult.exception)
}
}
}
class Success : SubmitCaptchaResult(null)
class ChallengeRequired(val challenges: List<String>) : SubmitCaptchaResult(null)
class UnknownError(cause: Throwable) : SubmitCaptchaResult(cause)
}

View File

@@ -41,6 +41,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu
} else {
Success(
sessionId = networkResult.result.body.id,
allowedToRequestCode = networkResult.result.body.allowedToRequestCode,
nextSms = RegistrationRepository.deriveTimestamp(networkResult.result.headers, networkResult.result.body.nextSms),
nextCall = RegistrationRepository.deriveTimestamp(networkResult.result.headers, networkResult.result.body.nextCall)
)
@@ -91,7 +92,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu
}
}
class Success(val sessionId: String, val nextSms: Long, val nextCall: Long) : VerificationCodeRequestResult(null)
class Success(val sessionId: String, val allowedToRequestCode: Boolean, val nextSms: Long, val nextCall: Long) : VerificationCodeRequestResult(null)
class ChallengeRequired(val challenges: List<String>) : VerificationCodeRequestResult(null)

View File

@@ -16,9 +16,9 @@ enum class RegistrationCheckpoint {
PUSH_NETWORK_AUDITED,
PHONE_NUMBER_CONFIRMED,
PIN_CONFIRMED,
VERIFICATION_CODE_REQUESTED,
CHALLENGE_RECEIVED,
CHALLENGE_COMPLETED,
VERIFICATION_CODE_REQUESTED,
VERIFICATION_CODE_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,

View File

@@ -25,6 +25,7 @@ data class RegistrationV2State(
val userSkippedReregistration: Boolean = false,
val isFcmSupported: Boolean = false,
val fcmToken: String? = null,
val captchaToken: String? = null,
val nextSms: Long = 0L,
val nextCall: Long = 0L,
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,

View File

@@ -29,15 +29,16 @@ import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.AttemptsExhausted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ChallengeRequired
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ExternalServiceFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ImpossibleNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MalformedRequest
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.NonNormalizedNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RateLimited
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.Success
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.TokenNotAccepted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.UnknownError
@@ -99,6 +100,15 @@ class RegistrationV2ViewModel : ViewModel() {
}
}
fun setCaptchaResponse(token: String) {
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_COMPLETED,
captchaToken = token
)
}
}
fun fetchFcmToken(context: Context) {
viewModelScope.launch(context = coroutineExceptionHandler) {
val fcmToken = RegistrationRepository.getFcmToken(context)
@@ -165,38 +175,63 @@ class RegistrationV2ViewModel : ViewModel() {
val codeRequestResponse = RegistrationRepository.requestSmsCode(context, e164, password, mccMncProducer.mcc, mccMncProducer.mnc)
when (codeRequestResponse) {
is UnknownError -> {
handleGenericError(codeRequestResponse.getCause())
return@launch
}
is Success -> {
updateFcmToken(context)
store.update {
it.copy(
sessionId = codeRequestResponse.sessionId,
nextSms = codeRequestResponse.nextSms,
nextCall = codeRequestResponse.nextCall,
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
)
}
}
is AttemptsExhausted -> Log.w(TAG, "TODO")
is ChallengeRequired -> Log.w(TAG, "TODO")
is ImpossibleNumber -> Log.w(TAG, "TODO")
is NonNormalizedNumber -> Log.w(TAG, "TODO")
is RateLimited -> Log.w(TAG, "TODO")
is ExternalServiceFailure -> Log.w(TAG, "TODO")
is InvalidTransportModeFailure -> Log.w(TAG, "TODO")
is MalformedRequest -> Log.w(TAG, "TODO")
is MustRetry -> Log.w(TAG, "TODO")
is TokenNotAccepted -> Log.w(TAG, "TODO")
}
handleSessionStateResult(context, codeRequestResponse)
}
}
fun submitCaptchaToken(context: Context) {
val e164 = getCurrentE164() ?: throw IllegalStateException("TODO")
val sessionId = store.value.sessionId ?: throw IllegalStateException("TODO")
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("TODO")
viewModelScope.launch {
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, sessionId, captchaToken)
handleSessionStateResult(context, captchaSubmissionResult)
}
}
/**
* @return whether the request was successful and execution should continue
*/
private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean {
when (sessionResult) {
is UnknownError -> {
handleGenericError(sessionResult.getCause())
return false
}
is Success -> {
updateFcmToken(context)
store.update {
it.copy(
sessionId = sessionResult.sessionId,
nextSms = sessionResult.nextSms,
nextCall = sessionResult.nextCall,
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
)
}
return true
}
is AttemptsExhausted -> Log.w(TAG, "TODO")
is ChallengeRequired -> store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED
)
}
is ImpossibleNumber -> Log.w(TAG, "TODO")
is NonNormalizedNumber -> Log.w(TAG, "TODO")
is RateLimited -> Log.w(TAG, "TODO")
is ExternalServiceFailure -> Log.w(TAG, "TODO")
is InvalidTransportModeFailure -> Log.w(TAG, "TODO")
is MalformedRequest -> Log.w(TAG, "TODO")
is MustRetry -> Log.w(TAG, "TODO")
is TokenNotAccepted -> Log.w(TAG, "TODO")
}
return false
}
private fun handleGenericError(cause: Throwable) {
Log.w(TAG, "Encountered unknown error!", cause)
store.update {

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.ui.captcha
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaV2Binding
import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2ViewModel
class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha_v2) {
private val sharedViewModel by activityViewModels<RegistrationV2ViewModel>()
private val binding: FragmentRegistrationCaptchaV2Binding by ViewBinderDelegate(FragmentRegistrationCaptchaV2Binding::bind)
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.registrationCaptchaWebView.settings.javaScriptEnabled = true
binding.registrationCaptchaWebView.clearCache(true)
binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length)
sharedViewModel.setCaptchaResponse(token)
findNavController().navigateUp()
return true
}
return false
}
}
binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL)
}
}

View File

@@ -100,10 +100,15 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
sharedState.networkError?.let {
presentNetworkError(it)
}
if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
moveToVerificationEntryScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.CHALLENGE_COMPLETED) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.CHALLENGE_RECEIVED) {
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionRequestCaptcha())
}
}
@@ -370,18 +375,18 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
private fun moveToEnterPinScreen() {
sharedViewModel.setInProgress(false)
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionReRegisterWithPinV2Fragment())
sharedViewModel.setInProgress(false)
}
private fun moveToVerificationEntryScreen() {
NavHostFragment.findNavController(this).safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEnterVerificationCode())
findNavController().safeNavigate(EnterPhoneNumberV2FragmentDirections.actionEnterVerificationCode())
sharedViewModel.setInProgress(false)
}
private fun popBackStack() {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
NavHostFragment.findNavController(this).popBackStack()
findNavController().popBackStack()
}
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationV2State>(sharedViewModel.uiState) {

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:background="@color/signal_colorSurface"
android:layout_width="match_parent"
android:layout_height="0dp">
<TextView
android:id="@+id/registration_captcha_title"
style="@style/Signal.Text.HeadlineMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:gravity="center"
android:text="@string/RegistrationActivity_we_need_to_verify_that_youre_human"
android:textColor="@color/signal_colorOnSurface"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<WebView
android:id="@+id/registration_captcha_web_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/registration_captcha_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -215,7 +215,7 @@
<fragment
android:id="@+id/captchaV2Fragment"
android:name="org.thoughtcrime.securesms.registration.fragments.CaptchaFragment"
android:name="org.thoughtcrime.securesms.registration.v2.ui.captcha.CaptchaFragment"
android:label="fragment_captcha"
tools:layout="@layout/fragment_registration_captcha" />

View File

@@ -54,12 +54,21 @@ class RegistrationApi(
/**
* Submit a verification code sent by the service via one of the supported channels (SMS, phone call) to prove the registrant's control of the phone number.
*/
fun verifyAccount(verificationCode: String, sessionId: String): NetworkResult<RegistrationSessionMetadataResponse> {
fun verifyAccount(sessionId: String, verificationCode: String): NetworkResult<RegistrationSessionMetadataResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.submitVerificationCode(sessionId, verificationCode)
}
}
/**
* Submits the solved captcha token to the service.
*/
fun submitCaptchaToken(sessionId: String, captchaToken: String): NetworkResult<RegistrationSessionMetadataResponse> {
return NetworkResult.fromFetch {
pushServiceSocket.patchVerificationSession(sessionId, null, null, null, captchaToken, null)
}
}
/**
* Submit the cryptographic assets required for an account to use the service.
*/