Identity: validate code received by SMS

This commit is contained in:
Benoit Marty 2020-05-09 18:04:11 +02:00
parent e962d1dadf
commit e411f139c8
17 changed files with 177 additions and 45 deletions

View File

@ -158,6 +158,9 @@ dependencies {
// Bus
implementation 'org.greenrobot:eventbus:3.1.1'
// Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23'
debugImplementation 'com.airbnb.okreplay:okreplay:1.5.0'
releaseImplementation 'com.airbnb.okreplay:noop:1.5.0'
androidTestImplementation 'com.airbnb.okreplay:espresso:1.5.0'

View File

@ -23,9 +23,9 @@ import im.vector.matrix.android.api.util.Cancelable
* Provides access to the identity server configuration and services identity server can provide
*/
interface IdentityService {
/**
* Return the default identity server of the homeserver (using Wellknown request)
* Return the default identity server of the homeserver (using Wellknown request).
* It may be different from the current configured identity server
*/
fun getDefaultIdentityServer(callback: MatrixCallback<String?>): Cancelable
@ -51,9 +51,11 @@ interface IdentityService {
fun finalizeBindThreePid(threePid: ThreePid, callback: MatrixCallback<Unit>): Cancelable
/**
* @param code the code sent to the user phone number
* Submit the code that the identity server has sent to the user (in email or SMS)
* Once successful, you will have to call [finalizeBindThreePid]
* @param code the code sent to the user
*/
fun submitValidationToken(pid: ThreePid, code: String, callback: MatrixCallback<Unit>): Cancelable
fun submitValidationToken(threePid: ThreePid, code: String, callback: MatrixCallback<Unit>): Cancelable
/**
* The request will actually be done on the homeserver

View File

@ -16,11 +16,13 @@
package im.vector.matrix.android.api.session.identity
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import im.vector.matrix.android.internal.session.profile.ThirdPartyIdentifier
sealed class ThreePid(open val value: String) {
data class Email(val email: String) : ThreePid(email)
data class Msisdn(val msisdn: String, val countryCode: String? = null) : ThreePid(msisdn)
data class Msisdn(val msisdn: String) : ThreePid(msisdn)
}
internal fun ThreePid.toMedium(): String {
@ -29,3 +31,10 @@ internal fun ThreePid.toMedium(): String {
is ThreePid.Msisdn -> ThirdPartyIdentifier.MEDIUM_MSISDN
}
}
@Throws(NumberParseException::class)
internal fun ThreePid.Msisdn.getCountryCode(): String {
return with(PhoneNumberUtil.getInstance()) {
getRegionCodeForCountryCode(parse("+$msisdn", null).countryCode)
}
}

View File

@ -86,7 +86,7 @@ internal class DefaultBulkLookupTask @Inject constructor(
executeRequest(null) {
apiCall = identityAPI.lookup(IdentityLookUpParams(
hashedAddresses,
"sha256",
IdentityHashDetailResponse.ALGORITHM_SHA256,
hashDetailResponse.pepper
))
}
@ -103,7 +103,7 @@ internal class DefaultBulkLookupTask @Inject constructor(
// Retrieve the new hash details
val newHashDetailResponse = fetchAndStoreHashDetails(identityAPI)
if (hashDetailResponse.algorithms.contains("sha256").not()) {
if (hashDetailResponse.algorithms.contains(IdentityHashDetailResponse.ALGORITHM_SHA256).not()) {
// TODO We should ask the user if he is ok to send their 3Pid in clear, but for the moment we do not do it
// Also, what we have in cache is maybe outdated, the identity server maybe now support sha256
throw IdentityServiceError.BulkLookupSha256NotSupported

View File

@ -71,6 +71,7 @@ internal class DefaultIdentityService @Inject constructor(
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val updateUserAccountDataTask: UpdateUserAccountDataTask,
private val bindThreePidsTask: BindThreePidsTask,
private val submitTokenForBindingTask: IdentitySubmitTokenForBindingTask,
private val unbindThreePidsTask: UnbindThreePidsTask,
private val identityApiProvider: IdentityApiProvider,
private val accountDataDataSource: AccountDataDataSource
@ -132,8 +133,10 @@ internal class DefaultIdentityService @Inject constructor(
}
}
override fun submitValidationToken(pid: ThreePid, code: String, callback: MatrixCallback<Unit>): Cancelable {
TODO("Not yet implemented")
override fun submitValidationToken(threePid: ThreePid, code: String, callback: MatrixCallback<Unit>): Cancelable {
return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) {
submitTokenForBindingTask.execute(IdentitySubmitTokenForBindingTask.Params(threePid, code))
}
}
override fun unbindThreePid(threePid: ThreePid, callback: MatrixCallback<Unit>): Cancelable {

View File

@ -16,11 +16,13 @@
package im.vector.matrix.android.internal.session.identity
import im.vector.matrix.android.internal.auth.registration.SuccessResult
import im.vector.matrix.android.internal.network.NetworkConstants
import im.vector.matrix.android.internal.session.identity.model.IdentityAccountResponse
import im.vector.matrix.android.internal.session.identity.model.IdentityHashDetailResponse
import im.vector.matrix.android.internal.session.identity.model.IdentityLookUpParams
import im.vector.matrix.android.internal.session.identity.model.IdentityLookUpResponse
import im.vector.matrix.android.internal.session.identity.model.IdentityRequestOwnershipParams
import im.vector.matrix.android.internal.session.identity.model.IdentityRequestTokenForEmailBody
import im.vector.matrix.android.internal.session.identity.model.IdentityRequestTokenForMsisdnBody
import im.vector.matrix.android.internal.session.identity.model.IdentityRequestTokenResponse
@ -28,6 +30,7 @@ import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Ref: https://matrix.org/docs/spec/identity_service/latest
@ -83,4 +86,13 @@ internal interface IdentityAPI {
*/
@POST(NetworkConstants.URI_IDENTITY_PATH_V2 + "validate/msisdn/requestToken")
fun requestTokenToBindMsisdn(@Body body: IdentityRequestTokenForMsisdnBody): Call<IdentityRequestTokenResponse>
/**
* Validate ownership of an email address, or a phone number.
* Ref:
* - https://matrix.org/docs/spec/identity_service/latest#post-matrix-identity-v2-validate-msisdn-submittoken
* - https://matrix.org/docs/spec/identity_service/latest#post-matrix-identity-v2-validate-email-submittoken
*/
@POST(NetworkConstants.URI_IDENTITY_PATH_V2 + "validate/{medium}/submitToken")
fun submitToken(@Path("medium") medium: String, @Body body: IdentityRequestOwnershipParams): Call<SuccessResult>
}

View File

@ -83,7 +83,6 @@ internal abstract class IdentityModule {
.deleteRealmIfMigrationNeeded()
.build()
}
}
@Binds
@ -99,6 +98,9 @@ internal abstract class IdentityModule {
@Binds
abstract fun bindIdentityRequestTokenForBindingTask(task: DefaultIdentityRequestTokenForBindingTask): IdentityRequestTokenForBindingTask
@Binds
abstract fun bindIdentitySubmitTokenForBindingTask(task: DefaultIdentitySubmitTokenForBindingTask): IdentitySubmitTokenForBindingTask
@Binds
abstract fun bindBulkLookupTask(task: DefaultBulkLookupTask): BulkLookupTask

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.identity
import im.vector.matrix.android.api.session.identity.IdentityServiceError
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.identity.getCountryCode
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.identity.db.RealmIdentityServiceStore
@ -52,12 +53,14 @@ internal class DefaultIdentityRequestTokenForBindingTask @Inject constructor(
sendAttempt = 1,
email = params.threePid.email
))
is ThreePid.Msisdn -> identityAPI.requestTokenToBindMsisdn(IdentityRequestTokenForMsisdnBody(
clientSecret = clientSecret,
sendAttempt = 1,
phoneNumber = params.threePid.msisdn,
countryCode = params.threePid.countryCode
))
is ThreePid.Msisdn -> {
identityAPI.requestTokenToBindMsisdn(IdentityRequestTokenForMsisdnBody(
clientSecret = clientSecret,
sendAttempt = 1,
phoneNumber = params.threePid.msisdn,
countryCode = params.threePid.getCountryCode()
))
}
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright (c) 2020 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.session.identity
import im.vector.matrix.android.api.session.identity.IdentityServiceError
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.identity.toMedium
import im.vector.matrix.android.internal.auth.registration.SuccessResult
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.identity.db.RealmIdentityServiceStore
import im.vector.matrix.android.internal.session.identity.model.IdentityRequestOwnershipParams
import im.vector.matrix.android.internal.task.Task
import javax.inject.Inject
internal interface IdentitySubmitTokenForBindingTask : Task<IdentitySubmitTokenForBindingTask.Params, Unit> {
data class Params(
val threePid: ThreePid,
val token: String
)
}
internal class DefaultIdentitySubmitTokenForBindingTask @Inject constructor(
private val identityApiProvider: IdentityApiProvider,
private val identityServiceStore: RealmIdentityServiceStore,
@UserId private val userId: String
) : IdentitySubmitTokenForBindingTask {
override suspend fun execute(params: IdentitySubmitTokenForBindingTask.Params) {
val identityAPI = getIdentityApiAndEnsureTerms(identityApiProvider, userId)
val pendingThreePid = identityServiceStore.getPendingBinding(params.threePid) ?: throw IdentityServiceError.NoCurrentBindingError
val tokenResponse = executeRequest<SuccessResult>(null) {
apiCall = identityAPI.submitToken(
params.threePid.toMedium(),
IdentityRequestOwnershipParams(
clientSecret = pendingThreePid.clientSecret,
sid = pendingThreePid.sid,
token = params.token
))
}
if (!tokenResponse.isSuccess()) {
throw IdentityServiceError.BindingError
}
}
}

View File

@ -37,4 +37,9 @@ internal data class IdentityHashDetailResponse(
*/
@Json(name = "algorithms")
val algorithms: List<String>
)
) {
companion object{
const val ALGORITHM_SHA256 = "sha256"
const val ALGORITHM_NONE = "none"
}
}

