Merge pull request #6171 from vector-im/feature/adm/sdk-new-password-on-confirmation

[SDK] Allow passwords to be set at the point of reset confirmation
This commit is contained in:
Adam Brown 2022-06-07 16:43:28 +01:00 committed by GitHub
commit 462d3071de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 201 additions and 119 deletions

1
changelog.d/6169.sdk Normal file
View File

@ -0,0 +1 @@
Allows new passwords to be passed at the point of confirmation when resetting a password

View File

@ -65,16 +65,14 @@ interface LoginWizard {
* [resetPasswordMailConfirmed] is successfully called. * [resetPasswordMailConfirmed] is successfully called.
* *
* @param email an email previously associated to the account the user wants the password to be reset. * @param email an email previously associated to the account the user wants the password to be reset.
* @param newPassword the desired new password
*/ */
suspend fun resetPassword( suspend fun resetPassword(email: String)
email: String,
newPassword: String
)
/** /**
* Confirm the new password, once the user has checked their email * Confirm the new password, once the user has checked their email
* When this method succeed, tha account password will be effectively modified. * When this method succeed, tha account password will be effectively modified.
*
* @param newPassword the desired new password
*/ */
suspend fun resetPasswordMailConfirmed() suspend fun resetPasswordMailConfirmed(newPassword: String)
} }

View File

@ -103,7 +103,7 @@ internal class DefaultLoginWizard(
return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig)
} }
override suspend fun resetPassword(email: String, newPassword: String) { override suspend fun resetPassword(email: String) {
val param = RegisterAddThreePidTask.Params( val param = RegisterAddThreePidTask.Params(
RegisterThreePid.Email(email), RegisterThreePid.Email(email),
pendingSessionData.clientSecret, pendingSessionData.clientSecret,
@ -117,18 +117,16 @@ internal class DefaultLoginWizard(
authAPI.resetPassword(AddThreePidRegistrationParams.from(param)) authAPI.resetPassword(AddThreePidRegistrationParams.from(param))
} }
pendingSessionData = pendingSessionData.copy(resetPasswordData = ResetPasswordData(newPassword, result)) pendingSessionData = pendingSessionData.copy(resetPasswordData = ResetPasswordData(result))
.also { pendingSessionStore.savePendingSessionData(it) } .also { pendingSessionStore.savePendingSessionData(it) }
} }
override suspend fun resetPasswordMailConfirmed() { override suspend fun resetPasswordMailConfirmed(newPassword: String) {
val safeResetPasswordData = pendingSessionData.resetPasswordData val resetPasswordData = pendingSessionData.resetPasswordData ?: throw IllegalStateException("Developer error - Must call resetPassword first")
?: throw IllegalStateException("developer error, no reset password in progress")
val param = ResetPasswordMailConfirmed.create( val param = ResetPasswordMailConfirmed.create(
pendingSessionData.clientSecret, pendingSessionData.clientSecret,
safeResetPasswordData.addThreePidRegistrationResponse.sid, resetPasswordData.addThreePidRegistrationResponse.sid,
safeResetPasswordData.newPassword newPassword
) )
executeRequest(null) { executeRequest(null) {

View File

@ -24,6 +24,5 @@ import org.matrix.android.sdk.internal.auth.registration.AddThreePidRegistration
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
internal data class ResetPasswordData( internal data class ResetPasswordData(
val newPassword: String,
val addThreePidRegistrationResponse: AddThreePidRegistrationResponse val addThreePidRegistrationResponse: AddThreePidRegistrationResponse
) )

View File

@ -413,7 +413,8 @@ class LoginViewModel @AssistedInject constructor(
copy( copy(
asyncResetPassword = Uninitialized, asyncResetPassword = Uninitialized,
asyncResetMailConfirmed = Uninitialized, asyncResetMailConfirmed = Uninitialized,
resetPasswordEmail = null resetPasswordEmail = null,
resetPasswordNewPassword = null
) )
} }
} }
@ -488,7 +489,7 @@ class LoginViewModel @AssistedInject constructor(
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
try { try {
safeLoginWizard.resetPassword(action.email, action.newPassword) safeLoginWizard.resetPassword(action.email)
} catch (failure: Throwable) { } catch (failure: Throwable) {
setState { setState {
copy( copy(
@ -501,7 +502,8 @@ class LoginViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
asyncResetPassword = Success(Unit), asyncResetPassword = Success(Unit),
resetPasswordEmail = action.email resetPasswordEmail = action.email,
resetPasswordNewPassword = action.newPassword
) )
} }
@ -529,24 +531,35 @@ class LoginViewModel @AssistedInject constructor(
} }
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
try { val state = awaitState()
safeLoginWizard.resetPasswordMailConfirmed()
} catch (failure: Throwable) { if (state.resetPasswordNewPassword == null) {
setState { setState {
copy( copy(
asyncResetMailConfirmed = Fail(failure) asyncResetPassword = Uninitialized,
asyncResetMailConfirmed = Fail(Throwable("Developer error - New password not set"))
) )
} }
return@launch } else {
try {
safeLoginWizard.resetPasswordMailConfirmed(state.resetPasswordNewPassword)
} catch (failure: Throwable) {
setState {
copy(
asyncResetMailConfirmed = Fail(failure)
)
}
return@launch
}
setState {
copy(
asyncResetMailConfirmed = Success(Unit),
resetPasswordEmail = null,
resetPasswordNewPassword = null
)
}
_viewEvents.post(LoginViewEvents.OnResetPasswordMailConfirmationSuccess)
} }
setState {
copy(
asyncResetMailConfirmed = Success(Unit),
resetPasswordEmail = null
)
}
_viewEvents.post(LoginViewEvents.OnResetPasswordMailConfirmationSuccess)
} }
} }
} }

