From 79c006774ee354510434e3e6ecf39bcb989457e1 Mon Sep 17 00:00:00 2001 From: Marta Jankovics Date: Sun, 23 Mar 2025 17:04:31 +0100 Subject: [PATCH] FINERACT-2185: User management improvement --- .../domain/CommandProcessingResultType.java | 14 ++++ .../commands/domain/CommandSource.java | 42 ++++++++--- .../commands/domain/CommandWrapper.java | 28 +++++--- .../service/CommandSourceService.java | 62 ++++++++++++++-- .../service/CommandWrapperBuilder.java | 24 ++++++- ...CommandSourceWritePlatformServiceImpl.java | 4 +- .../SynchronousCommandProcessingService.java | 11 +-- .../useradministration/domain/AppUser.java | 42 ++++++----- .../service/AppUserConstants.java | 2 + ...encyCommandProcessFailedExceptionTest.java | 4 +- .../center/CenterImportHandler.java | 2 +- .../group/GroupImportHandler.java | 2 +- .../api/InteropWrapperBuilder.java | 2 +- .../api/UsersApiResource.java | 23 +++++- .../api/UsersApiResourceSwagger.java | 37 ++++++++++ .../ChangeUserPasswordCommandHandler.java | 47 ++++++++++++ .../service/AppUserWritePlatformService.java | 2 + ...WritePlatformServiceJpaRepositoryImpl.java | 30 ++++++++ .../service/UserDataValidator.java | 72 ++++++++++++------- .../db/changelog/tenant/changelog-tenant.xml | 1 + .../parts/0169_add_missing_permissions.xml | 9 +++ .../tenant/parts/0173_user_change_pwd.xml | 53 ++++++++++++++ .../resources/static/legacy-docs/apiLive.htm | 37 +++++++++- .../CommandServiceStepDefinitions.java | 2 +- .../service/CommandSourceServiceTest.java | 4 ++ .../service/IdempotencyKeyResolverTest.java | 2 +- ...nchronousCommandProcessingServiceTest.java | 5 +- .../UserAdministrationTest.java | 38 +++++++++- 28 files changed, 506 insertions(+), 95 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/ChangeUserPasswordCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0173_user_change_pwd.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandProcessingResultType.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandProcessingResultType.java index 299c1a0e2..17f70dfe4 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandProcessingResultType.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandProcessingResultType.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.commands.domain; +import jakarta.validation.constraints.NotNull; import java.util.Arrays; import java.util.Map; import java.util.stream.Collectors; @@ -41,8 +42,21 @@ public enum CommandProcessingResultType { private final Integer value; private final String code; + @NotNull public static CommandProcessingResultType fromInt(final Integer value) { CommandProcessingResultType transactionType = BY_ID.get(value); return transactionType == null ? INVALID : transactionType; } + + public boolean isProcessed() { + return this == PROCESSED; + } + + public boolean isAwaitingApproval() { + return this == AWAITING_APPROVAL; + } + + public boolean isRejected() { + return this == REJECTED; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandSource.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandSource.java index e468e4a17..574d2cd09 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandSource.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandSource.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import java.time.OffsetDateTime; import lombok.AllArgsConstructor; import lombok.Builder; @@ -136,9 +137,11 @@ public class CommandSource extends AbstractPersistableCustom { @Column(name = "loan_external_id", length = 100) private ExternalId loanExternalId; - public static CommandSource fullEntryFrom(final CommandWrapper wrapper, final JsonCommand command, final AppUser maker, - String idempotencyKey, Integer status) { + @Column(name = "is_sanitized", nullable = false) + private boolean sanitized; + public static CommandSource fullEntryFrom(final CommandWrapper wrapper, final JsonCommand command, final AppUser maker, + String idempotencyKey, Integer status, boolean sanitized) { return CommandSource.builder() // .actionName(wrapper.actionName()) // .entityName(wrapper.entityName()) // @@ -159,31 +162,52 @@ public class CommandSource extends AbstractPersistableCustom { .transactionId(command.getTransactionId()) // .creditBureauId(command.getCreditBureauId()) // .organisationCreditBureauId(command.getOrganisationCreditBureauId()) // - .loanExternalId(command.getLoanExternalId()).build(); // + .loanExternalId(command.getLoanExternalId()).sanitized(sanitized).build(); // } public String getPermissionCode() { return this.actionName + "_" + this.entityName; } - public void markAsAwaitingApproval() { - this.status = CommandProcessingResultType.AWAITING_APPROVAL.getValue(); + @NotNull + public CommandProcessingResultType getStatusEnum() { + return CommandProcessingResultType.fromInt(status); } - public boolean isMarkedAsAwaitingApproval() { - return this.status.equals(CommandProcessingResultType.AWAITING_APPROVAL.getValue()); + public void setStatus(@NotNull CommandProcessingResultType status) { + this.status = status.getValue(); + } + + public void markAsAwaitingApproval() { + setStatus(CommandProcessingResultType.AWAITING_APPROVAL); + } + + public boolean isAwaitingApproval() { + return getStatusEnum().isAwaitingApproval(); + } + + public boolean isProcessed() { + return getStatusEnum().isProcessed(); } public void markAsChecked(final AppUser checker) { this.checker = checker; this.checkedOnDate = DateUtils.getAuditOffsetDateTime(); - this.status = CommandProcessingResultType.PROCESSED.getValue(); + setStatus(CommandProcessingResultType.PROCESSED); + } + + public boolean isChecked() { + return checker != null && isProcessed(); } public void markAsRejected(final AppUser checker) { this.checker = checker; this.checkedOnDate = DateUtils.getAuditOffsetDateTime(); - this.status = CommandProcessingResultType.REJECTED.getValue(); + setStatus(CommandProcessingResultType.REJECTED); + } + + public boolean isRejected() { + return checker != null && isRejected(); } public void updateForAudit(final CommandProcessingResult result) { diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java index fd73b43b9..8d1d60687 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/domain/CommandWrapper.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.commands.domain; +import java.util.Set; import lombok.Getter; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.useradministration.api.PasswordPreferencesApiConstants; @@ -45,6 +46,7 @@ public class CommandWrapper { private final Long organisationCreditBureauId; private final String jobName; private final ExternalId loanExternalId; + private final Set sanitizeJsonKeys; private final String idempotencyKey; @@ -67,7 +69,7 @@ public class CommandWrapper { final ExternalId loanExternalId) { return new CommandWrapper(commandId, actionName, entityName, resourceId, subresourceId, resourceGetUrl, productId, officeId, groupId, clientId, loanId, savingsId, transactionId, creditBureauId, organisationCreditBureauId, idempotencyKey, - loanExternalId); + loanExternalId, null); } private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId, @@ -92,12 +94,14 @@ public class CommandWrapper { this.jobName = null; this.idempotencyKey = null; this.loanExternalId = null; + this.sanitizeJsonKeys = null; } public CommandWrapper(final Long officeId, final Long groupId, final Long clientId, final Long loanId, final Long savingsId, final String actionName, final String entityName, final Long entityId, final Long subentityId, final String href, final String json, final String transactionId, final Long productId, final Long templateId, final Long creditBureauId, - final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId) { + final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId, + final Set sanitizeJsonKeys) { this.commandId = null; this.officeId = officeId; @@ -120,12 +124,14 @@ public class CommandWrapper { this.jobName = jobName; this.idempotencyKey = idempotencyKey; this.loanExternalId = loanExternalId; + this.sanitizeJsonKeys = sanitizeJsonKeys; } private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId, final Long subresourceId, final String resourceGetUrl, final Long productId, final Long officeId, final Long groupId, final Long clientId, final Long loanId, final Long savingsId, final String transactionId, final Long creditBureauId, - final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId) { + final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId, + final Set sanitizeJsonKeys) { this.commandId = commandId; this.officeId = officeId; @@ -147,6 +153,7 @@ public class CommandWrapper { this.jobName = null; this.idempotencyKey = idempotencyKey; this.loanExternalId = loanExternalId; + this.sanitizeJsonKeys = sanitizeJsonKeys; } public boolean isCreate() { @@ -200,21 +207,24 @@ public class CommandWrapper { return isnoteResource; } - public boolean isUpdateOfOwnUserDetails(final Long loggedInUserId) { - return isUserResource() && isUpdate() && loggedInUserId.equals(this.entityId); + public boolean isChangeOfOwnUserDetails(final Long loggedInUserId) { + return isUserResource() && loggedInUserId.equals(this.entityId) && (isUpdate() || isChangePasswordOperation()); } public boolean isUpdate() { - // permissions resource has special update which involves no resource. - return (isPermissionResource() && isUpdateOperation()) || (isCurrencyResource() && isUpdateOperation()) - || (isCacheResource() && isUpdateOperation()) || (isWorkingDaysResource() && isUpdateOperation()) - || (isPasswordPreferencesResource() && isUpdateOperation()) || (isUpdateOperation() && (this.entityId != null)); + // some resources have special update which involves no resource identifier. + return isUpdateOperation() && (this.entityId != null || isPermissionResource() || isCurrencyResource() || isCacheResource() + || isWorkingDaysResource() || isPasswordPreferencesResource()); } public boolean isCacheResource() { return this.entityName.equalsIgnoreCase("CACHE"); } + public boolean isChangePasswordOperation() { + return this.actionName.equalsIgnoreCase("CHANGEPWD"); + } + public boolean isUpdateOperation() { return this.actionName.equalsIgnoreCase("UPDATE"); } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index cabada788..2265e47da 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -20,6 +20,9 @@ package org.apache.fineract.commands.service; import static org.apache.fineract.commands.domain.CommandProcessingResultType.UNDER_PROCESSING; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.fineract.batch.exception.ErrorInfo; import org.apache.fineract.commands.domain.CommandSource; @@ -28,10 +31,13 @@ import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException; import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.useradministration.domain.AppUser; import org.jetbrains.annotations.NotNull; import org.springframework.orm.jpa.JpaSystemException; @@ -49,8 +55,13 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class CommandSourceService { + public static final String COMMAND_MASK_VALUE = "***"; + public static final String COMMAND_SANITIZE_ALL = "SANITIZE_ALL"; + + private final ConfigurationDomainService configurationDomainService; private final CommandSourceRepository commandSourceRepository; private final ErrorHandler errorHandler; + private final FromJsonHelper fromApiJsonHelper; @NotNull @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ) @@ -110,7 +121,8 @@ public class CommandSourceService { public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) { CommandSource commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey, - UNDER_PROCESSING.getValue()); + UNDER_PROCESSING.getValue(), false); + sanitizeJson(commandSourceResult, wrapper.getSanitizeJsonKeys()); if (commandSourceResult.getCommandAsJson() == null) { commandSourceResult.setCommandAsJson("{}"); } @@ -119,13 +131,51 @@ public class CommandSourceService { @Transactional public CommandProcessingResult processCommand(NewCommandSourceHandler handler, JsonCommand command, CommandSource commandSource, - AppUser user, boolean isApprovedByChecker, boolean isMakerChecker) { + AppUser user, boolean isApprovedByChecker) { final CommandProcessingResult result = handler.processCommand(command); - boolean isRollback = !isApprovedByChecker && !user.isCheckerSuperUser() && (isMakerChecker || result.isRollbackTransaction()); - if (isRollback) { - commandSource.markAsAwaitingApproval(); - throw new RollbackTransactionNotApprovedException(commandSource.getId(), commandSource.getResourceId()); + + String permission = commandSource.getPermissionCode(); + boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); + if (isMakerChecker || result.isRollbackTransaction()) { + if (isApprovedByChecker || user.isCheckerSuperUser()) { + commandSource.markAsChecked(user); + } else { + if (commandSource.isSanitized()) { + throw new GeneralPlatformDomainRuleException("error.msg.invalid.sanitization", + "Maker-checker command can not be sanitized, please change the permission configuration", permission); + } + commandSource.markAsAwaitingApproval(); + throw new RollbackTransactionNotApprovedException(commandSource.getId(), commandSource.getResourceId()); + } } return result; } + + private void sanitizeJson(@NotNull CommandSource commandSource, Set sanitizeKeys) { + if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { + return; + } + String commandAsJson = commandSource.getCommandAsJson(); + if (commandAsJson == null || commandAsJson.isEmpty()) { + return; + } + final JsonElement parsedCommand = this.fromApiJsonHelper.parse(commandAsJson); + if (!parsedCommand.isJsonObject()) { + return; + } + String sanitizedJson; + if (sanitizeKeys.contains(COMMAND_SANITIZE_ALL)) { + sanitizedJson = ""; + } else { + JsonObject jsonObject = parsedCommand.getAsJsonObject(); + for (String key : sanitizeKeys) { + if (jsonObject.has(key)) { + jsonObject.addProperty(key, COMMAND_MASK_VALUE); + } + } + sanitizedJson = jsonObject.toString(); + } + commandSource.setCommandAsJson(sanitizedJson); + commandSource.setSanitized(true); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index d50e16a1e..1d5b01451 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -18,7 +18,13 @@ */ package org.apache.fineract.commands.service; +import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD; +import static org.apache.fineract.useradministration.service.AppUserConstants.REPEAT_PASSWORD; + import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.infrastructure.accountnumberformat.service.AccountNumberFormatConstants; import org.apache.fineract.infrastructure.core.domain.ExternalId; @@ -49,18 +55,21 @@ public class CommandWrapperBuilder { private String jobName; private String idempotencyKey; private ExternalId loanExternalId; + private Set sanitizeJsonKeys; @SuppressFBWarnings(value = "UWF_UNWRITTEN_FIELD", justification = "TODO: fix this!") public CommandWrapper build() { return new CommandWrapper(this.officeId, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, this.templateId, - this.creditBureauId, this.organisationCreditBureauId, this.jobName, this.idempotencyKey, this.loanExternalId); + this.creditBureauId, this.organisationCreditBureauId, this.jobName, this.idempotencyKey, this.loanExternalId, + this.sanitizeJsonKeys); } public CommandWrapper build(String idempotencyKey) { return new CommandWrapper(this.officeId, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName, this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, this.templateId, - this.creditBureauId, this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId); + this.creditBureauId, this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId, + this.sanitizeJsonKeys); } public CommandWrapperBuilder updateCreditBureau() { @@ -264,6 +273,16 @@ public class CommandWrapperBuilder { this.entityName = "USER"; this.entityId = null; this.href = "/users/template"; + this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); + return this; + } + + public CommandWrapperBuilder changeUserPassword(final Long userId) { + this.actionName = "CHANGEPWD"; + this.entityName = "USER"; + this.entityId = userId; + this.href = "/users/" + userId + "/pwd"; + this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); return this; } @@ -272,6 +291,7 @@ public class CommandWrapperBuilder { this.entityName = "USER"; this.entityId = userId; this.href = "/users/" + userId; + this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); return this; } diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index dd2d8c81b..5406ac5b4 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -55,7 +55,7 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio boolean isApprovedByChecker = false; // check if is update of own account details - if (wrapper.isUpdateOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) { + if (wrapper.isChangeOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) { // then allow this operation to proceed. // maker checker doesnt mean anything here. isApprovedByChecker = true; // set to true in case permissions have @@ -117,7 +117,7 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio private CommandSource validateMakerCheckerTransaction(final Long makerCheckerId) { final CommandSource commandSource = this.commandSourceRepository.findById(makerCheckerId) .orElseThrow(() -> new CommandNotFoundException(makerCheckerId)); - if (!commandSource.isMarkedAsAwaitingApproval()) { + if (!commandSource.isAwaitingApproval()) { throw new CommandNotAwaitingApprovalException(makerCheckerId); } AppUser appUser = this.context.authenticatedUser(); diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java index f021cb23c..444dcd52d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java @@ -117,16 +117,11 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer storeCommandIdInContext(commandSource); // Store command id as a request attribute } - boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(wrapper.taskPermissionName()); - if (isApprovedByChecker || (isMakerChecker && user.isCheckerSuperUser())) { - commandSource.markAsChecked(user); - } setIdempotencyKeyStoreFlag(true); final CommandProcessingResult result; try { - result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker, - isMakerChecker); + result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker); } catch (Throwable t) { // NOSONAR RuntimeException mappable = ErrorHandler.getMappable(t); ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable); @@ -134,7 +129,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer commandSource.setResultStatusCode(statusCode); commandSource.setResult(errorInfo.getMessage()); if (statusCode != SC_OK) { - commandSource.setStatus(ERROR.getValue()); + commandSource.setStatus(ERROR); } if (!isEnclosingTransaction) { // TODO: temporary solution commandSource = commandSourceService.saveResultNewTransaction(commandSource); @@ -148,7 +143,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer commandSource.setResultStatusCode(SC_OK); commandSource.updateForAudit(result); commandSource.setResult(toApiResultJsonSerializer.serializeResult(result)); - commandSource.setStatus(PROCESSED.getValue()); + commandSource.setStatus(PROCESSED); commandSource = commandSourceService.saveResultSameTransaction(commandSource); storeCommandIdInContext(commandSource); // Store command id as a request attribute diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java index 55c791964..f2a5060f3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.useradministration.domain; +import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD; + import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -195,8 +197,27 @@ public class AppUser extends AbstractPersistableCustom implements Platform return organisationalRole; } + public Map changePassword(final JsonCommand command, final PlatformPasswordEncoder platformPasswordEncoder) { + // unencoded password provided + final Map actualChanges = new LinkedHashMap<>(1); + updatePassword(command, platformPasswordEncoder, actualChanges); + return actualChanges; + } + + private void updatePassword(JsonCommand command, PlatformPasswordEncoder platformPasswordEncoder, Map actualChanges) { + final String passwordParamName = PASSWORD; + if (command.hasParameter(passwordParamName)) { + if (command.isChangeInPasswordParameterNamed(passwordParamName, this.password, platformPasswordEncoder, getId())) { + final String passwordEncodedValue = command.passwordValueOfParameterNamed(passwordParamName, platformPasswordEncoder, + getId()); + actualChanges.put(passwordParamName, true); + updatePassword(passwordEncodedValue); + } + } + } + public void updatePassword(final String encodePassword) { - if (cannotChangePassword != null && cannotChangePassword == true) { + if (Boolean.TRUE.equals(cannotChangePassword)) { throw new NoAuthorizationException("Password of this user may not be modified"); } @@ -223,27 +244,10 @@ public class AppUser extends AbstractPersistableCustom implements Platform public Map update(final JsonCommand command, final PlatformPasswordEncoder platformPasswordEncoder, final Collection clients) { - final Map actualChanges = new LinkedHashMap<>(7); // unencoded password provided - final String passwordParamName = "password"; - final String passwordEncodedParamName = "passwordEncoded"; - if (command.hasParameter(passwordParamName)) { - if (command.isChangeInPasswordParameterNamed(passwordParamName, this.password, platformPasswordEncoder, getId())) { - final String passwordEncodedValue = command.passwordValueOfParameterNamed(passwordParamName, platformPasswordEncoder, - getId()); - updatePassword(passwordEncodedValue); - } - } - - if (command.hasParameter(passwordEncodedParamName)) { - if (command.isChangeInStringParameterNamed(passwordEncodedParamName, this.password)) { - final String newValue = command.stringValueOfParameterNamed(passwordEncodedParamName); - updatePassword(newValue); - } - } - + updatePassword(command, platformPasswordEncoder, actualChanges); final String officeIdParamName = "officeId"; if (command.isChangeInLongParameterNamed(officeIdParamName, this.office.getId())) { final Long newValue = command.longValueOfParameterNamed(officeIdParamName); diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java index 25fbeb265..47597e797 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java @@ -24,6 +24,8 @@ public final class AppUserConstants { } + public static final String PASSWORD = "password"; + public static final String REPEAT_PASSWORD = "repeatPassword"; public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires"; public static final String IS_SELF_SERVICE_USER = "isSelfServiceUser"; public static final String CLIENTS = "clients"; diff --git a/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/exception/IdempotencyCommandProcessFailedExceptionTest.java b/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/exception/IdempotencyCommandProcessFailedExceptionTest.java index d246f833b..18a7ad192 100644 --- a/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/exception/IdempotencyCommandProcessFailedExceptionTest.java +++ b/fineract-core/src/test/java/org/apache/fineract/infrastructure/core/exception/IdempotencyCommandProcessFailedExceptionTest.java @@ -58,8 +58,8 @@ public class IdempotencyCommandProcessFailedExceptionTest { public void testInconsistentStatus() { IdempotentCommandExceptionMapper mapper = new IdempotentCommandExceptionMapper(); CommandWrapper command = new CommandWrapper(null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null); - CommandSource source = CommandSource.fullEntryFrom(command, JsonCommand.from("{}"), null, "dummy-key", null); + null, null, null, null, null, null); + CommandSource source = CommandSource.fullEntryFrom(command, JsonCommand.from("{}"), null, "dummy-key", null, false); IdempotentCommandProcessFailedException exception = new IdempotentCommandProcessFailedException(command, null, source); Response result = mapper.toResponse(exception); assertEquals(500, result.getStatus()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/center/CenterImportHandler.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/center/CenterImportHandler.java index 40bfb22d8..d6a49f81a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/center/CenterImportHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/center/CenterImportHandler.java @@ -268,7 +268,7 @@ public class CenterImportHandler implements ImportHandler { String payload = gsonBuilder.create().toJson(calendarData); CommandWrapper commandWrapper = new CommandWrapper(result.getOfficeId(), result.getGroupId(), result.getClientId(), result.getLoanId(), result.getSavingsId(), null, null, null, null, null, payload, result.getTransactionId(), - result.getProductId(), null, null, null, null, idempotencyKeyGenerator.create(), null); + result.getProductId(), null, null, null, null, idempotencyKeyGenerator.create(), null, null); final CommandWrapper commandRequest = new CommandWrapperBuilder() // .createCalendar(commandWrapper, TemplatePopulateImportConstants.CENTER_ENTITY_TYPE, result.getGroupId()) // .withJson(payload) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/group/GroupImportHandler.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/group/GroupImportHandler.java index 117ac604b..85cccce8a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/group/GroupImportHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/bulkimport/importhandler/group/GroupImportHandler.java @@ -252,7 +252,7 @@ public class GroupImportHandler implements ImportHandler { CommandWrapper commandWrapper = new CommandWrapper(result.getOfficeId(), result.getGroupId(), result.getClientId(), result.getLoanId(), result.getSavingsId(), null, null, null, null, null, payload, result.getTransactionId(), - result.getProductId(), null, null, null, null, idempotencyKeyGenerator.create(), null); + result.getProductId(), null, null, null, null, idempotencyKeyGenerator.create(), null, null); final CommandWrapper commandRequest = new CommandWrapperBuilder() // .createCalendar(commandWrapper, TemplatePopulateImportConstants.CENTER_ENTITY_TYPE, result.getGroupId()) // .withJson(payload) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/interoperation/api/InteropWrapperBuilder.java b/fineract-provider/src/main/java/org/apache/fineract/interoperation/api/InteropWrapperBuilder.java index 6556b50c2..dac41655e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/interoperation/api/InteropWrapperBuilder.java +++ b/fineract-provider/src/main/java/org/apache/fineract/interoperation/api/InteropWrapperBuilder.java @@ -37,7 +37,7 @@ public class InteropWrapperBuilder { public CommandWrapper build() { return new CommandWrapper(null, null, null, null, null, actionName, entityName, null, null, href, json, null, null, null, null, - null, null, null, null); + null, null, null, null, null); } public InteropWrapperBuilder withJson(final String json) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java index b85246cb7..794a95eca 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java @@ -171,7 +171,7 @@ public class UsersApiResource { @PUT @Path("{userId}") - @Operation(summary = "Update a User", description = "When updating a password you must provide the repeatPassword parameter also.") + @Operation(summary = "Update a User", description = "Updates the user") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PutUsersUserIdRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PutUsersUserIdResponse.class))) }) @@ -190,6 +190,27 @@ public class UsersApiResource { return this.toApiJsonSerializer.serialize(result); } + @POST + @Path("{userId}/pwd") + @Operation(summary = "Change the password of a User", description = "When updating a password you must provide the repeatPassword parameter also.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.ChangePwdUsersUserIdRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.ChangePwdUsersUserIdResponse.class))) }) + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + public String changePassword(@PathParam("userId") @Parameter(description = "userId") final Long userId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder() // + .changeUserPassword(userId) // + .withJson(apiRequestBodyAsJson) // + .build(); + + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + + return this.toApiJsonSerializer.serialize(result); + } + @DELETE @Path("{userId}") @Operation(summary = "Delete a User", description = "Removes the user and the associated roles and permissions.") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java index 11b58fcd9..c3181a86d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java @@ -151,6 +151,43 @@ final class UsersApiResourceSwagger { public Long resourceId; } + @Schema(description = "ChangePwdUsersUserIdRequest") + public static final class ChangePwdUsersUserIdRequest { + + private ChangePwdUsersUserIdRequest() { + + } + + @Schema(example = "password") + public String password; + @Schema(example = "repeatPassword") + public String repeatPassword; + } + + @Schema(description = "ChangePwdUsersUserIdResponse") + public static final class ChangePwdUsersUserIdResponse { + + private ChangePwdUsersUserIdResponse() { + + } + + static final class ChangePwdUsersUserIdResponseChanges { + + private ChangePwdUsersUserIdResponseChanges() { + + } + + @Schema(example = "true") + public boolean password; + } + + @Schema(example = "1") + public Long officeId; + @Schema(example = "11") + public Long resourceId; + public ChangePwdUsersUserIdResponseChanges changes; + } + @Schema(description = "PutUsersUserIdRequest") public static final class PutUsersUserIdRequest { diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/ChangeUserPasswordCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/ChangeUserPasswordCommandHandler.java new file mode 100644 index 000000000..ee4c8b113 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/ChangeUserPasswordCommandHandler.java @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.useradministration.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.useradministration.service.AppUserWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "USER", action = "CHANGEPWD") +public class ChangeUserPasswordCommandHandler implements NewCommandSourceHandler { + + private final AppUserWritePlatformService writePlatformService; + + @Autowired + public ChangeUserPasswordCommandHandler(final AppUserWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + final Long userId = command.entityId(); + return this.writePlatformService.changeUserPassword(userId, command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformService.java index 1e0d7f5f5..06d0437ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformService.java @@ -25,6 +25,8 @@ public interface AppUserWritePlatformService { CommandProcessingResult createUser(JsonCommand command); + CommandProcessingResult changeUserPassword(Long userId, JsonCommand command); + CommandProcessingResult updateUser(Long userId, JsonCommand command); CommandProcessingResult deleteUser(Long userId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java index 6805f90ea..053f47f5e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java @@ -154,6 +154,36 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit } } + @Override + @Transactional + @Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) }) + public CommandProcessingResult changeUserPassword(final Long userId, final JsonCommand command) { + try { + this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build()); + this.fromApiJsonDeserializer.validateForChangePassword(command.json(), this.context.authenticatedUser()); + final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId)); + final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command); + final Map changes = userToUpdate.changePassword(command, this.platformPasswordEncoder); + if (!changes.isEmpty()) { + this.appUserRepository.saveAndFlush(userToUpdate); + if (currentPasswordToSaveAsPreview != null) { + this.appUserPreviewPasswordRepository.save(currentPasswordToSaveAsPreview); + } + } + return new CommandProcessingResultBuilder() // + .withEntityId(userId) // + .withOfficeId(userToUpdate.getOffice().getId()) // + .with(changes) // + .build(); + } catch (final DataIntegrityViolationException dve) { + throw handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve); + } catch (final JpaSystemException | PersistenceException | AuthenticationServiceException dve) { + log.error("changeUserPassword: JpaSystemException | PersistenceException | AuthenticationServiceException", dve); + Throwable throwable = ExceptionUtils.getRootCause(dve.getCause()); + throw handleDataIntegrityIssues(command, throwable, dve); + } + } + @Override @Transactional @Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java index 03575747b..932341fd0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java @@ -19,6 +19,8 @@ package org.apache.fineract.useradministration.service; import static org.apache.fineract.useradministration.service.AppUserConstants.CLIENTS; +import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD; +import static org.apache.fineract.useradministration.service.AppUserConstants.REPEAT_PASSWORD; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -49,8 +51,6 @@ public final class UserDataValidator { public static final String USERNAME = "username"; public static final String FIRSTNAME = "firstname"; public static final String LASTNAME = "lastname"; - public static final String PASSWORD = "password"; - public static final String REPEAT_PASSWORD = "repeatPassword"; public static final String EMAIL = "email"; public static final String OFFICE_ID = "officeId"; public static final String NOT_SELECTED_ROLES = "notSelectedRoles"; @@ -61,9 +61,13 @@ public final class UserDataValidator { /** * The parameters supported for this command. */ - private static final Set SUPPORTED_PARAMETERS = new HashSet<>( + private static final Set CREATE_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + private static final Set UPDATE_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, + SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + private static final Set CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire"; private final FromJsonHelper fromApiJsonHelper; @@ -84,7 +88,7 @@ public final class UserDataValidator { final Type typeOfMap = new TypeToken>() { }.getType(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CREATE_SUPPORTED_PARAMETERS); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("user"); @@ -106,16 +110,7 @@ public final class UserDataValidator { final String email = this.fromApiJsonHelper.extractStringNamed(EMAIL, element); baseDataValidator.reset().parameter(EMAIL).value(email).notBlank().notExceedingLengthOf(100); } else { - final String password = this.fromApiJsonHelper.extractStringNamed(PASSWORD, element); - final String repeatPassword = this.fromApiJsonHelper.extractStringNamed(REPEAT_PASSWORD, element); - final PasswordValidationPolicy validationPolicy = this.passwordValidationPolicy.findActivePasswordValidationPolicy(); - final String regex = validationPolicy.getRegex(); - final String description = validationPolicy.getDescription(); - baseDataValidator.reset().parameter(PASSWORD).value(password).matchesRegularExpression(regex, description); - - if (StringUtils.isNotBlank(password)) { - baseDataValidator.reset().parameter(PASSWORD).value(password).equalToParameter(REPEAT_PASSWORD, repeatPassword); - } + validatePassword(baseDataValidator, element); } } else { baseDataValidator.reset().parameter(SEND_PASSWORD_TO_EMAIL).value(sendPasswordToEmail).trueOrFalseRequired(false); @@ -196,6 +191,27 @@ public final class UserDataValidator { } } + public void validateForChangePassword(final String json, AppUser authenticatedUser) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() { + + }.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CHANGE_PASSWORD_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("user"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + validatePassword(baseDataValidator, element); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + validateFieldLevelACL(json, authenticatedUser); + } + public void validateForUpdate(final String json, AppUser authenticatedUser) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); @@ -204,7 +220,7 @@ public final class UserDataValidator { final Type typeOfMap = new TypeToken>() { }.getType(); - this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UPDATE_SUPPORTED_PARAMETERS); final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("user"); @@ -247,17 +263,7 @@ public final class UserDataValidator { } if (this.fromApiJsonHelper.parameterExists(PASSWORD, element)) { - final String password = this.fromApiJsonHelper.extractStringNamed(PASSWORD, element); - final String repeatPassword = this.fromApiJsonHelper.extractStringNamed(REPEAT_PASSWORD, element); - - final PasswordValidationPolicy validationPolicy = this.passwordValidationPolicy.findActivePasswordValidationPolicy(); - final String regex = validationPolicy.getRegex(); - final String description = validationPolicy.getDescription(); - baseDataValidator.reset().parameter(PASSWORD).value(password).matchesRegularExpression(regex, description); - - if (StringUtils.isNotBlank(password)) { - baseDataValidator.reset().parameter(PASSWORD).value(password).equalToParameter(REPEAT_PASSWORD, repeatPassword); - } + validatePassword(baseDataValidator, element); } if (this.fromApiJsonHelper.parameterExists(PASSWORD_NEVER_EXPIRE, element)) { @@ -291,4 +297,18 @@ public final class UserDataValidator { throwExceptionIfValidationWarningsExist(dataValidationErrors); validateFieldLevelACL(json, authenticatedUser); } + + private void validatePassword(DataValidatorBuilder baseDataValidator, JsonElement element) { + final String password = this.fromApiJsonHelper.extractStringNamed(PASSWORD, element); + final String repeatPassword = this.fromApiJsonHelper.extractStringNamed(REPEAT_PASSWORD, element); + + final PasswordValidationPolicy validationPolicy = this.passwordValidationPolicy.findActivePasswordValidationPolicy(); + final String regex = validationPolicy.getRegex(); + final String description = validationPolicy.getDescription(); + DataValidatorBuilder validator = baseDataValidator.reset().parameter(PASSWORD).value(password).matchesRegularExpression(regex, + description); + if (StringUtils.isNotBlank(password)) { + validator.equalToParameter(REPEAT_PASSWORD, repeatPassword); + } + } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 80abde2c2..e677629f1 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -191,4 +191,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0169_add_missing_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0169_add_missing_permissions.xml index 4133fdf56..83ef0a771 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0169_add_missing_permissions.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0169_add_missing_permissions.xml @@ -89,4 +89,13 @@ + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0173_user_change_pwd.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0173_user_change_pwd.xml new file mode 100644 index 000000000..5897a43c2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0173_user_change_pwd.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT mrp.role_id, mp_changepwd.id + FROM m_role_permission mrp + LEFT JOIN m_permission mp_update ON mp_update.id = mrp.permission_id + LEFT JOIN m_permission mp_changepwd ON mp_changepwd.action_name = 'CHANGEPWD' + AND mp_changepwd.entity_name = 'USER' + WHERE mp_update.action_name = 'UPDATE' + AND mp_update.entity_name = 'USER'; + + + diff --git a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm index ebdf1b92f..028496e87 100644 --- a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm +++ b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm @@ -39423,6 +39423,40 @@ Request body: +   +
+
+

Change the password of a User

+

+ Note: When updating a password you must provide the + repeatPassword parameter also. +

+
+
+ +POST https://DomainName/api/v1/users/{userId)/pwd + + +POST users/3/pwd +Content-Type: application/json +Request body: +{ + "password": "window75", + "repeatPassword": "window75" +} + + +{ + "officeId": 1, + "resourceId": 3, + "changes": { + "passwordEncoded": "abc3326b1bb376351c7baeb4175f5e0504e33aadf6a158474a6d71de1befae51" + } +} + +
+
+  
@@ -39451,8 +39485,7 @@ Request body: "officeId": 1, "resourceId": 3, "changes": { - "firstname": "Test", - "passwordEncoded": "abc3326b1bb376351c7baeb4175f5e0504e33aadf6a158474a6d71de1befae51" + "firstname": "Test" } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandServiceStepDefinitions.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandServiceStepDefinitions.java index 9e494ff66..49ecfc95c 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandServiceStepDefinitions.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandServiceStepDefinitions.java @@ -101,7 +101,7 @@ public class CommandServiceStepDefinitions implements En { public DummyCommand() { super(null, null, null, null, null, null, null, null, null, null, "{}", null, null, null, null, null, null, - UUID.randomUUID().toString(), null); + UUID.randomUUID().toString(), null, null); } @Override diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index c38ee5c3a..31c90c9e9 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -29,6 +29,7 @@ import org.apache.fineract.commands.domain.CommandSource; import org.apache.fineract.commands.domain.CommandSourceRepository; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.infrastructure.codes.exception.CodeNotFoundException; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; @@ -48,6 +49,9 @@ import org.mockito.MockitoAnnotations; @SuppressFBWarnings(value = "RV_EXCEPTION_NOT_THROWN", justification = "False positive") public class CommandSourceServiceTest { + @Mock + private ConfigurationDomainService configurationDomainService; + @Mock private CommandSourceRepository commandSourceRepository; diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java index 2e3af19d9..4b13d8a90 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java @@ -77,7 +77,7 @@ public class IdempotencyKeyResolverTest { public void testIPKResolveFromWrapper() { String idk = "idk"; CommandWrapper wrapper = new CommandWrapper(null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, idk, null); + null, null, null, idk, null, null); String resolvedIdk = underTest.resolve(wrapper); Assertions.assertEquals(idk, resolvedIdk); } diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java index ed4f42366..728fd70d5 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java @@ -117,7 +117,7 @@ public class SynchronousCommandProcessingServiceTest { when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue()); when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser); - when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false, false)) + when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false)) .thenReturn(commandProcessingResult); CommandProcessingResult actualCommandProcessingResult = underTest.executeCommand(commandWrapper, jsonCommand, false); @@ -163,8 +163,7 @@ public class SynchronousCommandProcessingServiceTest { when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(initialCommandSource); - when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false, false)) - .thenThrow(runtimeException); + when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false)).thenThrow(runtimeException); assertThrows(RuntimeException.class, () -> { underTest.executeCommand(commandWrapper, jsonCommand, false); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/UserAdministrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/UserAdministrationTest.java index 22758aa03..2db29dcf2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/UserAdministrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/UserAdministrationTest.java @@ -28,6 +28,8 @@ import io.restassured.specification.ResponseSpecification; import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.apache.fineract.client.models.ChangePwdUsersUserIdRequest; +import org.apache.fineract.client.models.ChangePwdUsersUserIdResponse; import org.apache.fineract.client.models.GetOfficesResponse; import org.apache.fineract.client.models.GetUsersUserIdResponse; import org.apache.fineract.client.models.PostUsersRequest; @@ -183,7 +185,7 @@ public class UserAdministrationTest extends IntegrationTest { } @Test - public void testApplicationUserCanChangeOwnPassword() { + public void testApplicationUserCanUpdateOwnPassword() { // Admin creates a new user with an empty role Integer roleId = RolesHelper.createRole(requestSpec, responseSpec); String originalPassword = "QwE!5rTy#9uP0"; @@ -215,6 +217,40 @@ public class UserAdministrationTest extends IntegrationTest { GetUsersUserIdResponse ok = ok(newFineractClient(simpleUsername, updatedPassword).users.retrieveOne31(userId)); } + @Test + public void testApplicationUserCanChangeOwnPassword() { + // Admin creates a new user with an empty role + Integer roleId = RolesHelper.createRole(requestSpec, responseSpec); + String originalPassword = "QwE!5rTy#9uP0"; + String simpleUsername = Utils.uniqueRandomStringGenerator("NotificationUser", 4); + GetOfficesResponse headOffice = OfficeHelper.getHeadOffice(requestSpec, responseSpec); + PostUsersRequest createUserRequest = new PostUsersRequest().username(simpleUsername) + .firstname(Utils.randomStringGenerator("NotificationFN", 4)).lastname(Utils.randomStringGenerator("NotificationLN", 4)) + .email("whatever@mifos.org").password(originalPassword).repeatPassword(originalPassword).sendPasswordToEmail(false) + .officeId(headOffice.getId()).roles(List.of(Long.valueOf(roleId))); + + PostUsersResponse userCreationResponse = UserHelper.createUser(requestSpec, responseSpec, createUserRequest); + Long userId = userCreationResponse.getResourceId(); + Assertions.assertNotNull(userId); + + // User changes its own password + + String updatedPassword = "pX268-4Pfv|kF6"; + ChangePwdUsersUserIdResponse changePwdUsersUserIdResponse = ok(newFineractClient(simpleUsername, originalPassword).users + .changePassword(userId, new ChangePwdUsersUserIdRequest().password(updatedPassword).repeatPassword(updatedPassword))); + Assertions.assertNotNull(changePwdUsersUserIdResponse.getResourceId()); + + // From then on the originalPassword is not working anymore + CallFailedRuntimeException callFailedRuntimeException = Assertions.assertThrows(CallFailedRuntimeException.class, () -> { + ok(newFineractClient(simpleUsername, originalPassword).users.retrieveOne31(userId)); + }); + Assertions.assertEquals(401, callFailedRuntimeException.getResponse().raw().code()); + Assertions.assertTrue(callFailedRuntimeException.getMessage().contains("Unauthorized")); + + // The update password is still working perfectly + GetUsersUserIdResponse ok = ok(newFineractClient(simpleUsername, updatedPassword).users.retrieveOne31(userId)); + } + @Test public void testApplicationUserShallNotBeAbleToChangeItsOwnRoles() { // Admin creates a new user with one role assigned