BIT-398: Launch ACTION_VIEW Intent with captcha URL and handle callback (#88)

This commit is contained in:
Ramsey Smith
2023-10-03 13:34:51 -06:00
committed by Álison Fernandes
parent 9d9ee38070
commit c6ce992342
16 changed files with 453 additions and 36 deletions

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -13,6 +15,9 @@ import dagger.hilt.android.AndroidEntryPoint
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val mainViewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
@@ -25,4 +30,13 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
mainViewModel.sendAction(
action = MainAction.ReceiveNewIntent(
intent = intent,
),
)
}
}

View File

@@ -0,0 +1,48 @@
package com.x8bit.bitwarden
import android.content.Intent
import androidx.lifecycle.ViewModel
import com.x8bit.bitwarden.data.auth.datasource.network.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* A view model that helps launch actions for the [MainActivity].
*/
@HiltViewModel
class MainViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
/**
* Send a [MainAction].
*/
fun sendAction(action: MainAction) {
when (action) {
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(intent = action.intent)
}
}
private fun handleNewIntentReceived(intent: Intent) {
val captchaCallbackTokenResult = intent.getCaptchaCallbackTokenResult()
when {
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = captchaCallbackTokenResult,
)
}
else -> Unit
}
}
}
/**
* Models actions for the [MainActivity].
*/
sealed class MainAction {
/**
* Receive Intent by the application.
*/
data class ReceiveNewIntent(val intent: Intent) : MainAction()
}

View File

@@ -26,5 +26,6 @@ interface IdentityApi {
@Field(value = "deviceName") deviceName: String,
@Field(value = "deviceType") deviceType: String,
@Field(value = "grant_type") grantType: String,
@Field(value = "captchaResponse") captchaResponse: String?,
): Result<GetTokenResponseJson.Success>
}

View File

@@ -12,9 +12,11 @@ interface IdentityService {
*
* @param email user's email address.
* @param passwordHash password hashed with the Bitwarden SDK.
* @param captchaToken captcha token to be passed to the API (nullable).
*/
suspend fun getToken(
email: String,
passwordHash: String,
captchaToken: String?,
): Result<GetTokenResponseJson>
}

View File

