Add setup for WebAuthn (#1294)

This commit is contained in:
David Perez 2024-04-22 11:18:11 -05:00 committed by Álison Fernandes
parent 77a7cb0e51
commit ae38b5d7ed
10 changed files with 263 additions and 17 deletions

View file

@ -124,6 +124,9 @@ sealed class GetTokenResponseJson {
@SerialName("TwoFactorProviders2")
val authMethodsData: Map<TwoFactorAuthMethod, JsonObject?>,
@SerialName("TwoFactorProviders")
val twoFactorProviders: List<String>?,
@SerialName("CaptchaBypassToken")
val captchaToken: String?,

View file

@ -2,8 +2,11 @@ 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
/**
@ -58,3 +61,53 @@ 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

@ -19,6 +19,7 @@ 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.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
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.shouldUseNfc
@ -456,12 +457,7 @@ data class TwoFactorLoginState(
/**
* The text to display for the button given the [authMethod].
*/
val buttonText: Text
get() = if (authMethod.isDuo) {
R.string.launch_duo.asText()
} else {
R.string.continue_text.asText()
}
val buttonText: Text get() = authMethod.button
/**
* Indicates if the screen should be listening for NFC events from the operating system.

View file

@ -20,6 +20,7 @@ val TwoFactorAuthMethod.title: Text
TwoFactorAuthMethod.EMAIL -> R.string.email.asText()
TwoFactorAuthMethod.RECOVERY_CODE -> R.string.recovery_code_title.asText()
TwoFactorAuthMethod.WEB_AUTH -> R.string.fido2_authenticate_web_authn.asText()
TwoFactorAuthMethod.YUBI_KEY -> R.string.yubi_key_title.asText()
else -> "".asText()
}
@ -38,10 +39,31 @@ fun TwoFactorAuthMethod.description(email: String): Text = when (this) {
}
TwoFactorAuthMethod.EMAIL -> R.string.enter_verification_code_email.asText(email)
TwoFactorAuthMethod.WEB_AUTH -> R.string.continue_to_complete_web_authn_verfication.asText()
TwoFactorAuthMethod.YUBI_KEY -> R.string.yubi_key_instruction.asText()
else -> "".asText()
}
/**
* Get the button label for the given auth method.
*/
val TwoFactorAuthMethod.button: Text
get() = when (this) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> R.string.launch_duo.asText()
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
TwoFactorAuthMethod.U2F,
TwoFactorAuthMethod.REMEMBER,
TwoFactorAuthMethod.RECOVERY_CODE,
-> R.string.continue_text.asText()
TwoFactorAuthMethod.WEB_AUTH -> R.string.launch_web_authn.asText()
}
/**
* Gets a boolean indicating if the given auth method uses Duo.
*/

View file

@ -15,4 +15,6 @@
<string name="password_protected" translatable="false">Password Protected</string>
<string name="password_used_to_export" translatable="false">This password will be used to export and import this file</string>
<string name="autofill_suggestion" translatable="false">Autofill suggestion</string>
<string name="continue_to_complete_web_authn_verfication" translatable="false">Continue to complete WebAuthn verification.</string>
<string name="launch_web_authn" translatable="false">Launch WebAuthn</string>
</resources>

View file

@ -385,7 +385,8 @@ private const val TWO_FACTOR_BODY_JSON = """
{
"TwoFactorProviders2": {"1": {"Email": "ex***@email.com"}, "0": {"Email": null}},
"SsoEmail2faSessionToken": "exampleToken",
"CaptchaBypassToken": "BWCaptchaBypass_ABCXYZ"
"CaptchaBypassToken": "BWCaptchaBypass_ABCXYZ",
"TwoFactorProviders": ["1", "3", "0"]
}
"""
private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired(
@ -395,6 +396,7 @@ private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired(
),
ssoToken = "exampleToken",
captchaToken = "BWCaptchaBypass_ABCXYZ",
twoFactorProviders = listOf("1", "3", "0"),
)
private const val LOGIN_SUCCESS_JSON = """

View file

@ -2,10 +2,12 @@ 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 kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@ -21,6 +23,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(
listOf(
@ -43,6 +46,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals("Bitwarden", subject.twoFactorDuoAuthUrl)
}
@ -58,6 +62,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals("Bitwarden", subject.twoFactorDuoAuthUrl)
}
@ -70,6 +75,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertNull(subject.twoFactorDuoAuthUrl)
}
@ -85,6 +91,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals("ex***@email.com", subject.twoFactorDisplayEmail)
}
@ -97,6 +104,7 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals("", subject.twoFactorDisplayEmail)
}
@ -112,7 +120,115 @@ class TwoFactorRequiredExtensionTest {
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(TwoFactorAuthMethod.AUTHENTICATOR_APP, subject.preferredAuthMethod)
}
@Test
fun `twoFactorDuoAuthUrl returns the expected value for DUO`() {
val authUrl = "vault.bitwarden.com"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.DUO to JsonObject(
mapOf("AuthUrl" to JsonPrimitive(authUrl)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(authUrl, subject.twoFactorDuoAuthUrl)
}
@Test
fun `twoFactorDuoAuthUrl returns the expected value for DUO_ORGANIZATION`() {
val authUrl = "vault.bitwarden.com"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.DUO_ORGANIZATION to JsonObject(
mapOf("AuthUrl" to JsonPrimitive(authUrl)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(authUrl, subject.twoFactorDuoAuthUrl)
}
@Test
fun `webAuthRpId returns the expected value`() {
val rpId = "vault.bitwarden.com"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("rpId" to JsonPrimitive(rpId)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(rpId, subject.webAuthRpId)
}
@Test
fun `webAuthUserVerification returns the expected value`() {
val userVerification = "discouraged"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("userVerification" to JsonPrimitive(userVerification)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(userVerification, subject.webAuthUserVerification)
}
@Test
fun `webAuthChallenge returns the expected value`() {
val challenge = "987t34478t9rxq7t8n"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf("challenge" to JsonPrimitive(challenge)),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertEquals(challenge, subject.webAuthChallenge)
}
@Test
fun `webAuthAllowCredentials returns the expected value`() {
val credential = "98426435782"
val subject = GetTokenResponseJson.TwoFactorRequired(
authMethodsData = mapOf(
TwoFactorAuthMethod.WEB_AUTH to JsonObject(
mapOf(
"allowCredentials" to JsonArray(
listOf(
JsonObject(
mapOf(
"type" to JsonPrimitive("public-key"),
"id" to JsonPrimitive(credential),
),
),
),
),
),
),
),
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
assertNotNull(subject.webAuthAllowCredentials)
}
}

View file

@ -1632,13 +1632,19 @@ class AuthRepositoryTest {
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
repository.twoFactorResponse,
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
GetTokenResponseJson.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
twoFactorProviders = null,
captchaToken = null,
ssoToken = null,
),
)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { identityService.preLogin(email = EMAIL) }
@ -1675,6 +1681,7 @@ class AuthRepositoryTest {
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
val firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
@ -2068,7 +2075,12 @@ class AuthRepositoryTest {
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
twoFactorProviders = null,
captchaToken = null,
ssoToken = null,
)
.asSuccess()
val result = repository.login(
email = EMAIL,
@ -2082,7 +2094,12 @@ class AuthRepositoryTest {
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
repository.twoFactorResponse,
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
GetTokenResponseJson.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
twoFactorProviders = null,
captchaToken = null,
ssoToken = null,
),
)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
@ -2115,7 +2132,12 @@ class AuthRepositoryTest {
uniqueAppId = UNIQUE_APP_ID,
)
} returns GetTokenResponseJson
.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null)
.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
twoFactorProviders = null,
captchaToken = null,
ssoToken = null,
)
.asSuccess()
val firstResult = repository.login(
email = EMAIL,
@ -2721,6 +2743,7 @@ class AuthRepositoryTest {
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
val result = repository.login(
@ -2734,7 +2757,12 @@ class AuthRepositoryTest {
assertEquals(LoginResult.TwoFactorRequired, result)
assertEquals(
repository.twoFactorResponse,
GetTokenResponseJson.TwoFactorRequired(TWO_FACTOR_AUTH_METHODS_DATA, null, null),
GetTokenResponseJson.TwoFactorRequired(
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
),
)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify {
@ -2771,6 +2799,7 @@ class AuthRepositoryTest {
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
@ -4124,6 +4153,7 @@ class AuthRepositoryTest {
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
.asSuccess()
val firstResult = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)

View file

@ -257,6 +257,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
authMethodsData = authMethodsData,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
every { authRepository.twoFactorResponse } returns response
val mockkUri = mockk<Uri>()
@ -291,6 +292,7 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
authMethodsData = authMethodsData,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
every { authRepository.twoFactorResponse } returns response
val viewModel = createViewModel(
@ -625,9 +627,10 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() {
)
private val TWO_FACTOR_RESPONSE =
GetTokenResponseJson.TwoFactorRequired(
TWO_FACTOR_AUTH_METHODS_DATA,
null,
null,
authMethodsData = TWO_FACTOR_AUTH_METHODS_DATA,
captchaToken = null,
ssoToken = null,
twoFactorProviders = null,
)
private val DEFAULT_STATE = TwoFactorLoginState(

View file

@ -20,7 +20,7 @@ class TwoFactorAuthMethodExtensionTest {
TwoFactorAuthMethod.DUO_ORGANIZATION to R.string.duo_org_title.asText(
R.string.organization.asText(),
),
TwoFactorAuthMethod.WEB_AUTH to "".asText(),
TwoFactorAuthMethod.WEB_AUTH to R.string.fido2_authenticate_web_authn.asText(),
TwoFactorAuthMethod.RECOVERY_CODE to R.string.recovery_code_title.asText(),
)
.forEach { (type, title) ->
@ -48,7 +48,8 @@ class TwoFactorAuthMethodExtensionTest {
.asText()
.concat(" ".asText())
.concat(R.string.follow_the_steps_from_duo_to_finish_logging_in.asText()),
TwoFactorAuthMethod.WEB_AUTH to "".asText(),
TwoFactorAuthMethod.WEB_AUTH to
R.string.continue_to_complete_web_authn_verfication.asText(),
TwoFactorAuthMethod.RECOVERY_CODE to "".asText(),
)
.forEach { (type, title) ->
@ -59,6 +60,24 @@ class TwoFactorAuthMethodExtensionTest {
}
}
@Test
fun `button returns the expected value`() {
mapOf(
TwoFactorAuthMethod.AUTHENTICATOR_APP to R.string.continue_text.asText(),
TwoFactorAuthMethod.EMAIL to R.string.continue_text.asText(),
TwoFactorAuthMethod.DUO to R.string.launch_duo.asText(),
TwoFactorAuthMethod.YUBI_KEY to R.string.continue_text.asText(),
TwoFactorAuthMethod.U2F to R.string.continue_text.asText(),
TwoFactorAuthMethod.REMEMBER to R.string.continue_text.asText(),
TwoFactorAuthMethod.DUO_ORGANIZATION to R.string.launch_duo.asText(),
TwoFactorAuthMethod.WEB_AUTH to R.string.launch_web_authn.asText(),
TwoFactorAuthMethod.RECOVERY_CODE to R.string.continue_text.asText(),
)
.forEach { (type, buttonLabel) ->
assertEquals(buttonLabel, type.button)
}
}
@Test
fun `isDuo returns the expected value`() {
mapOf(