View File

@ -20,12 +20,21 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class IdentityRequestOwnershipParams(
/**
* Required. The client secret that was supplied to the requestToken call.
*/
@Json(name = "client_secret")
var clientSecret: String? = null,
val clientSecret: String,
/**
* Required. The session ID, generated by the requestToken call.
*/
@Json(name = "sid")
var sid: String? = null,
val sid: String,
/**
* Required. The token generated by the requestToken call and sent to the user.
*/
@Json(name = "token")
var token: String? = null
val token: String
)

View File

@ -21,7 +21,13 @@ import com.squareup.moshi.JsonClass
// Just to consider common parameters
private interface IdentityRequestTokenBody {
/**
* Required. A unique string generated by the client, and used to identify the validation attempt.
* It must be a string consisting of the characters [0-9a-zA-Z.=_-].
* Its length must not exceed 255 characters and it must not be empty.
*/
val clientSecret: String
val sendAttempt: Int
}
@ -30,9 +36,19 @@ internal data class IdentityRequestTokenForEmailBody(
@Json(name = "client_secret")
override val clientSecret: String,
/**
* Required. The server will only send an email if the send_attempt is a number greater than the most
* recent one which it has seen, scoped to that email + client_secret pair. This is to avoid repeatedly
* sending the same email in the case of request retries between the POSTing user and the identity server.
* The client should increment this value if they desire a new email (e.g. a reminder) to be sent.
* If they do not, the server should respond with success but not resend the email.
*/
@Json(name = "send_attempt")
override val sendAttempt: Int,
/**
* Required. The email address to validate.
*/
@Json(name = "email")
val email: String
) : IdentityRequestTokenBody
@ -42,12 +58,25 @@ internal data class IdentityRequestTokenForMsisdnBody(
@Json(name = "client_secret")
override val clientSecret: String,
/**
* Required. The server will only send an SMS if the send_attempt is a number greater than the most recent one
* which it has seen, scoped to that country + phone_number + client_secret triple. This is to avoid repeatedly
* sending the same SMS in the case of request retries between the POSTing user and the identity server.
* The client should increment this value if they desire a new SMS (e.g. a reminder) to be sent.
*/
@Json(name = "send_attempt")
override val sendAttempt: Int,
/**
* Required. The phone number to validate.
*/
@Json(name = "phone_number")
val phoneNumber: String,
/**
* Required. The two-letter uppercase ISO-3166-1 alpha-2 country code that the number in phone_number
* should be parsed as if it were dialled from.
*/
@Json(name = "country")
val countryCode: String?
val countryCode: String
) : IdentityRequestTokenBody

