FINERACT-2185: User management improvement

This commit is contained in:
Marta Jankovics
2025-04-23 09:56:05 +02:00
committed by Adam Saghy
parent 8d83b5547c
commit 79c006774e
28 changed files with 506 additions and 95 deletions
@@ -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) //
@@ -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) //
@@ -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) {
@@ -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.")
@@ -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 {
@@ -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);
}
}
@@ -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);
@@ -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<String, Object> 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) })
@@ -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<String> SUPPORTED_PARAMETERS = new HashSet<>(
private static final Set<String> 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<String> 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<String> 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<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CREATE_SUPPORTED_PARAMETERS);
final List<ApiParameterError> 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<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, CHANGE_PASSWORD_SUPPORTED_PARAMETERS);
final List<ApiParameterError> 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<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UPDATE_SUPPORTED_PARAMETERS);
final List<ApiParameterError> 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);
}
}
}
@@ -191,4 +191,5 @@
<include file="parts/0170_days_in_year_custom_strategy.xml" relativeToChangelogFile="true"/>
<include file="parts/0171_loan_capitalized_income.xml" relativeToChangelogFile="true"/>
<include file="parts/0172_create_loan_capitalized_income_balance.xml" relativeToChangelogFile="true"/>
<include file="parts/0173_user_change_pwd.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -89,4 +89,13 @@
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="3">
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DEACTIVATEOVERDUE_LOANCHARGE"/>
<column name="entity_name" value="LOANCHARGE"/>
<column name="action_name" value="DEACTIVATEOVERDUE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_portfolio_command_source">
<column name="is_sanitized" type="boolean" defaultValueBoolean="false" >
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="2">
<insert tableName="m_permission">
<column name="grouping" value="authorisation"/>
<column name="code" value="CHANGEPWD_USER"/>
<column name="entity_name" value="USER"/>
<column name="action_name" value="CHANGEPWD"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="3">
<sql>
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';
</sql>
</changeSet>
</databaseChangeLog>
@@ -39423,6 +39423,40 @@ Request body:
</div>
</div>
<a id="users_changepwd" name="users_changepwd" class="old-syle-anchor">&nbsp;</a>
<div class="method-section">
<div class="method-description">
<h4>Change the password of a User</h4>
<p>
<b>Note:</b> When updating a password you must provide the
repeatPassword parameter also.
</p>
</div>
<div class="method-example">
<code class="method-declaration">
POST https://DomainName/api/v1/users/{userId)/pwd
</code>
<code class="method-request">
POST users/3/pwd
Content-Type: application/json
Request body:
{
"password": "window75",
"repeatPassword": "window75"
}
</code>
<code class="method-response">
{
"officeId": 1,
"resourceId": 3,
"changes": {
"passwordEncoded": "abc3326b1bb376351c7baeb4175f5e0504e33aadf6a158474a6d71de1befae51"
}
}
</code>
</div>
</div>
<a id="users_update" name="users_update" class="old-syle-anchor">&nbsp;</a>
<div class="method-section">
<div class="method-description">
@@ -39451,8 +39485,7 @@ Request body:
"officeId": 1,
"resourceId": 3,
"changes": {
"firstname": "Test",
"passwordEncoded": "abc3326b1bb376351c7baeb4175f5e0504e33aadf6a158474a6d71de1befae51"
"firstname": "Test"
}
}
</code>