Compare commits

...

4 Commits

Author SHA1 Message Date
André Bispo
2fd3db1804 [PM-11649] Add missing test case 2024-09-04 21:59:38 +01:00
André Bispo
087e97335d Merge branch 'main' into pm-11649/expired-link-services 2024-09-04 20:58:53 +01:00
André Bispo
5b438e4535 [PM-11649] Add tests to verify email token 2024-09-04 19:53:44 +01:00
André Bispo
15f72d8ba3 [PM-11649] Add service call to verify email to identity service and auth repository. 2024-09-04 19:53:19 +01:00
10 changed files with 284 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import kotlinx.serialization.json.JsonPrimitive
import retrofit2.Call
import retrofit2.http.Body
@@ -79,4 +80,9 @@ interface UnauthenticatedIdentityApi {
suspend fun sendVerificationEmail(
@Body body: SendVerificationEmailRequestJson,
): Result<JsonPrimitive?>
@POST("/accounts/register/verification-email-clicked")
suspend fun verifyEmailToken(
@Body body: VerifyEmailTokenRequestJson,
): Result<Unit>
}

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body to verify the email token.
*
* @param email The email being used to create the account.
* @param emailVerificationToken The token used to verify the email.
*/
@Serializable
data class VerifyEmailTokenRequestJson(
@SerialName("email")
val email: String,
@SerialName("emailVerificationToken")
val emailVerificationToken: String?,
)

View File

@@ -0,0 +1,40 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models response bodies for the verification of the email token.
*/
@Serializable
sealed class VerifyEmailTokenResponseJson {
/**
* Models a successful json response of the verify email request.
*/
@Serializable
data object Success : VerifyEmailTokenResponseJson()
/**
* Represents the json body of an invalid register request.
*
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : VerifyEmailTokenResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
/**
* Provides an API for querying identity endpoints.
@@ -72,4 +74,11 @@ interface IdentityService {
* Register a new account to Bitwarden using email verification flow.
*/
suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
/**
* Verifies that the token received by email is still valid
*/
suspend fun verifyEmailToken(
body: VerifyEmailTokenRequestJson,
): Result<VerifyEmailTokenResponseJson>
}

View File

@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResult
@@ -127,4 +129,22 @@ class IdentityServiceImpl(
.sendVerificationEmail(body = body)
.map { it?.content }
}
@Suppress("MagicNumber")
override suspend fun verifyEmailToken(
body: VerifyEmailTokenRequestJson,
): Result<VerifyEmailTokenResponseJson> {
return unauthenticatedIdentityApi
.verifyEmailToken(body = body)
.map { VerifyEmailTokenResponseJson.Success }
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError
.parseErrorBodyOrNull<VerifyEmailTokenResponseJson.Invalid>(
codes = (400..499).toList(),
json = json,
)
?: throw throwable
}
}
}

View File

@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyEmailTokenResult
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
@@ -377,4 +378,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult
/**
* Verifies token received by email.
*/
suspend fun verifyEmailToken(
email: String,
emailVerificationToken: String,
): VerifyEmailTokenResult
}

View File

