BIT-2276: Add support for logging in with WebAuthN two-factor (#1304)

This commit is contained in:
David Perez
2024-04-25 12:35:58 -05:00
committed by Álison Fernandes
parent a80f903df0
commit 80f6011571
17 changed files with 582 additions and 197 deletions

View File

@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -25,6 +26,7 @@ class AuthCallbackViewModel @Inject constructor(
private fun handleIntentReceived(action: AuthCallbackAction.IntentReceive) {
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
val webAuthResult = action.intent.getWebAuthResultOrNull()
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
@@ -51,6 +53,10 @@ class AuthCallbackViewModel @Inject constructor(
)
}
webAuthResult != null -> {
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
}
else -> Unit
}
}

View File

@@ -2,11 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
@@ -61,53 +58,3 @@ val GetTokenResponseJson.TwoFactorRequired?.twoFactorDisplayEmail: String
*/
private val Map<TwoFactorAuthMethod, JsonObject?>.duo: JsonObject?
get() = get(TwoFactorAuthMethod.DUO) ?: get(TwoFactorAuthMethod.DUO_ORGANIZATION)
/**
* If it exists, return the identifier for the relying party used with Web AuthN two-factor
* authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthRpId: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("rpId")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the type of user verification needed to complete the Web AuthN two-factor
* authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthUserVerification: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("userVerification")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the challenge that the authenticator need to solve to complete the
* Web AuthN two-factor authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthChallenge: String?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("challenge")
?.jsonPrimitive
?.contentOrNull
/**
* If it exists, return the credentials allowed to be used to solve the challenge to complete the
* Web AuthN two-factor authentication.
*/
val GetTokenResponseJson.TwoFactorRequired?.webAuthAllowCredentials: List<String>?
get() = this
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.get("allowCredentials")
?.jsonArray
?.mapNotNull {
it.jsonObject["id"]?.jsonPrimitive?.contentOrNull?.base64UrlDecodeOrNull()
}

View File

@@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import kotlinx.coroutines.flow.Flow
@@ -71,6 +72,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val yubiKeyResultFlow: Flow<YubiKeyResult>
/**
* Flow of the current [WebAuthResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setWebAuthResult] is called.
*/
val webAuthResultFlow: Flow<WebAuthResult>
/**
* The organization identifier currently associated with this user's SSO flow.
*/
@@ -285,6 +292,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
fun setYubiKeyResult(yubiKeyResult: YubiKeyResult)
/**
* Set the value of [webAuthResultFlow].
*/
fun setWebAuthResult(webAuthResult: WebAuthResult)
/**
* Checks for a claimed domain organization for the [email] that can be used for an SSO request.
*/

View File

@@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@@ -275,6 +276,9 @@ class AuthRepositoryImpl(
private val yubiKeyResultChannel = Channel<YubiKeyResult>(capacity = Int.MAX_VALUE)
override val yubiKeyResultFlow: Flow<YubiKeyResult> = yubiKeyResultChannel.receiveAsFlow()
private val webAuthResultChannel = Channel<WebAuthResult>(capacity = Int.MAX_VALUE)
override val webAuthResultFlow: Flow<WebAuthResult> = webAuthResultChannel.receiveAsFlow()
private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow<SsoCallbackResult>()
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
mutableSsoCallbackResultFlow.asSharedFlow()
@@ -934,6 +938,10 @@ class AuthRepositoryImpl(
yubiKeyResultChannel.trySend(yubiKeyResult)
}
override fun setWebAuthResult(webAuthResult: WebAuthResult) {
webAuthResultChannel.trySend(webAuthResult)
}
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): OrganizationDomainSsoDetailsResult = organizationService

View File

@@ -0,0 +1,78 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private const val WEB_AUTH_HOST: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
*
* - `null`: Intent is not an web auth key callback.
* - [WebAuthResult.Success]: Intent is the web auth key callback with correct data.
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
*/
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
val localData = data
return if (action == Intent.ACTION_VIEW &&
localData != null &&
localData.host == WEB_AUTH_HOST
) {
localData
.getQueryParameter("data")
?.let { WebAuthResult.Success(token = it) }
?: WebAuthResult.Failure
} else {
null
}
}
/**
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
*/
fun generateUriForWebAuth(
baseUrl: String,
data: JsonObject,
headerText: String,
buttonText: String,
returnButtonText: String,
): Uri {
val json = buildJsonObject {
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "data", value = data.toString())
put(key = "headerText", value = headerText)
put(key = "btnText", value = buttonText)
put(key = "btnReturnText", value = returnButtonText)
}
val base64Data = Base64
.getEncoder()
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = baseUrl +
"/webauthn-mobile-connector.html" +
"?data=$base64Data" +
"&parent=$parentParam" +
"&v=2"
return Uri.parse(url)
}
/**
* Sealed class representing the result of web auth callback token extraction.
*/
sealed class WebAuthResult {
/**
* Represents a token present in the web auth callback.
*/
data class Success(val token: String) : WebAuthResult()
/**
* Represents a failure in the web auth callback.
*/
data object Failure : WebAuthResult()
}

View File

@@ -108,6 +108,10 @@ fun TwoFactorLoginScreen(
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.NavigateToWebAuth -> {
intentManager.startCustomTabsActivity(uri = event.uri)
}
is TwoFactorLoginEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}