@@ -18,6 +18,7 @@ class IdentityServiceImpl constructor(
override suspend fun getToken(
email: String,
passwordHash: String,
captchaToken: String?,
): Result<GetTokenResponseJson> = api
.getToken(
// TODO: use correct base URL here BIT-328
@@ -33,6 +34,7 @@ class IdentityServiceImpl constructor(
grantType = "password",
passwordHash = passwordHash,
email = email,
captchaResponse = captchaToken,
)
.fold(
onSuccess = { Result.success(it) },

View File

@@ -5,9 +5,13 @@ import android.net.Uri
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
import java.util.Locale
private const val CAPTCHA_HOST: String = "captcha-callback"
private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST"
/**
* Generates an [Intent] to display a CAPTCHA challenge for Bitwarden authentication.
*/
@@ -15,7 +19,7 @@ fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent {
val json = buildJsonObject {
put(key = "siteKey", value = captchaId)
put(key = "locale", value = Locale.getDefault().toString())
put(key = "callbackUri", value = "bitwarden://captcha-callback")
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "captchaRequiredText", value = "Captcha required")
}
val base64Data = Base64
@@ -25,8 +29,47 @@ fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent {
.toString()
.toByteArray(Charsets.UTF_8),
)
val parentParam = "bitwarden%3A%2F%2Fcaptcha-callback"
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=$base64Data&parent=$parentParam&v=1"
return Intent(Intent.ACTION_VIEW, Uri.parse(url))
}
/**
* Retrieves a [CaptchaCallbackTokenResult] from an Intent. There are three possible cases.
*
* - [null]: Intent is not a captcha callback, or data is null.
*
* - [CaptchaCallbackTokenResult.MissingToken]:
* Intent is the captcha callback, but its missing a token value.
*
* - [CaptchaCallbackTokenResult.Success]:
* Intent is the captcha callback, and it has a token.
*/
fun Intent.getCaptchaCallbackTokenResult(): CaptchaCallbackTokenResult? {
val localData = data
return if (
action == Intent.ACTION_VIEW && localData != null && localData.host == CAPTCHA_HOST
) {
localData.getQueryParameter("token")?.let {
CaptchaCallbackTokenResult.Success(token = it)
} ?: CaptchaCallbackTokenResult.MissingToken
} else {
null
}
}
/**
* Sealed class representing the result of captcha callback token extraction.
*/
sealed class CaptchaCallbackTokenResult {
/**
* Represents a missing token in the captcha callback.
*/
data object MissingToken : CaptchaCallbackTokenResult()
/**
* Represents a token present in the captcha callback.
*/
data class Success(val token: String) : CaptchaCallbackTokenResult()
}

View File

@@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
@@ -13,6 +15,12 @@ interface AuthRepository {
*/
val authStateFlow: StateFlow<AuthState>
/**
* Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow
* in order to receive updates whenever [setCaptchaCallbackTokenResult] is called.
*/
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
@@ -20,5 +28,11 @@ interface AuthRepository {
suspend fun login(
email: String,
password: String,
captchaToken: String?,
): LoginResult
/**
* Set the value of [captchaTokenResultFlow].
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
}

View File

@@ -8,10 +8,14 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.util.flatMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,12 +34,15 @@ class AuthRepositoryImpl @Inject constructor(
private val mutableAuthStateFlow = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
override val authStateFlow: StateFlow<AuthState> = mutableAuthStateFlow.asStateFlow()
/**
* Attempt to login with the given email.
*/
private val mutableCaptchaTokenFlow =
MutableSharedFlow<CaptchaCallbackTokenResult>(extraBufferCapacity = Int.MAX_VALUE)
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
mutableCaptchaTokenFlow.asSharedFlow()
override suspend fun login(
email: String,
password: String,
captchaToken: String?,
): LoginResult = accountsService
.preLogin(email = email)
.flatMap {
@@ -50,6 +57,7 @@ class AuthRepositoryImpl @Inject constructor(
identityService.getToken(
email = email,
passwordHash = passwordHash,
captchaToken = captchaToken,
)
}
.fold(
@@ -70,4 +78,8 @@ class AuthRepositoryImpl @Inject constructor(
}
},
)
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.login
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,10 +37,15 @@ fun LoginScreen(
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginEvent.NavigateToLanding -> onNavigateToLanding()
is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent)
is LoginEvent.ShowErrorDialog -> {
// TODO Show proper error Dialog
Toast.makeText(context, event.messageRes, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -2,9 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent
import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@@ -39,6 +42,15 @@ class LoginViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository.captchaTokenResultFlow
.onEach {
sendAction(
LoginAction.Internal.ReceiveCaptchaToken(
tokenResult = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: LoginAction) {
@@ -47,15 +59,33 @@ class LoginViewModel @Inject constructor(
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action)
is LoginAction.Internal.ReceiveCaptchaToken -> {
handleCaptchaTokenReceived(action.tokenResult)
}
}
}
private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) {
when (tokenResult) {
CaptchaCallbackTokenResult.MissingToken -> {
sendEvent(LoginEvent.ShowErrorDialog(messageRes = R.string.captcha_failed))
}
is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token)
}
}
private fun handleLoginButtonClicked() {
attemptLogin(captchaToken = null)
}
private fun attemptLogin(captchaToken: String?) {
viewModelScope.launch {
// TODO: show progress here BIT-320
val result = authRepository.login(
email = mutableStateFlow.value.emailAddress,
password = mutableStateFlow.value.passwordInput,
captchaToken = captchaToken,
)
when (result) {
// TODO: show an error here BIT-320
@@ -109,6 +139,11 @@ sealed class LoginEvent {
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val intent: Intent) : LoginEvent()
/**
* Shows an error pop up with a given message
*/
data class ShowErrorDialog(@StringRes val messageRes: Int) : LoginEvent()
}
/**
@@ -134,4 +169,16 @@ sealed class LoginAction {
* Indicates that the password input has changed.
*/
data class PasswordInputChanged(val input: String) : LoginAction()
/**
* Models actions that the [LoginViewModel] itself might send.
*/
sealed class Internal : LoginAction() {
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
}
}