View File

@ -38,6 +38,8 @@ data class LoginViewState(
@PersistState @PersistState
val resetPasswordEmail: String? = null, val resetPasswordEmail: String? = null,
@PersistState @PersistState
val resetPasswordNewPassword: String? = null,
@PersistState
val homeServerUrlFromUser: String? = null, val homeServerUrlFromUser: String? = null,
// Can be modified after a Wellknown request // Can be modified after a Wellknown request

View File

@ -392,7 +392,8 @@ class LoginViewModel2 @AssistedInject constructor(
LoginAction2.ResetResetPassword -> { LoginAction2.ResetResetPassword -> {
setState { setState {
copy( copy(
resetPasswordEmail = null resetPasswordEmail = null,
resetPasswordNewPassword = null
) )
} }
} }
@ -443,7 +444,7 @@ class LoginViewModel2 @AssistedInject constructor(
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
try { try {
safeLoginWizard.resetPassword(action.email, action.newPassword) safeLoginWizard.resetPassword(action.email)
} catch (failure: Throwable) { } catch (failure: Throwable) {
_viewEvents.post(LoginViewEvents2.Failure(failure)) _viewEvents.post(LoginViewEvents2.Failure(failure))
setState { copy(isLoading = false) } setState { copy(isLoading = false) }
@ -453,7 +454,8 @@ class LoginViewModel2 @AssistedInject constructor(
setState { setState {
copy( copy(
isLoading = false, isLoading = false,
resetPasswordEmail = action.email resetPasswordEmail = action.email,
resetPasswordNewPassword = action.newPassword
) )
} }
@ -472,7 +474,8 @@ class LoginViewModel2 @AssistedInject constructor(
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
try { try {
safeLoginWizard.resetPasswordMailConfirmed() val state = awaitState()
safeLoginWizard.resetPasswordMailConfirmed(state.resetPasswordNewPassword!!)
} catch (failure: Throwable) { } catch (failure: Throwable) {
_viewEvents.post(LoginViewEvents2.Failure(failure)) _viewEvents.post(LoginViewEvents2.Failure(failure))
setState { copy(isLoading = false) } setState { copy(isLoading = false) }
@ -481,7 +484,8 @@ class LoginViewModel2 @AssistedInject constructor(
setState { setState {
copy( copy(
isLoading = false, isLoading = false,
resetPasswordEmail = null resetPasswordEmail = null,
resetPasswordNewPassword = null
) )
} }

View File

@ -36,6 +36,8 @@ data class LoginViewState2(
@PersistState @PersistState
val resetPasswordEmail: String? = null, val resetPasswordEmail: String? = null,
@PersistState @PersistState
val resetPasswordNewPassword: String? = null,
@PersistState
val homeServerUrlFromUser: String? = null, val homeServerUrlFromUser: String? = null,
// Can be modified after a Wellknown request // Can be modified after a Wellknown request

View File

@ -128,7 +128,7 @@ class OnboardingViewModel @AssistedInject constructor(
val isRegistrationStarted: Boolean val isRegistrationStarted: Boolean
get() = authenticationService.isRegistrationStarted() get() = authenticationService.isRegistrationStarted()
private val loginWizard: LoginWizard? private val loginWizard: LoginWizard
get() = authenticationService.getLoginWizard() get() = authenticationService.getLoginWizard()
private var loginConfig: LoginConfig? = null private var loginConfig: LoginConfig? = null
@ -246,21 +246,15 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleLoginWithToken(action: OnboardingAction.LoginWithToken) { private fun handleLoginWithToken(action: OnboardingAction.LoginWithToken) {
val safeLoginWizard = loginWizard val safeLoginWizard = loginWizard
setState { copy(isLoading = true) }
if (safeLoginWizard == null) { currentJob = viewModelScope.launch {
setState { copy(isLoading = false) } try {
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Bad configuration"))) val result = safeLoginWizard.loginWithToken(action.loginToken)
} else { onSessionCreated(result, authenticationDescription = AuthenticationDescription.Login)
setState { copy(isLoading = true) } } catch (failure: Throwable) {
setState { copy(isLoading = false) }
currentJob = viewModelScope.launch { _viewEvents.post(OnboardingViewEvents.Failure(failure))
try {
val result = safeLoginWizard.loginWithToken(action.loginToken)
onSessionCreated(result, authenticationDescription = AuthenticationDescription.Login)
} catch (failure: Throwable) {
setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(failure))
}
} }
} }
} }
@ -377,7 +371,7 @@ class OnboardingViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
isLoading = false, isLoading = false,
resetPasswordEmail = null resetState = ResetState()
) )
} }
} }
@ -447,59 +441,52 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleResetPassword(action: OnboardingAction.ResetPassword) { private fun handleResetPassword(action: OnboardingAction.ResetPassword) {
val safeLoginWizard = loginWizard val safeLoginWizard = loginWizard
setState { copy(isLoading = true) }
if (safeLoginWizard == null) { currentJob = viewModelScope.launch {
setState { copy(isLoading = false) } runCatching { safeLoginWizard.resetPassword(action.email) }.fold(
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Bad configuration"))) onSuccess = {
} else { setState {
setState { copy(isLoading = true) } copy(
isLoading = false,
currentJob = viewModelScope.launch { resetState = ResetState(email = action.email, newPassword = action.newPassword)
try { )
safeLoginWizard.resetPassword(action.email, action.newPassword) }
} catch (failure: Throwable) { _viewEvents.post(OnboardingViewEvents.OnResetPasswordSendThreePidDone)
setState { copy(isLoading = false) } },
_viewEvents.post(OnboardingViewEvents.Failure(failure)) onFailure = {
return@launch setState { copy(isLoading = false) }
} _viewEvents.post(OnboardingViewEvents.Failure(it))
}
setState { )
copy(
isLoading = false,
resetPasswordEmail = action.email
)
}
_viewEvents.post(OnboardingViewEvents.OnResetPasswordSendThreePidDone)
}
} }
} }
private fun handleResetPasswordMailConfirmed() { private fun handleResetPasswordMailConfirmed() {
val safeLoginWizard = loginWizard setState { copy(isLoading = true) }
currentJob = viewModelScope.launch {
if (safeLoginWizard == null) { val resetState = awaitState().resetState
setState { copy(isLoading = false) } when (val newPassword = resetState.newPassword) {
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Bad configuration"))) null -> {
} else {
setState { copy(isLoading = false) }
currentJob = viewModelScope.launch {
try {
safeLoginWizard.resetPasswordMailConfirmed()
} catch (failure: Throwable) {
setState { copy(isLoading = false) } setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(failure)) _viewEvents.post(OnboardingViewEvents.Failure(IllegalStateException("Developer error - No new password has been set")))
return@launch
} }
setState { else -> {
copy( runCatching { loginWizard.resetPasswordMailConfirmed(newPassword) }.fold(
isLoading = false, onSuccess = {
resetPasswordEmail = null setState {
copy(
isLoading = false,
resetState = ResetState()
)
}
_viewEvents.post(OnboardingViewEvents.OnResetPasswordMailConfirmationSuccess)
},
onFailure = {
setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(it))
}
) )
} }
_viewEvents.post(OnboardingViewEvents.OnResetPasswordMailConfirmationSuccess)
} }
} }
} }
@ -519,25 +506,19 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleLogin(action: AuthenticateAction.Login) { private fun handleLogin(action: AuthenticateAction.Login) {
val safeLoginWizard = loginWizard val safeLoginWizard = loginWizard
setState { copy(isLoading = true) }
if (safeLoginWizard == null) { currentJob = viewModelScope.launch {
setState { copy(isLoading = false) } try {
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Bad configuration"))) val result = safeLoginWizard.login(
} else { action.username,
setState { copy(isLoading = true) } action.password,
currentJob = viewModelScope.launch { action.initialDeviceName
try { )
val result = safeLoginWizard.login( reAuthHelper.data = action.password
action.username, onSessionCreated(result, authenticationDescription = AuthenticationDescription.Login)
action.password, } catch (failure: Throwable) {
action.initialDeviceName setState { copy(isLoading = false) }
) _viewEvents.post(OnboardingViewEvents.Failure(failure))
reAuthHelper.data = action.password
onSessionCreated(result, authenticationDescription = AuthenticationDescription.Login)
} catch (failure: Throwable) {
setState { copy(isLoading = false) }
_viewEvents.post(OnboardingViewEvents.Failure(failure))
}
} }
} }
} }