View File

@@ -18,17 +18,21 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.button
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.imageRes
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isDuo
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isContinueButtonEnabled
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.shouldUseNfc
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.showPasswordInput
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -48,6 +52,7 @@ private const val KEY_STATE = "state"
class TwoFactorLoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val resourceManager: ResourceManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<TwoFactorLoginState, TwoFactorLoginEvent, TwoFactorLoginAction>(
initialState = savedStateHandle[KEY_STATE]
@@ -57,7 +62,10 @@ class TwoFactorLoginViewModel @Inject constructor(
codeInput = "",
displayEmail = authRepository.twoFactorResponse.twoFactorDisplayEmail,
dialogState = null,
isContinueButtonEnabled = authRepository.twoFactorResponse.preferredAuthMethod.isDuo,
isContinueButtonEnabled = authRepository
.twoFactorResponse
.preferredAuthMethod
.isContinueButtonEnabled,
isRememberMeEnabled = false,
captchaToken = null,
email = TwoFactorLoginArgs(savedStateHandle).emailAddress,
@@ -100,6 +108,13 @@ class TwoFactorLoginViewModel @Inject constructor(
.map { TwoFactorLoginAction.Internal.ReceiveYubiKeyResult(yubiKeyResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
// Process the Web Authn result when it is received.
authRepository
.webAuthResultFlow
.map { TwoFactorLoginAction.Internal.ReceiveWebAuthResult(webAuthResult = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: TwoFactorLoginAction) {
@@ -130,6 +145,10 @@ class TwoFactorLoginViewModel @Inject constructor(
handleReceiveYubiKeyResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveWebAuthResult -> {
handleReceiveWebAuthResult(action)
}
is TwoFactorLoginAction.Internal.ReceiveResendEmailResult -> {
handleReceiveResendEmailResult(action)
}
@@ -175,23 +194,59 @@ class TwoFactorLoginViewModel @Inject constructor(
/**
* Navigates to the Duo webpage if appropriate, else processes the login.
*/
@Suppress("MaxLineLength")
private fun handleContinueButtonClick() {
if (state.authMethod.isDuo) {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
sendEvent(
event = authUrl
?.let {
TwoFactorLoginEvent.NavigateToDuo(
uri = Uri.parse(it),
)
}
?: TwoFactorLoginEvent.ShowToast(
message = R.string.generic_error_message.asText(),
),
)
} else {
initiateLogin()
when (state.authMethod) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
sendEvent(
event = authUrl
?.let { TwoFactorLoginEvent.NavigateToDuo(uri = Uri.parse(it)) }
?: TwoFactorLoginEvent.ShowToast(R.string.generic_error_message.asText()),
)
}
TwoFactorAuthMethod.WEB_AUTH -> {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val uri = generateUriForWebAuth(
baseUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
data = it,
headerText = resourceManager.getString(
resId = R.string.fido2_title,
),
buttonText = resourceManager.getString(
resId = R.string.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = R.string.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri)
}
?: TwoFactorLoginEvent.ShowToast(
message = R.string.there_was_an_error_starting_web_authn_two_factor_authentication.asText(),
),
)
}
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> initiateLogin()
}
}
@@ -289,6 +344,30 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Handle the web auth result.
*/
private fun handleReceiveWebAuthResult(
action: TwoFactorLoginAction.Internal.ReceiveWebAuthResult,
) {
when (val result = action.webAuthResult) {
WebAuthResult.Failure -> {
mutableStateFlow.update {
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is WebAuthResult.Success -> {
mutableStateFlow.update { it.copy(codeInput = result.token) }
initiateLogin()
}
}
}
/**
* Handle the resend email result.
*/
@@ -481,7 +560,7 @@ data class TwoFactorLoginState(
/**
* Indicates whether the code input should be displayed.
*/
val shouldShowCodeInput: Boolean get() = !authMethod.isDuo
val shouldShowCodeInput: Boolean get() = authMethod.showPasswordInput
/**
* The image to display for the given the [authMethod].
@@ -532,6 +611,11 @@ sealed class TwoFactorLoginEvent {
*/
data class NavigateToDuo(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the WebAuth authentication screen.
*/
data class NavigateToWebAuth(val uri: Uri) : TwoFactorLoginEvent()
/**
* Navigates to the recovery code help page.
*
@@ -631,5 +715,12 @@ sealed class TwoFactorLoginAction {
val resendEmailResult: ResendEmailResult,
val isUserInitiated: Boolean,
) : Internal()
/**
* Indicates a web auth result has been received.
*/
data class ReceiveWebAuthResult(
val webAuthResult: WebAuthResult,
) : Internal()
}
}

View File

@@ -65,15 +65,41 @@ val TwoFactorAuthMethod.button: Text
}
/**
* Gets a boolean indicating if the given auth method uses Duo.
* Gets a boolean indicating if the given auth method has the continue button enabled by default.
*/
val TwoFactorAuthMethod.isDuo: Boolean
val TwoFactorAuthMethod.isContinueButtonEnabled: Boolean
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.WEB_AUTH,
-> true
else -> false
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> false
}
/**
* Gets a boolean indicating if the given auth method should display the password input field.
*/
val TwoFactorAuthMethod.showPasswordInput: Boolean
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
TwoFactorAuthMethod.WEB_AUTH,
-> false
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> true
}
/**