Handle device deletion the proper way

This commit is contained in:
Benoit Marty 2019-06-17 17:32:35 +02:00
parent 9649e190ef
commit 6266f9e6a1
13 changed files with 172 additions and 139 deletions

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.failure

import im.vector.matrix.android.api.session.crypto.MXCryptoError
import im.vector.matrix.android.internal.auth.registration.RegistrationFlowResponse
import java.io.IOException

/**
@ -35,6 +36,8 @@ sealed class Failure(cause: Throwable? = null) : Throwable(cause = cause) {
// When server send an error, but it cannot be interpreted as a MatrixError
data class OtherServerError(val errorBody: String, val httpCode: Int) : Failure(RuntimeException(errorBody))

data class RegistrationFlowError(val registrationFlowResponse: RegistrationFlowResponse) : Failure(RuntimeException(registrationFlowResponse.toString()))

data class CryptoError(val error: MXCryptoError) : Failure(RuntimeException(error.toString()))

abstract class FeatureFailure : Failure()

View File

@ -36,7 +36,9 @@ interface CryptoService {

fun setDeviceName(deviceId: String, deviceName: String, callback: MatrixCallback<Unit>)

fun deleteDevice(deviceId: String, accountPassword: String, callback: MatrixCallback<Unit>)
fun deleteDevice(deviceId: String, callback: MatrixCallback<Unit>)

fun deleteDeviceWithUserPassword(deviceId: String, authSession: String?, password: String, callback: MatrixCallback<Unit>)

fun getCryptoVersion(context: Context, longFormat: Boolean): String


View File

@ -23,7 +23,7 @@ import com.squareup.moshi.JsonClass
* An interactive authentication flow.
*/
@JsonClass(generateAdapter = true)
internal data class InteractiveAuthenticationFlow(
data class InteractiveAuthenticationFlow(

@Json(name = "type")
val type: String? = null,

View File

@ -16,7 +16,7 @@

package im.vector.matrix.android.internal.auth.data

internal object LoginFlowTypes {
object LoginFlowTypes {
const val PASSWORD = "m.login.password"
const val OAUTH2 = "m.login.oauth2"
const val EMAIL_CODE = "m.login.email.code"

View File

@ -22,7 +22,7 @@ import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.internal.auth.data.InteractiveAuthenticationFlow

@JsonClass(generateAdapter = true)
internal data class RegistrationFlowResponse(
data class RegistrationFlowResponse(

/**
* The list of flows.

View File

@ -55,6 +55,7 @@ import im.vector.matrix.android.internal.crypto.model.rest.KeysUploadResponse
import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody
import im.vector.matrix.android.internal.crypto.repository.WarnOnUnknownDeviceRepository
import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore
import im.vector.matrix.android.internal.crypto.tasks.*
import im.vector.matrix.android.internal.crypto.tasks.DeleteDeviceTask
import im.vector.matrix.android.internal.crypto.tasks.GetDevicesTask
import im.vector.matrix.android.internal.crypto.tasks.SetDeviceNameTask
@ -124,6 +125,7 @@ internal class CryptoManager(
private val megolmEncryptionFactory: MXMegolmEncryptionFactory,
private val olmEncryptionFactory: MXOlmEncryptionFactory,
private val deleteDeviceTask: DeleteDeviceTask,
private val deleteDeviceWithUserPasswordTask: DeleteDeviceWithUserPasswordTask,
// Tasks
private val getDevicesTask: GetDevicesTask,
private val setDeviceNameTask: SetDeviceNameTask,
@ -163,9 +165,16 @@ internal class CryptoManager(
.executeBy(taskExecutor)
}

override fun deleteDevice(deviceId: String, accountPassword: String, callback: MatrixCallback<Unit>) {
override fun deleteDevice(deviceId: String, callback: MatrixCallback<Unit>) {
deleteDeviceTask
.configureWith(DeleteDeviceTask.Params(deviceId, accountPassword))
.configureWith(DeleteDeviceTask.Params(deviceId))
.dispatchTo(callback)
.executeBy(taskExecutor)
}

override fun deleteDeviceWithUserPassword(deviceId: String, authSession: String?, password: String, callback: MatrixCallback<Unit>) {
deleteDeviceWithUserPasswordTask
.configureWith(DeleteDeviceWithUserPasswordTask.Params(deviceId, authSession, password))
.dispatchTo(callback)
.executeBy(taskExecutor)
}

View File

@ -193,6 +193,7 @@ internal class CryptoModule {
megolmEncryptionFactory = get(),
olmEncryptionFactory = get(),
deleteDeviceTask = get(),
deleteDeviceWithUserPasswordTask = get(),
// Tasks
getDevicesTask = get(),
setDeviceNameTask = get(),
@ -227,7 +228,10 @@ internal class CryptoModule {
DefaultClaimOneTimeKeysForUsersDevice(get()) as ClaimOneTimeKeysForUsersDeviceTask
}
scope(DefaultSession.SCOPE) {
DefaultDeleteDeviceTask(get(), get()) as DeleteDeviceTask
DefaultDeleteDeviceTask(get()) as DeleteDeviceTask
}
scope(DefaultSession.SCOPE) {
DefaultDeleteDeviceWithUserPasswordTask(get(), get()) as DeleteDeviceWithUserPasswordTask
}
scope(DefaultSession.SCOPE) {
DefaultDownloadKeysForUsers(get()) as DownloadKeysForUsersTask

View File

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
* This class provides the authentication data to delete a device
*/
@JsonClass(generateAdapter = true)
data class DeleteDeviceAuth(
internal data class DeleteDeviceAuth(

// device device session id
@Json(name = "session")

View File

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
* This class provides the parameter to delete a device
*/
@JsonClass(generateAdapter = true)
data class DeleteDeviceParams(
internal data class DeleteDeviceParams(
@Json(name = "auth")
var deleteDeviceAuth: DeleteDeviceAuth? = null
)

View File

@ -19,30 +19,21 @@ package im.vector.matrix.android.internal.crypto.tasks
import arrow.core.Try
import arrow.core.failure
import arrow.core.recoverWith
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.internal.auth.registration.RegistrationFlowResponse
import im.vector.matrix.android.internal.crypto.api.CryptoApi
import im.vector.matrix.android.internal.crypto.model.rest.DeleteDeviceAuth
import im.vector.matrix.android.internal.crypto.model.rest.DeleteDeviceParams
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.CountDownLatch

internal interface DeleteDeviceTask : Task<DeleteDeviceTask.Params, Unit> {
data class Params(
val deviceId: String,
val accountPassword: String
val deviceId: String
)
}

internal class DefaultDeleteDeviceTask(private val cryptoApi: CryptoApi,
private val credentials: Credentials)
internal class DefaultDeleteDeviceTask(private val cryptoApi: CryptoApi)
: DeleteDeviceTask {

override suspend fun execute(params: DeleteDeviceTask.Params): Try<Unit> {
@ -50,8 +41,6 @@ internal class DefaultDeleteDeviceTask(private val cryptoApi: CryptoApi,
apiCall = cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams())
}.recoverWith { throwable ->
if (throwable is Failure.OtherServerError && throwable.httpCode == 401) {
// Replay the request with passing the credentials

// Parse to get a RegistrationFlowResponse
val registrationFlowResponse = try {
MoshiProvider.providesMoshi()
@ -62,26 +51,8 @@ internal class DefaultDeleteDeviceTask(private val cryptoApi: CryptoApi,
}

// check if the server response can be casted
if (registrationFlowResponse?.flows?.isNotEmpty() == true) {
val stages = ArrayList<String>()

// Get all stages
registrationFlowResponse.flows?.forEach {
stages.addAll(it.stages ?: emptyList())
}

Timber.v("## deleteDevice() : supported stages $stages")

val latch = CountDownLatch(1)
var deleteDeviceRecursiveResult: Try<Unit> = Try.just(Unit)

GlobalScope.launch {
deleteDeviceRecursiveResult = deleteDeviceRecursive(registrationFlowResponse.session, params, stages)
latch.countDown()
}

latch.await()
deleteDeviceRecursiveResult
if (registrationFlowResponse != null) {
Failure.RegistrationFlowError(registrationFlowResponse).failure()
} else {
throwable.failure()
}
@ -92,52 +63,4 @@ internal class DefaultDeleteDeviceTask(private val cryptoApi: CryptoApi,
}
}
}

private suspend fun deleteDeviceRecursive(authSession: String?,
params: DeleteDeviceTask.Params,
remainingStages: MutableList<String>): Try<Unit> {
// Pick the first stage
val stage = remainingStages.first()

val newParams = DeleteDeviceParams()
.apply {
deleteDeviceAuth = DeleteDeviceAuth()
.apply {
type = stage
session = authSession
user = credentials.userId
password = params.accountPassword
}
}

return executeRequest<Unit> {
apiCall = cryptoApi.deleteDevice(params.deviceId, newParams)
}.recoverWith { throwable ->
if (throwable is Failure.ServerError
&& throwable.httpCode == 401
&& (throwable.error.code == MatrixError.FORBIDDEN || throwable.error.code == MatrixError.UNKNOWN)) {
if (remainingStages.size > 1) {
// Try next stage
val otherStages = remainingStages.subList(1, remainingStages.size)

val latch = CountDownLatch(1)
var deleteDeviceRecursiveResult: Try<Unit> = Try.just(Unit)

GlobalScope.launch {
deleteDeviceRecursiveResult = deleteDeviceRecursive(authSession, params, otherStages)
latch.countDown()
}

latch.await()
deleteDeviceRecursiveResult
} else {
// No more stage remaining
throwable.failure()
}
} else {
// Other error
throwable.failure()
}
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.internal.crypto.tasks

import arrow.core.Try
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
import im.vector.matrix.android.internal.crypto.api.CryptoApi
import im.vector.matrix.android.internal.crypto.model.rest.DeleteDeviceAuth
import im.vector.matrix.android.internal.crypto.model.rest.DeleteDeviceParams
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task

internal interface DeleteDeviceWithUserPasswordTask : Task<DeleteDeviceWithUserPasswordTask.Params, Unit> {
data class Params(
val deviceId: String,
val authSession: String?,
val password: String
)
}

internal class DefaultDeleteDeviceWithUserPasswordTask(private val cryptoApi: CryptoApi,
private val credentials: Credentials)
: DeleteDeviceWithUserPasswordTask {

override suspend fun execute(params: DeleteDeviceWithUserPasswordTask.Params): Try<Unit> {
return executeRequest {
apiCall = cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams()
.apply {
deleteDeviceAuth = DeleteDeviceAuth()
.apply {
type = LoginFlowTypes.PASSWORD
session = params.authSession
user = credentials.userId
password = params.password
}
})
}
}
}

View File

@ -290,8 +290,12 @@ internal class DefaultSession(override val sessionParams: SessionParams) : Sessi
cryptoService.setDeviceName(deviceId, deviceName, callback)
}

override fun deleteDevice(deviceId: String, accountPassword: String, callback: MatrixCallback<Unit>) {
cryptoService.deleteDevice(deviceId, accountPassword, callback)
override fun deleteDevice(deviceId: String, callback: MatrixCallback<Unit>) {
cryptoService.deleteDevice(deviceId, callback)
}

override fun deleteDeviceWithUserPassword(deviceId: String, authSession: String?, password: String, callback: MatrixCallback<Unit>) {
cryptoService.deleteDeviceWithUserPassword(deviceId, authSession, password, callback)
}

override fun getCryptoVersion(context: Context, longFormat: Boolean): String {

View File

@ -46,7 +46,9 @@ import com.google.android.material.textfield.TextInputLayout
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.extensions.getFingerprintHumanReadable
import im.vector.matrix.android.api.extensions.sortByLastSeen
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
import im.vector.matrix.android.internal.crypto.model.rest.DeviceInfo
import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse
import im.vector.riotredesign.R
@ -2413,7 +2415,7 @@ class VectorSettingsPreferencesFragment : VectorPreferenceFragment(), SharedPref

// disable the deletion for our own device
if (!TextUtils.equals(mSession.getMyDevice()?.deviceId, aDeviceInfo.deviceId)) {
builder.setNegativeButton(R.string.delete) { _, _ -> displayDeviceDeletionDialog(aDeviceInfo) }
builder.setNegativeButton(R.string.delete) { _, _ -> deleteDevice(aDeviceInfo) }
}

builder.setNeutralButton(R.string.cancel, null)
@ -2486,11 +2488,17 @@ class VectorSettingsPreferencesFragment : VectorPreferenceFragment(), SharedPref
/**
* Try to delete a device.
*
* @param deviceId the device id
* @param deviceInfo the device to delete
*/
private fun deleteDevice(deviceId: String) {
private fun deleteDevice(deviceInfo: DeviceInfo) {
val deviceId = deviceInfo.deviceId
if (deviceId == null) {
Timber.e("## displayDeviceDeletionDialog(): sanity check failure")
return
}

displayLoadingView()
mSession.deleteDevice(deviceId, mAccountPassword, object : MatrixCallback<Unit> {
mSession.deleteDevice(deviceId, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
hideLoadingView()
// force settings update
@ -2498,59 +2506,85 @@ class VectorSettingsPreferencesFragment : VectorPreferenceFragment(), SharedPref
}

override fun onFailure(failure: Throwable) {
mAccountPassword = ""
onCommonDone(failure.localizedMessage)
var isPasswordRequestFound = false

if (failure is Failure.RegistrationFlowError) {
// We only support LoginFlowTypes.PASSWORD
// Check if we can provide the user password
failure.registrationFlowResponse.flows?.forEach { interactiveAuthenticationFlow ->
isPasswordRequestFound = isPasswordRequestFound || interactiveAuthenticationFlow.stages?.any { it == LoginFlowTypes.PASSWORD } == true
}

if (isPasswordRequestFound) {
maybeShowDeleteDeviceWithPasswordDialog(deviceId, failure.registrationFlowResponse.session)
}

}

if (!isPasswordRequestFound) {
// LoginFlowTypes.PASSWORD not supported, and this is the only one RiotX supports so far...
onCommonDone(failure.localizedMessage)
}
}
})
}

/**
* Display a delete confirmation dialog to remove a device.<br></br>
* The user is invited to enter his password to confirm the deletion.
*
* @param aDeviceInfoToDelete device info
* Show a dialog to ask for user password, or use a previously entered password.
*/
private fun displayDeviceDeletionDialog(aDeviceInfoToDelete: DeviceInfo) {
if (aDeviceInfoToDelete.deviceId != null) {
if (!TextUtils.isEmpty(mAccountPassword)) {
deleteDevice(aDeviceInfoToDelete.deviceId!!)
} else {
activity?.let {
val inflater = it.layoutInflater
val layout = inflater.inflate(R.layout.dialog_device_delete, null)
val passwordEditText = layout.findViewById<EditText>(R.id.delete_password)

AlertDialog.Builder(it)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.devices_delete_dialog_title)
.setView(layout)
.setPositiveButton(R.string.devices_delete_submit_button_label, DialogInterface.OnClickListener { _, _ ->
if (TextUtils.isEmpty(passwordEditText.toString())) {
it.toast(R.string.error_empty_field_your_password)
return@OnClickListener
}
mAccountPassword = passwordEditText.text.toString()
deleteDevice(aDeviceInfoToDelete.deviceId!!)
})
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
dialog.cancel()
hideLoadingView()
return@OnKeyListener true
}
false
})
.show()
}
}
private fun maybeShowDeleteDeviceWithPasswordDialog(deviceId: String, authSession: String?) {
if (!TextUtils.isEmpty(mAccountPassword)) {
deleteDeviceWithPassword(deviceId, authSession, mAccountPassword)
} else {
Timber.e("## displayDeviceDeletionDialog(): sanity check failure")
activity?.let {
val inflater = it.layoutInflater
val layout = inflater.inflate(R.layout.dialog_device_delete, null)
val passwordEditText = layout.findViewById<EditText>(R.id.delete_password)

AlertDialog.Builder(it)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.devices_delete_dialog_title)
.setView(layout)
.setPositiveButton(R.string.devices_delete_submit_button_label, DialogInterface.OnClickListener { _, _ ->
if (TextUtils.isEmpty(passwordEditText.toString())) {
it.toast(R.string.error_empty_field_your_password)
return@OnClickListener
}
mAccountPassword = passwordEditText.text.toString()
deleteDeviceWithPassword(deviceId, authSession, mAccountPassword)
})
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
dialog.cancel()
hideLoadingView()
return@OnKeyListener true
}
false
})
.show()
}
}
}

private fun deleteDeviceWithPassword(deviceId: String, authSession: String?, accountPassword: String) {
mSession.deleteDeviceWithUserPassword(deviceId, authSession, accountPassword, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
hideLoadingView()
// force settings update
refreshDevicesList()
}

override fun onFailure(failure: Throwable) {
// Password is maybe not good
onCommonDone(failure.localizedMessage)
mAccountPassword = ""
}
})
}

/**
* Manage the e2e keys export.
*/