diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java index b41bf8a90..88ec4bc0d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java @@ -80,6 +80,7 @@ public final class GlobalConfigurationConstants { public static final String ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-for-external-asset-transfer"; public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer"; public static final String ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION = "enable-originator-creation-during-loan-application"; + public static final String PASSWORD_REUSE_CHECK_HISTORY_COUNT = "password-reuse-check-history-count"; private GlobalConfigurationConstants() {} } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index e7a98fb2c..83c21afd2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -151,4 +151,6 @@ public interface ConfigurationDomainService { boolean isImmediateChargeAccrualPostMaturityEnabled(); String getAssetOwnerTransferOustandingInterestStrategy(); + + Integer getPasswordReuseRestrictionCount(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index 26b48b2f0..94695f2f2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -548,4 +548,15 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return getGlobalConfigurationPropertyData( GlobalConfigurationConstants.ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY).getStringValue(); } + + @Override + public Integer getPasswordReuseRestrictionCount() { + final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData( + GlobalConfigurationConstants.PASSWORD_REUSE_CHECK_HISTORY_COUNT); + if (!property.isEnabled()) { + return null; + } + Long value = property.getValue(); + return value != null && value > 0 ? value.intValue() : 0; + } } 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 053f47f5e..9c5ec5c80 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 @@ -32,6 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; @@ -48,7 +49,6 @@ import org.apache.fineract.organisation.staff.domain.Staff; import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; -import org.apache.fineract.useradministration.api.AppUserApiConstant; import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUserPreviousPassword; import org.apache.fineract.useradministration.domain.AppUserPreviousPasswordRepository; @@ -83,6 +83,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit private final AppUserPreviousPasswordRepository appUserPreviewPasswordRepository; private final StaffRepositoryWrapper staffRepositoryWrapper; private final ClientRepositoryWrapper clientRepositoryWrapper; + private final ConfigurationDomainService configurationDomainService; @Override @Transactional @@ -269,12 +270,20 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit AppUserPreviousPassword currentPasswordToSaveAsPreview = null; if (passWordEncodedValue != null) { - PageRequest pageRequest = PageRequest.of(0, AppUserApiConstant.numberOfPreviousPasswords, Sort.Direction.DESC, "removalDate"); - final List nLastUsedPasswords = this.appUserPreviewPasswordRepository.findByUserId(user.getId(), - pageRequest); - for (AppUserPreviousPassword aPreviewPassword : nLastUsedPasswords) { - if (aPreviewPassword.getPassword().equals(passWordEncodedValue)) { - throw new PasswordPreviouslyUsedException(); + final Integer passwordReuseRestrictionCount = this.configurationDomainService.getPasswordReuseRestrictionCount(); + if (passwordReuseRestrictionCount != null) { + List previousPasswords; + if (passwordReuseRestrictionCount == 0) { + previousPasswords = this.appUserPreviewPasswordRepository.findByUserId(user.getId(), + PageRequest.of(0, Integer.MAX_VALUE, Sort.Direction.DESC, "removalDate")); + } else { + PageRequest pageRequest = PageRequest.of(0, passwordReuseRestrictionCount, Sort.Direction.DESC, "removalDate"); + previousPasswords = this.appUserPreviewPasswordRepository.findByUserId(user.getId(), pageRequest); + } + for (AppUserPreviousPassword aPreviewPassword : previousPasswords) { + if (aPreviewPassword.getPassword().equals(passWordEncodedValue)) { + throw new PasswordPreviouslyUsedException(); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java index f092ee0e3..9ef25cac8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.useradministration.starter; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.security.service.PlatformPasswordEncoder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -75,10 +76,10 @@ public class UserAdministrationConfiguration { PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository, OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer, AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper, - ClientRepositoryWrapper clientRepositoryWrapper) { + ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService) { return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository, officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper, - clientRepositoryWrapper); + clientRepositoryWrapper, configurationDomainService); } @Bean 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 34da1913f..55f8380e1 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 @@ -228,4 +228,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_password_reuse_check_history_count.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_password_reuse_check_history_count.xml new file mode 100644 index 000000000..3ade2313a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0210_add_configuration_password_reuse_check_history_count.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImplTest.java new file mode 100644 index 000000000..5accacc22 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImplTest.java @@ -0,0 +1,151 @@ +/** + * 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.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.fineract.commands.domain.CommandWrapper; +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.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.security.service.PlatformPasswordEncoder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserPreviousPassword; +import org.apache.fineract.useradministration.domain.AppUserPreviousPasswordRepository; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.apache.fineract.useradministration.domain.RoleRepository; +import org.apache.fineract.useradministration.domain.UserDomainService; +import org.apache.fineract.useradministration.exception.PasswordPreviouslyUsedException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +public class AppUserWritePlatformServiceJpaRepositoryImplTest { + + private static final Long USER_ID = 1L; + + @Mock + private PlatformSecurityContext context; + @Mock + private UserDomainService userDomainService; + @Mock + private PlatformPasswordEncoder platformPasswordEncoder; + @Mock + private AppUserRepository appUserRepository; + @Mock + private OfficeRepositoryWrapper officeRepositoryWrapper; + @Mock + private RoleRepository roleRepository; + @Mock + private UserDataValidator fromApiJsonDeserializer; + @Mock + private AppUserPreviousPasswordRepository appUserPreviewPasswordRepository; + @Mock + private StaffRepositoryWrapper staffRepositoryWrapper; + @Mock + private ClientRepositoryWrapper clientRepositoryWrapper; + @Mock + private ConfigurationDomainService configurationDomainService; + + @InjectMocks + private AppUserWritePlatformServiceJpaRepositoryImpl underTest; + + private JsonCommand command; + private AppUser user; + private AppUser authenticatedUser; + + @BeforeEach + void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + command = mock(JsonCommand.class); + user = mock(AppUser.class); + authenticatedUser = mock(AppUser.class); + + when(command.json()).thenReturn("{}"); + when(appUserRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(context.authenticatedUser(any(CommandWrapper.class))).thenReturn(authenticatedUser); + doNothing().when(fromApiJsonDeserializer).validateForChangePassword(anyString(), nullable(AppUser.class)); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + @Test + void changeUserPasswordThrowsWhenPasswordPreviouslyUsed() { + when(user.getId()).thenReturn(USER_ID); + when(user.getEncodedPassword(command, platformPasswordEncoder)).thenReturn("encoded"); + when(configurationDomainService.getPasswordReuseRestrictionCount()).thenReturn(2); + + AppUserPreviousPassword previousPassword = mock(AppUserPreviousPassword.class); + when(previousPassword.getPassword()).thenReturn("encoded"); + when(appUserPreviewPasswordRepository.findByUserId(eq(USER_ID), any(PageRequest.class))).thenReturn(List.of(previousPassword)); + + assertThrows(PasswordPreviouslyUsedException.class, () -> underTest.changeUserPassword(USER_ID, command)); + + verify(appUserRepository, never()).saveAndFlush(user); + verify(appUserPreviewPasswordRepository, never()).save(any(AppUserPreviousPassword.class)); + } + + @Test + void changeUserPasswordSavesPreviousPasswordWhenAllowed() { + Office office = mock(Office.class); + when(office.getId()).thenReturn(7L); + when(user.getOffice()).thenReturn(office); + when(user.getId()).thenReturn(USER_ID); + when(user.getPassword()).thenReturn("currentEncoded"); + when(user.getEncodedPassword(command, platformPasswordEncoder)).thenReturn("newEncoded"); + when(user.changePassword(command, platformPasswordEncoder)).thenReturn(Map.of("password", "new")); + when(configurationDomainService.getPasswordReuseRestrictionCount()).thenReturn(2); + when(appUserPreviewPasswordRepository.findByUserId(eq(USER_ID), any(PageRequest.class))).thenReturn(List.of()); + + CommandProcessingResult result = underTest.changeUserPassword(USER_ID, command); + + assertEquals(USER_ID, result.getResourceId()); + verify(appUserRepository).saveAndFlush(user); + verify(appUserPreviewPasswordRepository).save(any(AppUserPreviousPassword.class)); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java index 10f3290aa..a92d47a1f 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java @@ -217,6 +217,13 @@ public class GlobalConfigurationHelper { forcePasswordResetDaysDefault.put("trapDoor", false); defaults.add(forcePasswordResetDaysDefault); + HashMap passwordReuseCheckHistoryCountDefault = new HashMap<>(); + passwordReuseCheckHistoryCountDefault.put("name", GlobalConfigurationConstants.PASSWORD_REUSE_CHECK_HISTORY_COUNT); + passwordReuseCheckHistoryCountDefault.put("value", 3L); + passwordReuseCheckHistoryCountDefault.put("enabled", false); + passwordReuseCheckHistoryCountDefault.put("trapDoor", false); + defaults.add(passwordReuseCheckHistoryCountDefault); + HashMap graceOnPenaltyPostingDefault = new HashMap<>(); graceOnPenaltyPostingDefault.put("name", GlobalConfigurationConstants.GRACE_ON_PENALTY_POSTING); graceOnPenaltyPostingDefault.put("value", 0L);