From ea01470d214a621b592ba1db51b15a4def5482d0 Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:40:38 -0600 Subject: [PATCH] Add requestOtp and verifyOtp API methods (#1275) --- .../network/api/AuthenticatedAccountsApi.kt | 9 ++++ .../network/model/VerifyOtpRequestJson.kt | 15 ++++++ .../network/service/AccountsService.kt | 10 ++++ .../network/service/AccountsServiceImpl.kt | 11 ++++ .../data/auth/repository/AuthRepository.kt | 14 ++++++ .../auth/repository/AuthRepositoryImpl.kt | 19 +++++++ .../auth/repository/model/RequestOtpResult.kt | 17 +++++++ .../auth/repository/model/VerifyOtpResult.kt | 17 +++++++ .../network/service/AccountsServiceTest.kt | 40 +++++++++++++++ .../auth/repository/AuthRepositoryTest.kt | 50 +++++++++++++++++++ 10 files changed, 202 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyOtpRequestJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RequestOtpResult.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifyOtpResult.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt index 44c3df6a06..72d86d0c66 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysR import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson import retrofit2.http.Body import retrofit2.http.HTTP import retrofit2.http.POST @@ -24,6 +25,14 @@ interface AuthenticatedAccountsApi { @HTTP(method = "DELETE", path = "/accounts", hasBody = true) suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result + @POST("/accounts/request-otp") + suspend fun requestOtp(): Result + + @POST("/accounts/verify-otp") + suspend fun verifyOtp( + @Body body: VerifyOtpRequestJson, + ): Result + /** * Resets the temporary password. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyOtpRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyOtpRequestJson.kt new file mode 100644 index 0000000000..841ac3a81f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyOtpRequestJson.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for verifying a passcode. + * + * @param oneTimePasscode The one-time passcode to verify. + */ +@Serializable +data class VerifyOtpRequestJson( + @SerialName("OTP") + val oneTimePasscode: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index 03ab93c406..35f661cd5b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -33,6 +33,16 @@ interface AccountsService { */ suspend fun register(body: RegisterRequestJson): Result + /** + * Request a one-time passcode that is sent to the user's email. + */ + suspend fun requestOneTimePasscode(): Result + + /** + * Verify that the provided [passcode] is correct. + */ + suspend fun verifyOneTimePasscode(passcode: String): Result + /** * Request a password hint. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index bfc4d9bd17..2d118c0453 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJs import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull import kotlinx.serialization.json.Json @@ -58,6 +59,16 @@ class AccountsServiceImpl( ) ?: throw throwable } + override suspend fun requestOneTimePasscode(): Result = + authenticatedAccountsApi.requestOtp() + + override suspend fun verifyOneTimePasscode(passcode: String): Result = + authenticatedAccountsApi.verifyOtp( + VerifyOtpRequestJson( + oneTimePasscode = passcode, + ), + ) + override suspend fun requestPasswordHint( email: String, ): Result = diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 62baf8e46b..e95d5082c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -16,12 +16,14 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult 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.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 @@ -194,6 +196,18 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ fun logout() + /** + * Requests that a one-time passcode be sent to the user's email. + */ + suspend fun requestOneTimePasscode(): RequestOtpResult + + /** + * Verifies that the given one-time passcode is correct. A successful result will correspond to + * [VerifyOtpResult.Verified], while an error or failure to verify will return + * [VerifyOtpResult.NotVerified]. + */ + suspend fun verifyOneTimePasscode(oneTimePasscode: String): VerifyOtpResult + /** * Resend the email with the two-factor verification code. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 6f21324c0a..4203ce92e9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult @@ -52,6 +53,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.VaultUnlockType +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 @@ -564,6 +566,23 @@ class AuthRepositoryImpl( userLogoutManager.logout(userId = userId) } + override suspend fun requestOneTimePasscode(): RequestOtpResult = + accountsService.requestOneTimePasscode() + .fold( + onFailure = { RequestOtpResult.Error(it.message) }, + onSuccess = { RequestOtpResult.Success }, + ) + + override suspend fun verifyOneTimePasscode(oneTimePasscode: String): VerifyOtpResult = + accountsService + .verifyOneTimePasscode( + passcode = oneTimePasscode, + ) + .fold( + onFailure = { VerifyOtpResult.NotVerified(it.message) }, + onSuccess = { VerifyOtpResult.Verified }, + ) + override suspend fun resendVerificationCodeEmail(): ResendEmailResult = resendEmailRequestJson ?.let { jsonRequest -> diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RequestOtpResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RequestOtpResult.kt new file mode 100644 index 0000000000..c0643f61c7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/RequestOtpResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of requesting a one-time passcode. + */ +sealed class RequestOtpResult { + + /** + * Represents a successful send of the one-time passcode. + */ + data object Success : RequestOtpResult() + + /** + * Represents a failure to send the one-time passcode. + */ + data class Error(val message: String?) : RequestOtpResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifyOtpResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifyOtpResult.kt new file mode 100644 index 0000000000..a182637d44 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/VerifyOtpResult.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of verifying a one-time passcode. + */ +sealed class VerifyOtpResult { + + /** + * Represents a successful verification of the one-time passcode. + */ + data object Verified : VerifyOtpResult() + + /** + * Represents a failure to verify the one-time passcode. + */ + data class NotVerified(val errorMessage: String?) : VerifyOtpResult() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index ca4d8cfce7..b8284efda2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -219,6 +219,46 @@ class AccountsServiceTest : BaseServiceTest() { assertEquals(expectedResponse.asSuccess(), service.register(registerRequestBody)) } + @Test + fun `requestOtp success should return Success`() = runTest { + val response = MockResponse().setResponseCode(200) + server.enqueue(response) + + val result = service.requestOneTimePasscode() + + assertTrue(result.isSuccess) + } + + @Test + fun `requestOtp failure should return Failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + + val result = service.requestOneTimePasscode() + + assertTrue(result.isFailure) + } + + @Test + fun `verifyOtp success should return Success`() = runTest { + val response = MockResponse().setResponseCode(200) + server.enqueue(response) + + val result = service.verifyOneTimePasscode("passcode") + + assertTrue(result.isSuccess) + } + + @Test + fun `verifyOtp failure should return Failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + + val result = service.verifyOneTimePasscode("passcode") + + assertTrue(result.isFailure) + } + @Test fun `requestPasswordHint success should return Success`() = runTest { val email = "test@example.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index d685d076ec..c98f1e9b4d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -63,6 +63,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult @@ -70,6 +71,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult 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.VaultUnlockType +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 @@ -4045,6 +4047,54 @@ class AuthRepositoryTest { verify { userLogoutManager.logout(userId = userId) } } + @Test + fun `requestOneTimePasscode with success response should return Success`() = runTest { + coEvery { + accountsService.requestOneTimePasscode() + } returns Unit.asSuccess() + + val result = repository.requestOneTimePasscode() + + assertEquals(RequestOtpResult.Success, result) + } + + @Test + fun `requestOneTimePasscode with error response should return Error`() = runTest { + val errorMessage = "Error message" + coEvery { + accountsService.requestOneTimePasscode() + } returns Throwable(errorMessage).asFailure() + + val result = repository.requestOneTimePasscode() + + assertEquals(RequestOtpResult.Error(errorMessage), result) + } + + @Test + fun `verifyOneTimePasscode with success response should return Verified result`() = runTest { + val passcode = "passcode" + coEvery { + accountsService.verifyOneTimePasscode(passcode) + } returns Unit.asSuccess() + + val result = repository.verifyOneTimePasscode(passcode) + + assertEquals(VerifyOtpResult.Verified, result) + } + + @Test + fun `verifyOneTimePasscode with error response should return NotVerified result`() = runTest { + val errorMessage = "Error message" + val passcode = "passcode" + coEvery { + accountsService.verifyOneTimePasscode(passcode) + } returns Throwable(errorMessage).asFailure() + + val result = repository.verifyOneTimePasscode(passcode) + + assertEquals(VerifyOtpResult.NotVerified(errorMessage), result) + } + @Test fun `resendVerificationCodeEmail uses cached request data to make api call`() = runTest { // Attempt a normal login with a two factor error first, so that the necessary