diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt index 76367042..c90caf22 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt @@ -13,4 +13,5 @@ interface UserAccountRepository { fun deleteById(id: UUID) fun loadUserProfileById(id: UUID): UserProfileDto? fun loadFullUserAccountById(id: UUID): UserAccountDto? + fun updatePassword(id: UUID, newHashedPassword: String) } \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt new file mode 100644 index 00000000..7e1db8a9 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.user.update + +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.domain.shared.presenter.GenericPresenter + +interface ChangeAccountPasswordPresenter : GenericPresenter \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt new file mode 100644 index 00000000..4e02c3a1 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -0,0 +1,20 @@ +package br.all.application.user.update + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +interface ChangeAccountPasswordService { + fun changePassword(presenter: ChangeAccountPasswordPresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val oldPassword: String, + val newPassword: String, + val confirmPassword: String + ) + + @Schema(name = "ChangeAccountPasswordServiceResponseModel", description = "Response model for Change Account Password Service") + data class ResponseModel( + val userId: UUID + ) +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt new file mode 100644 index 00000000..cca8ef7d --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt @@ -0,0 +1,47 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.domain.shared.service.PasswordEncoderPort + +class ChangeAccountPasswordServiceImpl( + private val repository: UserAccountRepository, + private val encoder: PasswordEncoderPort +) : ChangeAccountPasswordService { + override fun changePassword( + presenter: ChangeAccountPasswordPresenter, + request: ChangeAccountPasswordService.RequestModel + ) { + val userCredentials = repository.loadCredentialsById(request.userId) + if (userCredentials == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) + return + } + + if (!encoder.matches(request.oldPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("Invalid old password provided!")) + return + } + + if (request.newPassword != request.confirmPassword) { + presenter.prepareFailView(IllegalArgumentException("Confirm password does not match new password!")) + return + } + + if (encoder.matches(request.newPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("New password cannot be the same as the old password!")) + return + } + + val newHashedPassword = encoder.encode(request.newPassword) + + try { + repository.updatePassword(request.userId, newHashedPassword) + presenter.prepareSuccessView(ChangeAccountPasswordService.ResponseModel( + request.userId, + )) + } catch (e: Exception) { + presenter.prepareFailView(e) + } + } + +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt index 57f1c1d5..cff1ae6f 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt @@ -48,6 +48,14 @@ class UserAccountRepositoryImpl( ) } + override fun updatePassword(id: UUID, newHashedPassword: String) { + val credentials = credentialsRepository.findById(id).orElse(null) + ?: throw NoSuchElementException("Cannot update password. Credentials for user with id $id not found!") + + credentials.password = newHashedPassword + credentialsRepository.save(credentials) + } + override fun loadCredentialsByUsername(username: String) = credentialsRepository.findByUsername(username)?.toAccountCredentialsDto() diff --git a/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt new file mode 100644 index 00000000..07d2b3a9 --- /dev/null +++ b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt @@ -0,0 +1,181 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.utils.TestDataFactory +import br.all.domain.shared.service.PasswordEncoderPort +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class ChangeAccountPasswordServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var repository: UserAccountRepository + + @MockK + private lateinit var encoder: PasswordEncoderPort + + @MockK(relaxUnitFun = true) + private lateinit var presenter: ChangeAccountPasswordPresenter + + private lateinit var sut: ChangeAccountPasswordServiceImpl + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setup() { + sut = ChangeAccountPasswordServiceImpl(repository, encoder) + factory = TestDataFactory() + } + + @Nested + @DisplayName("When changing a user's password") + inner class WhenChangingPassword { + + @Test + fun `should update password when all inputs are valid`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + val newHashedPassword = "newHashedPassword" + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns false + every { encoder.encode(request.newPassword) } returns newHashedPassword + + val userIdSlot = slot() + val passwordSlot = slot() + every { repository.updatePassword(capture(userIdSlot), capture(passwordSlot)) } returns Unit + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.updatePassword(userCredentials.id, newHashedPassword) } + + assertEquals(userCredentials.id, userIdSlot.captured) + assertEquals(newHashedPassword, passwordSlot.captured) + assertEquals(userCredentials.id, responseSlot.captured.userId) + } + + @Test + fun `should prepare fail view when user does not exist`() { + val nonExistentUserId = UUID.randomUUID() + val request = ChangeAccountPasswordService.RequestModel( + userId = nonExistentUserId, + oldPassword = "any", newPassword = "any", confirmPassword = "any" + ) + + every { repository.loadCredentialsById(nonExistentUserId) } returns null + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { repository.updatePassword(any(), any()) } + + assertIs(exceptionSlot.captured) + assertEquals("User with id $nonExistentUserId doesn't exist!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when old password is incorrect`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "wrongOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns false + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Invalid old password provided!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password and confirmation do not match`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "doesNotMatch" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Confirm password does not match new password!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password is the same as the old one`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainOldPassword", + confirmPassword = "plainOldPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("New password cannot be the same as the old password!", exceptionSlot.captured.message) + } + } +} diff --git a/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt new file mode 100644 index 00000000..6d72fc0d --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt @@ -0,0 +1,6 @@ +package br.all.domain.shared.service + +interface PasswordEncoderPort { + fun encode(rawPassword: String): String + fun matches(rawPassword: String, encodedPassword: String): Boolean +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt new file mode 100644 index 00000000..01a5afbd --- /dev/null +++ b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt @@ -0,0 +1,18 @@ +package br.all.security.service + +import br.all.domain.shared.service.PasswordEncoderPort +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Component + +@Component +class PasswordEncoderAdapter( + private val encoder: PasswordEncoder +) : PasswordEncoderPort { + override fun encode(rawPassword: String): String { + return encoder.encode(rawPassword) + } + + override fun matches(rawPassword: String, encodedPassword: String): Boolean { + return encoder.matches(rawPassword, encodedPassword) + } +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt index 04b92878..3f2c117e 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt @@ -3,7 +3,9 @@ package br.all.user.controller import br.all.application.user.create.RegisterUserAccountServiceImpl import br.all.application.user.find.RetrieveUserProfileServiceImpl import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.update.ChangeAccountPasswordServiceImpl import br.all.application.user.update.PatchUserProfileServiceImpl +import br.all.domain.shared.service.PasswordEncoderPort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -18,4 +20,7 @@ class UserAccountConfiguration { @Bean fun patchUserProfile(repository: UserAccountRepository) = PatchUserProfileServiceImpl(repository) + + @Bean + fun changeAccountPassword(repository: UserAccountRepository, encoder: PasswordEncoderPort) = ChangeAccountPasswordServiceImpl(repository, encoder) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index b3dbaaea..e7598210 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -4,23 +4,30 @@ import br.all.application.user.CredentialsService import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.create.RegisterUserAccountService.RequestModel import br.all.application.user.find.RetrieveUserProfileService +import br.all.application.user.update.ChangeAccountPasswordService import br.all.application.user.update.PatchUserProfileService import br.all.security.service.AuthenticationInfoService +import br.all.security.service.AuthenticationService +import br.all.user.presenter.RestfulChangeAccountPasswordPresenter import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter import br.all.user.presenter.RestfulRetrieveUserProfilePresenter +import br.all.user.requests.ChangeAccountPasswordRequest import br.all.user.requests.PatchUserProfileRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -32,7 +39,9 @@ class UserAccountController( private val encoder: PasswordEncoder, private val retrieveUserProfileService: RetrieveUserProfileService, private val authenticationInfoService: AuthenticationInfoService, - private val patchUserProfileService: PatchUserProfileService + private val patchUserProfileService: PatchUserProfileService, + private val changeAccountPasswordService: ChangeAccountPasswordService, + private val authenticationService: AuthenticationService ) { @PostMapping @@ -146,4 +155,57 @@ class UserAccountController( patchUserProfileService.patchProfile(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + @PutMapping("/change-password") + @Operation(summary = "Update password of an user account and logout") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Success updating account password", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ChangeAccountPasswordService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating account password - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating account password - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail updating account password - nonexistent user", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun putAccountPassword( + @RequestBody body: ChangeAccountPasswordRequest, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity<*> { + val presenter = RestfulChangeAccountPasswordPresenter() + val userId = authenticationInfoService.getAuthenticatedUserId() + val changePasswordRequest = ChangeAccountPasswordService.RequestModel( + userId = userId, + oldPassword = body.oldPassword, + newPassword = body.newPassword, + confirmPassword = body.confirmPassword + ) + + changeAccountPasswordService.changePassword(presenter, changePasswordRequest) + + if (presenter.responseEntity?.statusCode?.isError == true) { + return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + + authenticationService.logout(request, response) + + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt new file mode 100644 index 00000000..7a639421 --- /dev/null +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt @@ -0,0 +1,29 @@ +package br.all.user.presenter + +import br.all.application.user.update.ChangeAccountPasswordPresenter +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.shared.error.createErrorResponseFrom +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.ok +import java.util.UUID + +class RestfulChangeAccountPasswordPresenter : ChangeAccountPasswordPresenter { + + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val restfulResponse = ViewModel( + userId = response.userId + ) + responseEntity = ok(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val userId: UUID + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt new file mode 100644 index 00000000..7ce198d0 --- /dev/null +++ b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt @@ -0,0 +1,7 @@ +package br.all.user.requests + +data class ChangeAccountPasswordRequest( + val oldPassword: String, + val newPassword: String, + val confirmPassword: String +) \ No newline at end of file