View File

@ -39,7 +39,7 @@ data class OnboardingViewState(
@PersistState @PersistState
val signMode: SignMode = SignMode.Unknown, val signMode: SignMode = SignMode.Unknown,
@PersistState @PersistState
val resetPasswordEmail: String? = null, val resetState: ResetState = ResetState(),
// For SSO session recovery // For SSO session recovery
@PersistState @PersistState
@ -84,6 +84,12 @@ data class PersonalizationState(
fun supportsPersonalization() = supportsChangingDisplayName || supportsChangingProfilePicture fun supportsPersonalization() = supportsChangingDisplayName || supportsChangingProfilePicture
} }
@Parcelize
data class ResetState(
val email: String? = null,
val newPassword: String? = null,
) : Parcelable
@Parcelize @Parcelize
data class SelectedAuthenticationState( data class SelectedAuthenticationState(
val description: AuthenticationDescription? = null, val description: AuthenticationDescription? = null,

View File

@ -153,7 +153,7 @@ abstract class AbstractFtueAuthFragment<VB : ViewBinding> : VectorBaseFragment<V
final override fun invalidate() = withState(viewModel) { state -> final override fun invalidate() = withState(viewModel) { state ->
// True when email is sent with success to the homeserver // True when email is sent with success to the homeserver
isResetPasswordStarted = state.resetPasswordEmail.isNullOrBlank().not() isResetPasswordStarted = state.resetState.email.isNullOrBlank().not()
updateWithState(state) updateWithState(state)
} }

View File

@ -44,7 +44,7 @@ class FtueAuthResetPasswordMailConfirmationFragment @Inject constructor() : Abst
} }
private fun setupUi(state: OnboardingViewState) { private fun setupUi(state: OnboardingViewState) {
views.resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetPasswordEmail) views.resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetState.email)
} }
private fun submit() { private fun submit() {

View File

@ -31,6 +31,7 @@ import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeDirectLoginUseCase import im.vector.app.test.fakes.FakeDirectLoginUseCase
import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeLoginWizard
import im.vector.app.test.fakes.FakeRegisterActionHandler import im.vector.app.test.fakes.FakeRegisterActionHandler
import im.vector.app.test.fakes.FakeRegistrationWizard import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeSession
@ -67,6 +68,8 @@ private val A_DIRECT_LOGIN = OnboardingAction.AuthenticateAction.LoginDirect("@a
private const val A_HOMESERVER_URL = "https://edited-homeserver.org" private const val A_HOMESERVER_URL = "https://edited-homeserver.org"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance) private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance)
private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password) private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password)
private const val AN_EMAIL = "hello@example.com"
private const val A_PASSWORD = "a-password"
class OnboardingViewModelTest { class OnboardingViewModelTest {
@ -85,6 +88,7 @@ class OnboardingViewModelTest {
private val fakeHomeServerConnectionConfigFactory = FakeHomeServerConnectionConfigFactory() private val fakeHomeServerConnectionConfigFactory = FakeHomeServerConnectionConfigFactory()
private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase() private val fakeStartAuthenticationFlowUseCase = FakeStartAuthenticationFlowUseCase()
private val fakeHomeServerHistoryService = FakeHomeServerHistoryService() private val fakeHomeServerHistoryService = FakeHomeServerHistoryService()
private val fakeLoginWizard = FakeLoginWizard()
private var initialState = OnboardingViewState() private var initialState = OnboardingViewState()
private lateinit var viewModel: OnboardingViewModel private lateinit var viewModel: OnboardingViewModel
@ -466,6 +470,43 @@ class OnboardingViewModelTest {
.finish() .finish()
} }
@Test
fun `given can successfully reset password, when resetting password, then emits reset done event`() = runTest {
val test = viewModel.test()
fakeLoginWizard.givenResetPasswordSuccess(AN_EMAIL)
fakeAuthenticationService.givenLoginWizard(fakeLoginWizard)
viewModel.handle(OnboardingAction.ResetPassword(email = AN_EMAIL, newPassword = A_PASSWORD))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(isLoading = false, resetState = ResetState(AN_EMAIL, A_PASSWORD)) }
)
.assertEvents(OnboardingViewEvents.OnResetPasswordSendThreePidDone)
.finish()
}
@Test
fun `given can successfully confirm reset password, when confirm reset password, then emits reset success`() = runTest {
viewModelWith(initialState.copy(resetState = ResetState(AN_EMAIL, A_PASSWORD)))
val test = viewModel.test()
fakeLoginWizard.givenConfirmResetPasswordSuccess(A_PASSWORD)
fakeAuthenticationService.givenLoginWizard(fakeLoginWizard)
viewModel.handle(OnboardingAction.ResetPasswordMailConfirmed)
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(isLoading = false, resetState = ResetState()) }
)
.assertEvents(OnboardingViewEvents.OnResetPasswordMailConfirmationSuccess)
.finish()
}
private fun viewModelWith(state: OnboardingViewState) { private fun viewModelWith(state: OnboardingViewState) {
OnboardingViewModel( OnboardingViewModel(
state, state,

View File

@ -23,6 +23,7 @@ import io.mockk.mockk
import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
@ -36,6 +37,10 @@ class FakeAuthenticationService : AuthenticationService by mockk() {
every { isRegistrationStarted() } returns started every { isRegistrationStarted() } returns started
} }
fun givenLoginWizard(loginWizard: LoginWizard) {
every { getLoginWizard() } returns loginWizard
}
fun givenLoginFlow(config: HomeServerConnectionConfig, result: LoginFlowResult) { fun givenLoginFlow(config: HomeServerConnectionConfig, result: LoginFlowResult) {
coEvery { getLoginFlow(config) } returns result coEvery { getLoginFlow(config) } returns result
} }

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2022 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.app.test.fakes
import io.mockk.coJustRun
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.login.LoginWizard
class FakeLoginWizard : LoginWizard by mockk() {
fun givenResetPasswordSuccess(email: String) {
coJustRun { resetPassword(email) }
}
fun givenConfirmResetPasswordSuccess(password: String) {
coJustRun { resetPasswordMailConfirmed(password) }
}
}