@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@@ -64,6 +65,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifyEmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@@ -1256,6 +1258,32 @@ class AuthRepositoryImpl(
},
)
override suspend fun verifyEmailToken(
email: String,
emailVerificationToken: String,
): VerifyEmailTokenResult =
identityService
.verifyEmailToken(
VerifyEmailTokenRequestJson(
email = email,
emailVerificationToken = emailVerificationToken,
),
)
.fold(
onSuccess = {
VerifyEmailTokenResult.Verified
},
onFailure = {
// Server sends a message if the link is expired, this is the only way
// to check if the token is expired
if (it.message?.contains("Expired link", ignoreCase = true) == true) {
return VerifyEmailTokenResult.LinkExpired
} else {
return VerifyEmailTokenResult.Error
}
},
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of verifying the email token.
*/
sealed class VerifyEmailTokenResult {
/**
* Represents a successful verification of email token.
*/
data object Verified : VerifyEmailTokenResult()
/**
* Represents an expired email verification token.
*/
data object LinkExpired : VerifyEmailTokenResult()
/**
* There was an error verifying email token.
*/
data object Error : VerifyEmailTokenResult()
}

View File

@@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEm
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider
import com.x8bit.bitwarden.data.platform.util.asSuccess
@@ -369,6 +371,35 @@ class IdentityServiceTest : BaseServiceTest() {
assertTrue(result.isFailure)
}
@Test
fun `verifyEmailToken should return null when response is empty success`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))
val result = identityService.verifyEmailToken(VERIFY_EMAIL_REQUEST)
assertTrue(result.isSuccess)
}
@Test
fun `verifyEmailToken should return an error when response is an error`() = runTest {
server.enqueue(MockResponse().setResponseCode(400))
val result = identityService.verifyEmailToken(VERIFY_EMAIL_REQUEST)
assertTrue(result.isFailure)
}
@Test
fun `verifyEmailToken failure with expired link should return Invalid`() = runTest {
val response = MockResponse().setResponseCode(400).setBody(EXPIRED_LINK_RESPONSE_JSON)
server.enqueue(response)
val result = identityService.verifyEmailToken(VERIFY_EMAIL_REQUEST)
assertEquals(
@Suppress("MaxLineLength")
VerifyEmailTokenResponseJson.Invalid(
errorMessage = "Expired link. Please restart registration or try logging in. You may already have an account.",
validationErrors = null,
),
result.getOrThrow(),
)
}
companion object {
private const val UNIQUE_APP_ID = "testUniqueAppId"
private const val REFRESH_TOKEN = "refreshToken"
@@ -569,6 +600,14 @@ private const val CAPTCHA_BYPASS_TOKEN_RESPONSE_JSON = """
{
"captchaBypassToken": "mock_token"
}
"""
private const val EXPIRED_LINK_RESPONSE_JSON = """
{
"Object": "error",
"Message": "Expired link. Please restart registration or try logging in. You may already have an account."
}
"""
private val INVALID_LOGIN = GetTokenResponseJson.Invalid(
@@ -582,3 +621,8 @@ private val SEND_VERIFICATION_EMAIL_REQUEST = SendVerificationEmailRequestJson(
name = "Name Example",
receiveMarketingEmails = true,
)
private val VERIFY_EMAIL_REQUEST = VerifyEmailTokenRequestJson(
email = "email@example.com",
emailVerificationToken = "mock_token",
)

View File

@@ -42,6 +42,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserD
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@@ -81,6 +83,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifyEmailTokenResult
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
@@ -5999,6 +6002,90 @@ class AuthRepositoryTest {
)
}
@Test
fun `verifyEmailToken success should return success`() = runTest {
coEvery {
identityService.verifyEmailToken(
VerifyEmailTokenRequestJson(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
),
)
} returns VerifyEmailTokenResponseJson.Success.asSuccess()
val result = repository.verifyEmailToken(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
)
assertEquals(
VerifyEmailTokenResult.Verified,
result,
)
}
@Test
fun `verifyEmailToken failure with expired link message should return ExpiredLink`() = runTest {
coEvery {
identityService.verifyEmailToken(
VerifyEmailTokenRequestJson(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
),
)
} returns Throwable("Expired link").asFailure()
val result = repository.verifyEmailToken(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
)
assertEquals(
VerifyEmailTokenResult.LinkExpired,
result,
)
}
@Test
fun `verifyEmailToken generic failure should return error`() = runTest {
coEvery {
identityService.verifyEmailToken(
VerifyEmailTokenRequestJson(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
),
)
} returns Throwable("generic fail").asFailure()
val result = repository.verifyEmailToken(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
)
assertEquals(
VerifyEmailTokenResult.Error,
result,
)
}
@Test
fun `verifyEmailToken failure without message should return error`() = runTest {
coEvery {
identityService.verifyEmailToken(
VerifyEmailTokenRequestJson(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
),
)
} returns Throwable().asFailure()
val result = repository.verifyEmailToken(
email = EMAIL,
emailVerificationToken = EMAIL_VERIFICATION_TOKEN,
)
assertEquals(
VerifyEmailTokenResult.Error,
result,
)
}
companion object {
private const val UNIQUE_APP_ID = "testUniqueAppId"
private const val NAME = "Example Name"