BIT-1291: Initiate Login with Device flow (#791)

This commit is contained in:
Caleb Derosier 2024-01-25 19:50:09 -07:00 committed by Álison Fernandes
parent 52acc2fa47
commit 0818638273
16 changed files with 407 additions and 3 deletions

View file

@ -1,13 +1,24 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
/**
* Defines raw calls under the /auth-requests API.
*/
interface AuthRequestsApi {
/**
* Notifies the server of a new authentication request.
*/
@POST("/auth-requests")
suspend fun createAuthRequest(
@Body body: AuthRequestRequestJson,
): Result<AuthRequestsResponseJson.AuthRequest>
/**
* Gets a list of auth requests for this device.
*/

View file

@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedSe
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import dagger.Module
import dagger.Provides
@ -74,4 +76,12 @@ object AuthNetworkModule {
.build()
.create(),
)
@Provides
@Singleton
fun providesNewAuthRequestService(
retrofits: Retrofits,
): NewAuthRequestService = NewAuthRequestServiceImpl(
authRequestsApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for creating an auth request.
*/
@Serializable
data class AuthRequestRequestJson(
@SerialName("email")
val email: String,
@SerialName("publicKey")
val publicKey: String,
@SerialName("deviceIdentifier")
val deviceId: String,
@SerialName("accessCode")
val accessCode: String,
@SerialName("type")
val type: AuthRequestTypeJson,
@SerialName("fingerprintPhrase")
val fingerprint: String,
)

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the different types of auth requests.
*/
@Serializable(AuthRequestTypeSerializer::class)
enum class AuthRequestTypeJson {
@SerialName("0")
LOGIN_WITH_DEVICE,
}
@Keep
private class AuthRequestTypeSerializer :
BaseEnumeratedIntSerializer<AuthRequestTypeJson>(AuthRequestTypeJson.entries.toTypedArray())

View file

@ -41,6 +41,7 @@ data class AuthRequestsResponseJson(
@SerialName("requestIpAddress")
val ipAddress: String,
@SerialName("key")
val key: String?,

View file

@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsRespon
class AuthRequestsServiceImpl(
private val authRequestsApi: AuthRequestsApi,
) : AuthRequestsService {
override suspend fun getAuthRequests(): Result<AuthRequestsResponseJson> =
authRequestsApi.getAuthRequests()
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
/**
* Provides an API for creating a new authentication request.
*/
interface NewAuthRequestService {
/**
* Informs the server of a new auth request in order to notify approving devices.
*/
suspend fun createAuthRequest(
email: String,
publicKey: String,
deviceId: String,
accessCode: String,
fingerprint: String,
): Result<AuthRequestsResponseJson.AuthRequest>
}

View file

@ -0,0 +1,31 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
/**
* The default implementation of the [NewAuthRequestService].
*/
class NewAuthRequestServiceImpl(
private val authRequestsApi: AuthRequestsApi,
) : NewAuthRequestService {
override suspend fun createAuthRequest(
email: String,
publicKey: String,
deviceId: String,
accessCode: String,
fingerprint: String,
): Result<AuthRequestsResponseJson.AuthRequest> =
authRequestsApi.createAuthRequest(
AuthRequestRequestJson(
email = email,
publicKey = publicKey,
deviceId = deviceId,
accessCode = accessCode,
fingerprint = fingerprint,
type = AuthRequestTypeJson.LOGIN_WITH_DEVICE,
),
)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@ -157,6 +158,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setSsoCallbackResult(result: SsoCallbackResult)
/**
* Creates a new authentication request.
*/
suspend fun createAuthRequest(email: String): AuthRequestResult
/**
* Get a list of the current user's [AuthRequest]s.
*/

View file

@ -21,10 +21,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsServ
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@ -80,6 +82,7 @@ class AuthRepositoryImpl(
private val devicesService: DevicesService,
private val haveIBeenPwnedService: HaveIBeenPwnedService,
private val identityService: IdentityService,
private val newAuthRequestService: NewAuthRequestService,
private val authSdkSource: AuthSdkSource,
private val authDiskSource: AuthDiskSource,
private val environmentRepository: EnvironmentRepository,
@ -555,6 +558,40 @@ class AuthRepositoryImpl(
mutableSsoCallbackResultFlow.tryEmit(result)
}
override suspend fun createAuthRequest(
email: String,
): AuthRequestResult =
authSdkSource
.getNewAuthRequest(email)
.flatMap { authRequest ->
newAuthRequestService.createAuthRequest(
email = email,
publicKey = authRequest.publicKey,
deviceId = authDiskSource.uniqueAppId,
accessCode = authRequest.accessCode,
fingerprint = authRequest.fingerprint,
)
}
.fold(
onFailure = { AuthRequestResult.Error },
onSuccess = { request ->
AuthRequestResult.Success(
authRequest = AuthRequest(
id = request.id,
publicKey = request.publicKey,
platform = request.platform,
ipAddress = request.ipAddress,
key = request.key,
masterPasswordHash = request.masterPasswordHash,
creationDate = request.creationDate,
responseDate = request.responseDate,
requestApproved = request.requestApproved ?: false,
originUrl = request.originUrl,
),
)
},
)
override suspend fun getAuthRequests(): AuthRequestsResult =
authRequestsService.getAuthRequests()
.fold(
@ -582,7 +619,8 @@ class AuthRepositoryImpl(
override suspend fun getFingerprintPhrase(
email: String,
): UserFingerprintResult =
authSdkSource.getNewAuthRequest(email)
authSdkSource
.getNewAuthRequest(email)
.flatMap { requestResponse ->
authSdkSource
.getUserFingerprint(

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsServ
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@ -36,6 +37,7 @@ object AuthRepositoryModule {
devicesService: DevicesService,
identityService: IdentityService,
haveIBeenPwnedService: HaveIBeenPwnedService,
newAuthRequestService: NewAuthRequestService,
authSdkSource: AuthSdkSource,
authDiskSource: AuthDiskSource,
dispatchers: DispatcherManager,
@ -48,6 +50,7 @@ object AuthRepositoryModule {
authRequestsService = authRequestsService,
devicesService = devicesService,
identityService = identityService,
newAuthRequestService = newAuthRequestService,
authSdkSource = authSdkSource,
authDiskSource = authDiskSource,
haveIBeenPwnedService = haveIBeenPwnedService,

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of creating a new login approval request.
*/
sealed class AuthRequestResult {
/**
* Models the data returned when creating an auth request.
*/
data class Success(
val authRequest: AuthRequest,
) : AuthRequestResult()
/**
* There was an error getting the user's auth requests.
*/
data object Error : AuthRequestResult()
}

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -32,6 +33,8 @@ class LoginWithDeviceViewModel @Inject constructor(
),
) {
init {
sendNewAuthRequest()
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.FingerprintPhraseReceived(
@ -47,6 +50,10 @@ class LoginWithDeviceViewModel @Inject constructor(
LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked()
LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked()
is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> {
handleNewAuthRequestResultReceived(action)
}
is LoginWithDeviceAction.Internal.FingerprintPhraseReceived -> {
handleFingerprintPhraseReceived(action)
}
@ -66,6 +73,14 @@ class LoginWithDeviceViewModel @Inject constructor(
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
private fun handleNewAuthRequestResultReceived(
action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive,
) {
if (action.result is AuthRequestResult.Error) {
// TODO BIT-1563 handle error
}
}
private fun handleFingerprintPhraseReceived(
action: LoginWithDeviceAction.Internal.FingerprintPhraseReceived,
) {
@ -91,6 +106,18 @@ class LoginWithDeviceViewModel @Inject constructor(
}
}
}
private fun sendNewAuthRequest() {
viewModelScope.launch {
trySendAction(
LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(
result = authRepository.createAuthRequest(
email = state.emailAddress,
),
),
)
}
}
}
/**
@ -176,6 +203,13 @@ sealed class LoginWithDeviceAction {
* Models actions for internal use by the view model.
*/
sealed class Internal : LoginWithDeviceAction() {
/**
* A new auth request result was received.
*/
data class NewAuthRequestResultReceive(
val result: AuthRequestResult,
) : Internal()
/**
* A fingerprint phrase for this user has been received.
*/

View file

@ -0,0 +1,73 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
import java.time.ZonedDateTime
class NewAuthRequestServiceTest : BaseServiceTest() {
private val authRequestsApi: AuthRequestsApi = retrofit.create()
private val service = NewAuthRequestServiceImpl(
authRequestsApi = authRequestsApi,
)
@Test
fun `createAuthRequest when request response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.createAuthRequest(
email = "test@gmail.com",
publicKey = "1234",
deviceId = "4321",
accessCode = "accessCode",
fingerprint = "fingerprint",
)
assertTrue(actual.isFailure)
}
@Test
fun `createAuthRequest when request response is Success should return Success`() = runTest {
val json = """
{
"id": "1",
"publicKey": "2",
"requestDeviceType": "Android",
"requestIpAddress": "1.0.0.1",
"key": "key",
"masterPasswordHash": "verySecureHash",
"creationDate": "2024-09-13T01:00:00.00Z",
"requestApproved": true,
"origin": "www.bitwarden.com"
}
"""
val expected = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = "2",
platform = "Android",
ipAddress = "1.0.0.1",
key = "key",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
)
val response = MockResponse().setBody(json).setResponseCode(200)
server.enqueue(response)
val actual = service.createAuthRequest(
email = "test@gmail.com",
publicKey = "1234",
deviceId = "4321",
accessCode = "accessCode",
fingerprint = "fingerprint",
)
assertEquals(Result.success(expected), actual)
}
}

View file

@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsServ
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
@ -36,6 +37,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestsResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
@ -98,6 +100,7 @@ class AuthRepositoryTest {
private val devicesService: DevicesService = mockk()
private val identityService: IdentityService = mockk()
private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk()
private val newAuthRequestService: NewAuthRequestService = mockk()
private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE)
private val vaultRepository: VaultRepository = mockk {
every { vaultStateFlow } returns mutableVaultStateFlow
@ -114,6 +117,11 @@ class AuthRepositoryTest {
every { setDefaultsIfNecessary(any()) } just runs
}
private val authSdkSource = mockk<AuthSdkSource> {
coEvery {
getNewAuthRequest(
email = EMAIL,
)
} returns Result.success(AUTH_REQUEST_RESPONSE)
coEvery {
hashPassword(
email = EMAIL,
@ -151,6 +159,7 @@ class AuthRepositoryTest {
devicesService = devicesService,
identityService = identityService,
haveIBeenPwnedService = haveIBeenPwnedService,
newAuthRequestService = newAuthRequestService,
authSdkSource = authSdkSource,
authDiskSource = fakeAuthDiskSource,
environmentRepository = fakeEnvironmentRepository,
@ -1605,6 +1614,93 @@ class AuthRepositoryTest {
)
}
@Test
fun `createAuthRequest should return failure when service returns failure`() = runTest {
val accessCode = "accessCode"
val fingerprint = "fingerprint"
coEvery {
newAuthRequestService.createAuthRequest(
email = EMAIL,
publicKey = PUBLIC_KEY,
deviceId = UNIQUE_APP_ID,
accessCode = accessCode,
fingerprint = fingerprint,
)
} returns Throwable("Fail").asFailure()
val result = repository.createAuthRequest(
email = EMAIL,
)
coVerify(exactly = 1) {
newAuthRequestService.createAuthRequest(
email = EMAIL,
publicKey = PUBLIC_KEY,
deviceId = UNIQUE_APP_ID,
accessCode = accessCode,
fingerprint = fingerprint,
)
}
assertEquals(AuthRequestResult.Error, result)
}
@Test
fun `createAuthRequest should return success when service returns success`() = runTest {
val accessCode = "accessCode"
val fingerprint = "fingerprint"
val responseJson = AuthRequestsResponseJson.AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
)
val expected = AuthRequestResult.Success(
authRequest = AuthRequest(
id = "1",
publicKey = PUBLIC_KEY,
platform = "Android",
ipAddress = "192.168.0.1",
key = "public",
masterPasswordHash = "verySecureHash",
creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
responseDate = null,
requestApproved = true,
originUrl = "www.bitwarden.com",
),
)
coEvery {
newAuthRequestService.createAuthRequest(
email = EMAIL,
publicKey = PUBLIC_KEY,
deviceId = UNIQUE_APP_ID,
accessCode = accessCode,
fingerprint = fingerprint,
)
} returns responseJson.asSuccess()
val result = repository.createAuthRequest(
email = EMAIL,
)
coVerify(exactly = 1) {
newAuthRequestService.createAuthRequest(
email = EMAIL,
publicKey = PUBLIC_KEY,
deviceId = UNIQUE_APP_ID,
accessCode = accessCode,
fingerprint = fingerprint,
)
}
assertEquals(expected, result)
}
@Test
fun `getAuthRequests should return failure when service returns failure`() = runTest {
coEvery {
@ -1869,6 +1965,12 @@ class AuthRepositoryTest {
private val PRE_LOGIN_SUCCESS = PreLoginResponseJson(
kdfParams = PreLoginResponseJson.KdfParams.Pbkdf2(iterations = 1u),
)
private val AUTH_REQUEST_RESPONSE = AuthRequestResponse(
privateKey = PRIVATE_KEY,
publicKey = PUBLIC_KEY,
accessCode = "accessCode",
fingerprint = "fingerprint",
)
private val REFRESH_TOKEN_RESPONSE_JSON = RefreshTokenResponseJson(
accessToken = ACCESS_TOKEN_2,
expiresIn = 3600,

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -20,6 +21,9 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
coEvery {
getFingerprintPhrase(EMAIL)
} returns UserFingerprintResult.Success("initialFingerprint")
coEvery {
createAuthRequest(EMAIL)
} returns mockk<AuthRequestResult.Success>()
}
@Test
@ -28,12 +32,17 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
coVerify { authRepository.createAuthRequest(EMAIL) }
coVerify { authRepository.getFingerprintPhrase(EMAIL) }
}
@Test
fun `initial state should be correct when set`() = runTest {
val newEmail = "newEmail@gmail.com"
coEvery {
authRepository.createAuthRequest(newEmail)
} returns mockk<AuthRequestResult.Success>()
coEvery {
authRepository.getFingerprintPhrase(newEmail)
} returns UserFingerprintResult.Success("initialFingerprint")
@ -47,7 +56,10 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(state, awaitItem())
}
coVerify { authRepository.getFingerprintPhrase(newEmail) }
coVerify {
authRepository.createAuthRequest(newEmail)
authRepository.getFingerprintPhrase(newEmail)
}
}
@Test