This is an automated email from the ASF dual-hosted git repository.

adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git


The following commit(s) were added to refs/heads/develop by this push:
     new 7daf4b8a84 FINERACT-2005: Prohibit password re-use with configurable 
global setting
7daf4b8a84 is described below

commit 7daf4b8a84f9ed126877f6008e2e5cf5f9868611
Author: airajena <[email protected]>
AuthorDate: Fri Jan 23 01:00:38 2026 +0530

    FINERACT-2005: Prohibit password re-use with configurable global setting
---
 .../api/GlobalConfigurationConstants.java          |   1 +
 .../domain/ConfigurationDomainService.java         |   2 +
 .../domain/ConfigurationDomainServiceJpa.java      |  11 ++
 ...pUserWritePlatformServiceJpaRepositoryImpl.java |  23 +++-
 .../starter/UserAdministrationConfiguration.java   |   5 +-
 .../db/changelog/tenant/changelog-tenant.xml       |   1 +
 ...guration_password_reuse_check_history_count.xml |  35 +++++
 ...rWritePlatformServiceJpaRepositoryImplTest.java | 151 +++++++++++++++++++++
 .../common/GlobalConfigurationHelper.java          |   7 +
 9 files changed, 227 insertions(+), 9 deletions(-)

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 b41bf8a90f..88ec4bc0dd 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 e7a98fb2cc..83c21afd2b 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 26b48b2f06..94695f2f20 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 053f47f5e2..9c5ec5c80d 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<AppUserPreviousPassword> 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<AppUserPreviousPassword> 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 f092ee0e38..9ef25cac86 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 34da1913f5..55f8380e1e 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 @@
     <include file="parts/0207_add_allow_full_term_for_tranche.xml" 
relativeToChangelogFile="true" />
     <include 
file="parts/0208_trial_balance_summary_with_asset_owner_journal_entry_aggregation_fix.xml"
 relativeToChangelogFile="true" />
     <include 
file="parts/0209_transaction_summary_with_asset_owner_and_from_asset_owner_id_for_asset_sales.xml"
 relativeToChangelogFile="true" />
+    <include 
file="parts/0210_add_configuration_password_reuse_check_history_count.xml" 
relativeToChangelogFile="true" />
 </databaseChangeLog>
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 0000000000..3ade2313a4
--- /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 @@
+<?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">
+        <insert tableName="c_configuration">
+            <column name="name" value="password-reuse-check-history-count"/>
+            <column name="value" valueNumeric="3"/>
+            <column name="date_value"/>
+            <column name="string_value"/>
+            <column name="enabled" valueBoolean="false"/>
+            <column name="is_trap_door" valueBoolean="false"/>
+            <column name="description" value="When enabled, prevents password 
reuse. The value specifies how many previous passwords to check (e.g., 3 = last 
3 passwords). Set to 0 to check ALL previous passwords. Disable this setting to 
allow password reuse."/>
+        </insert>
+    </changeSet>
+</databaseChangeLog>
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 0000000000..5accacc22c
--- /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 10f3290aa3..a92d47a1f3 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<String, Object> 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<String, Object> graceOnPenaltyPostingDefault = new HashMap<>();
         graceOnPenaltyPostingDefault.put("name", 
GlobalConfigurationConstants.GRACE_ON_PENALTY_POSTING);
         graceOnPenaltyPostingDefault.put("value", 0L);

Reply via email to