View File

@ -21,9 +21,17 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class IdentityRequestTokenResponse(
/**
* Required. The session ID. Session IDs are opaque strings generated by the identity server.
* They must consist entirely of the characters [0-9a-zA-Z.=_-].
* Their length must not exceed 255 characters and they must not be empty.
*/
@Json(name = "sid")
val sid: String,
/**
* Not documented
*/
@Json(name = "success")
val success: Boolean
)

View File

@ -132,7 +132,7 @@ class DiscoverySettingsController @Inject constructor(
}
SharedState.BINDING_IN_PROGRESS -> {
buttonType(SettingsTextButtonItem.ButtonType.NORMAL)
buttonTitle("")
buttonTitle(null)
}
}
}
@ -145,7 +145,7 @@ class DiscoverySettingsController @Inject constructor(
interactionListener(object : SettingsItemText.Listener {
override fun onValidate(code: String) {
if (piState.threePid is ThreePid.Msisdn) {
listener?.checkMsisdnVerification(piState.threePid, code)
listener?.sendMsisdnVerificationCode(piState.threePid, code)
}
}
})
@ -299,7 +299,7 @@ class DiscoverySettingsController @Inject constructor(
fun onTapRevoke(threePid: ThreePid)
fun onTapShare(threePid: ThreePid)
fun checkEmailVerification(threePid: ThreePid.Email)
fun checkMsisdnVerification(threePid: ThreePid.Msisdn, code: String)
fun sendMsisdnVerificationCode(threePid: ThreePid.Msisdn, code: String)
fun onTapChangeIdentityServer()
fun onTapDisconnectIdentityServer()
fun onTapRetryToRetrieveBindings()

View File

@ -67,7 +67,7 @@ class DiscoverySettingsFragment @Inject constructor(
viewModel.observeViewEvents {
when (it) {
is DiscoverySettingsViewEvents.Failure -> {
// TODO Snackbar.make(view, throwable.toString(), Snackbar.LENGTH_LONG).show()
displayErrorDialog(it.throwable)
}
}.exhaustive
}
@ -126,7 +126,7 @@ class DiscoverySettingsFragment @Inject constructor(
viewModel.handle(DiscoverySettingsAction.FinalizeBind3pid(threePid))
}
override fun checkMsisdnVerification(threePid: ThreePid.Msisdn, code: String) {
override fun sendMsisdnVerificationCode(threePid: ThreePid.Msisdn, code: String) {
viewModel.handle(DiscoverySettingsAction.SubmitMsisdnToken(threePid, code))
}

View File

@ -24,7 +24,6 @@ import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
@ -166,18 +165,7 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
if (state.identityServer() == null) return@withState
changeThreePidState(action.threePid, Loading())
val threePid = if (action.threePid is ThreePid.Msisdn && action.threePid.countryCode == null) {
// Ensure we have a country code
val phoneNumber = PhoneNumberUtil.getInstance()
.parse("+${action.threePid.msisdn}", null)
action.threePid.copy(countryCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode)
)
} else {
action.threePid
}
identityService.startBindThreePid(threePid, object : MatrixCallback<Unit> {
identityService.startBindThreePid(action.threePid, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
changeThreePidState(action.threePid, Success(SharedState.BINDING_IN_PROGRESS))
}
@ -286,8 +274,8 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
override fun onSuccess(data: Map<ThreePid, SharedState>) {
setState {
copy(
emailList = Success(toPidInfoList(data.filter { it.key is ThreePid.Email })),
phoneNumbersList = Success(toPidInfoList(data.filter { it.key is ThreePid.Msisdn }))
emailList = Success(data.filter { it.key is ThreePid.Email }.toPidInfoList()),
phoneNumbersList = Success(data.filter { it.key is ThreePid.Msisdn }.toPidInfoList())
)
}
}
@ -312,8 +300,8 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
})
}
private fun toPidInfoList(threePidStatuses: Map<ThreePid, SharedState>): List<PidInfo> {
return threePidStatuses.map { threePidStatus ->
private fun Map<ThreePid, SharedState>.toPidInfoList(): List<PidInfo> {
return map { threePidStatus ->
PidInfo(
threePid = threePidStatus.key,
isShared = Success(threePidStatus.value)
@ -328,7 +316,6 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
action.code,
object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// TODO This should be done in the task
finalizeBind3pid(DiscoverySettingsAction.FinalizeBind3pid(action.threePid))
}

View File

@ -7,7 +7,6 @@
android:background="?attr/colorBackgroundFloating"
android:orientation="vertical"
android:paddingStart="@dimen/layout_horizontal_margin"
android:paddingTop="@dimen/layout_vertical_margin"
android:paddingEnd="@dimen/layout_horizontal_margin"
android:paddingBottom="@dimen/layout_vertical_margin">