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 ca3e980543 FINERACT-2003: Enforce password reset on first login
ca3e980543 is described below

commit ca3e98054321f72af2a5d3d9c8368454f452370b
Author: DeathGun44 <[email protected]>
AuthorDate: Tue Jan 27 17:18:11 2026 +0530

    FINERACT-2003: Enforce password reset on first login
    
    :wq
---
 .../api/GlobalConfigurationConstants.java          |   1 +
 .../domain/ConfigurationDomainService.java         |   2 +
 .../useradministration/domain/AppUser.java         |  11 ++
 .../domain/ConfigurationDomainServiceJpa.java      |   5 +
 .../infrastructure/core/config/SecurityConfig.java |   6 +-
 .../service/PlatformUserDetailsChecker.java        |  39 +++++++
 .../api/SelfAuthenticationApiResource.java         |   5 +-
 ...pUserWritePlatformServiceJpaRepositoryImpl.java |  17 ++-
 .../db/changelog/tenant/changelog-tenant.xml       |   1 +
 .../parts/0212_add_force_password_reset_config.xml |  42 +++++++
 .../security/api/AuthenticationApiResource.java    |   3 +
 .../exception/PasswordResetRequiredException.java  |  43 +++++++
 .../PasswordResetRequiredExceptionMapper.java      |  57 ++++++++++
 .../SpringSecurityPlatformSecurityContext.java     |  12 +-
 .../PasswordResetIntegrationTest.java              | 126 +++++++++++++++++++++
 .../common/GlobalConfigurationHelper.java          |   7 ++
 16 files changed, 367 insertions(+), 10 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 88ec4bc0dd..a9336214d1 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
@@ -81,6 +81,7 @@ public final class GlobalConfigurationConstants {
     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";
+    public static final String FORCE_PASSWORD_RESET_ON_FIRST_LOGIN = 
"force-password-reset-on-first-login";
 
     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 83c21afd2b..b2a897b465 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
@@ -153,4 +153,6 @@ public interface ConfigurationDomainService {
     String getAssetOwnerTransferOustandingInterestStrategy();
 
     Integer getPasswordReuseRestrictionCount();
+
+    boolean isForcePasswordResetOnFirstLoginEnabled();
 }
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 661564d52e..5d7ab23054 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
@@ -131,6 +131,17 @@ public class AppUser extends 
AbstractPersistableCustom<Long> implements Platform
     @Column(name = "cannot_change_password", nullable = true)
     private Boolean cannotChangePassword;
 
+    @Column(name = "password_reset_required", nullable = false)
+    private boolean passwordResetRequired;
+
+    public boolean isPasswordResetRequired() {
+        return this.passwordResetRequired;
+    }
+
+    public void updatePasswordResetRequired(final boolean required) {
+        this.passwordResetRequired = required;
+    }
+
     public static AppUser fromJson(final Office userOffice, final Staff 
linkedStaff, final Set<Role> allRoles,
             final Collection<Client> clients, final JsonCommand command) {
 
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 94695f2f20..e43ee3396f 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
@@ -559,4 +559,9 @@ public class ConfigurationDomainServiceJpa implements 
ConfigurationDomainService
         Long value = property.getValue();
         return value != null && value > 0 ? value.intValue() : 0;
     }
+
+    @Override
+    public boolean isForcePasswordResetOnFirstLoginEnabled() {
+        return 
getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN).isEnabled();
+    }
 }
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java
 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java
index fffee05b95..6168cfc631 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java
@@ -45,6 +45,7 @@ import 
org.apache.fineract.infrastructure.security.data.PlatformRequestLog;
 import 
org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
 import 
org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
 import 
org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
+import 
org.apache.fineract.infrastructure.security.service.PlatformUserDetailsChecker;
 import 
org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
 import org.apache.fineract.infrastructure.security.service.TwoFactorService;
 import org.apache.fineract.notification.service.UserNotificationService;
@@ -115,7 +116,9 @@ public class SecurityConfig {
     @Autowired
     private IdempotencyStoreHelper idempotencyStoreHelper;
     @Autowired
-    ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
+    private ProgressiveLoanModelCheckerFilter 
progressiveLoanModelCheckerFilter;
+    @Autowired
+    private PlatformUserDetailsChecker platformUserDetailsChecker;
 
     @Bean
     public SecurityFilterChain filterChain(HttpSecurity http) throws Exception 
{
@@ -418,6 +421,7 @@ public class SecurityConfig {
         DaoAuthenticationProvider authProvider = new 
DaoAuthenticationProvider();
         authProvider.setUserDetailsService(userDetailsService);
         authProvider.setPasswordEncoder(passwordEncoder());
+        authProvider.setPostAuthenticationChecks(platformUserDetailsChecker);
         return authProvider;
     }
 
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java
 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java
new file mode 100644
index 0000000000..f840da55f7
--- /dev/null
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java
@@ -0,0 +1,39 @@
+/**
+ * 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.infrastructure.security.service;
+
+import org.springframework.security.authentication.CredentialsExpiredException;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsChecker;
+import org.springframework.stereotype.Component;
+
+/**
+ * Checks user details during Spring Security authentication. Password reset 
enforcement is handled by
+ * SpringSecurityPlatformSecurityContext and AuthenticationApiResource after 
authentication succeeds.
+ */
+@Component
+public class PlatformUserDetailsChecker implements UserDetailsChecker {
+
+    @Override
+    public void check(UserDetails userDetails) {
+        if (!userDetails.isCredentialsNonExpired()) {
+            throw new CredentialsExpiredException("User credentials have 
expired");
+        }
+    }
+}
diff --git 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java
 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java
index bd277d6e83..3c41df7b50 100644
--- 
a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java
+++ 
b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java
@@ -23,6 +23,7 @@ import io.swagger.v3.oas.annotations.media.Content;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.parameters.RequestBody;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.ws.rs.Consumes;
 import jakarta.ws.rs.POST;
@@ -53,7 +54,9 @@ public class SelfAuthenticationApiResource {
     @Operation(summary = "Verify authentication", description = "Authenticates 
the credentials provided and returns the set roles and permissions allowed.\n\n"
             + "Please visit this link for more info - 
https://fineract.apache.org/docs/legacy/#selfbasicauth";)
     @RequestBody(required = true, content = @Content(schema = 
@Schema(implementation = 
AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
-    @ApiResponse(responseCode = "200", description = "OK", content = 
@Content(schema = @Schema(implementation = 
SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class)))
+    @ApiResponses({
+            @ApiResponse(responseCode = "200", description = "OK", content = 
@Content(schema = @Schema(implementation = 
SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class))),
+            @ApiResponse(responseCode = "403", description = "Password reset 
required") })
     public String authenticate(final String apiRequestBodyAsJson) {
         return 
this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true);
     }
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 9c5ec5c80d..ccf0e7b6e3 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
@@ -127,6 +127,9 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl 
implements AppUserWrit
             }
 
             AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, 
allRoles, clients, command);
+            if 
(this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
+                appUser.updatePasswordResetRequired(true);
+            }
 
             final Boolean sendPasswordToEmail = 
command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
             this.userDomainService.create(appUser, sendPasswordToEmail);
@@ -160,12 +163,14 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl 
implements AppUserWrit
     @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());
+            this.context.authenticatedUser(new 
CommandWrapperBuilder().changeUserPassword(userId).build());
+            
this.fromApiJsonDeserializer.validateForChangePassword(command.json(),
+                    this.context.authenticatedUser(new 
CommandWrapperBuilder().changeUserPassword(userId).build()));
             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()) {
+                userToUpdate.updatePasswordResetRequired(false);
                 this.appUserRepository.saveAndFlush(userToUpdate);
                 if (currentPasswordToSaveAsPreview != null) {
                     
this.appUserPreviewPasswordRepository.save(currentPasswordToSaveAsPreview);
@@ -190,9 +195,9 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl 
implements AppUserWrit
     @Caching(evict = { @CacheEvict(value = "users", allEntries = true), 
@CacheEvict(value = "usersByUsername", allEntries = true) })
     public CommandProcessingResult updateUser(final Long userId, final 
JsonCommand command) {
         try {
-            this.context.authenticatedUser(new 
CommandWrapperBuilder().updateUser(null).build());
+            final AppUser currentUser = this.context.authenticatedUser(new 
CommandWrapperBuilder().updateUser(null).build());
 
-            this.fromApiJsonDeserializer.validateForUpdate(command.json(), 
this.context.authenticatedUser());
+            this.fromApiJsonDeserializer.validateForUpdate(command.json(), 
currentUser);
 
             final AppUser userToUpdate = 
this.appUserRepository.findById(userId).orElseThrow(() -> new 
UserNotFoundException(userId));
 
@@ -238,6 +243,10 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl 
implements AppUserWrit
             }
 
             if (!changes.isEmpty()) {
+                if ((changes.containsKey("password") || 
changes.containsKey("passwordEncoded")) && !currentUser.getId().equals(userId)
+                        && 
this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
+                    userToUpdate.updatePasswordResetRequired(true);
+                }
                 this.appUserRepository.saveAndFlush(userToUpdate);
 
                 if (currentPasswordToSaveAsPreview != null) {
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 297426a0ac..81aa2fa1e7 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
@@ -230,4 +230,5 @@
     <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" />
     <include 
file="parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml" 
relativeToChangelogFile="true" />
+    <include file="parts/0212_add_force_password_reset_config.xml" 
relativeToChangelogFile="true" />
 </databaseChangeLog>
diff --git 
a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_force_password_reset_config.xml
 
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_force_password_reset_config.xml
new file mode 100644
index 0000000000..16354740ef
--- /dev/null
+++ 
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_force_password_reset_config.xml
@@ -0,0 +1,42 @@
+<?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.3.xsd";>
+    <changeSet author="fineract" id="1">
+        <addColumn tableName="m_appuser">
+            <column name="password_reset_required" type="boolean" 
defaultValueBoolean="false">
+                <constraints nullable="false"/>
+            </column>
+        </addColumn>
+    </changeSet>
+
+    <changeSet author="fineract" id="3">
+        <insert tableName="c_configuration">
+            <column name="name" value="force-password-reset-on-first-login"/>
+            <column name="value" valueNumeric="0"/>
+            <column name="enabled" valueBoolean="false"/>
+            <column name="is_trap_door" valueBoolean="false"/>
+            <column name="description" value="If enabled, users must reset 
their password upon first login or after an admin reset. Value is unused."/>
+        </insert>
+    </changeSet>
+</databaseChangeLog>
diff --git 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java
 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java
index cca62de456..81d8c3117c 100644
--- 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java
+++ 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java
@@ -43,6 +43,7 @@ import 
org.apache.fineract.infrastructure.core.data.EnumOptionData;
 import 
org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
 import 
org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
 import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
+import 
org.apache.fineract.infrastructure.security.exception.PasswordResetRequiredException;
 import 
org.apache.fineract.infrastructure.security.service.SpringSecurityPlatformSecurityContext;
 import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
 import org.apache.fineract.useradministration.data.RoleData;
@@ -86,6 +87,7 @@ public class AuthenticationApiResource {
     @RequestBody(required = true, content = @Content(schema = 
@Schema(implementation = 
AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
     @ApiResponse(responseCode = "200", description = "OK", content = 
@Content(schema = @Schema(implementation = 
AuthenticationApiResourceSwagger.PostAuthenticationResponse.class)))
     @ApiResponse(responseCode = "400", description = "Unauthenticated. Please 
login")
+    @ApiResponse(responseCode = "403", description = "Password reset required")
     public String authenticate(@Parameter(hidden = true) final String 
apiRequestBodyAsJson,
             @QueryParam("returnClientList") @DefaultValue("false") boolean 
returnClientList) {
         // TODO FINERACT-819: sort out Jersey so JSON conversion does not have
@@ -137,6 +139,7 @@ public class AuthenticationApiResource {
                 authenticatedUserData = new 
AuthenticatedUserData().setUsername(request.username).setUserId(userId)
                         .setBase64EncodedAuthenticationKey(new 
String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8))
                         
.setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired);
+                throw new 
PasswordResetRequiredException(authenticatedUserData);
             } else {
 
                 authenticatedUserData = new 
AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
diff --git 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredException.java
 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredException.java
new file mode 100644
index 0000000000..ecfb705e0d
--- /dev/null
+++ 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredException.java
@@ -0,0 +1,43 @@
+/**
+ * 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.infrastructure.security.exception;
+
+import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
+import org.springframework.security.core.AuthenticationException;
+
+/**
+ * Exception thrown when a user must reset their password before proceeding.
+ *
+ * This exception is thrown during authentication when the user's credentials 
are valid but they are required to change
+ * their password (e.g., on first login or after an admin reset). It carries 
the authenticated user data so the client
+ * receives enough information to proceed with the password reset flow.
+ */
+public class PasswordResetRequiredException extends AuthenticationException {
+
+    private final AuthenticatedUserData authenticatedUserData;
+
+    public PasswordResetRequiredException(AuthenticatedUserData 
authenticatedUserData) {
+        super("Password reset required");
+        this.authenticatedUserData = authenticatedUserData;
+    }
+
+    public AuthenticatedUserData getAuthenticatedUserData() {
+        return authenticatedUserData;
+    }
+}
diff --git 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredExceptionMapper.java
 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredExceptionMapper.java
new file mode 100644
index 0000000000..5d808e2d08
--- /dev/null
+++ 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredExceptionMapper.java
@@ -0,0 +1,57 @@
+/**
+ * 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.infrastructure.security.exception;
+
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
+import 
org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
+import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
+import org.springframework.context.annotation.Scope;
+import org.springframework.stereotype.Component;
+
+/**
+ * An {@link ExceptionMapper} to map {@link PasswordResetRequiredException} 
thrown during authentication into a HTTP API
+ * friendly format.
+ *
+ * The exception is thrown when a user's credentials are valid but they must 
reset their password before proceeding.
+ * This mapper returns a 403 FORBIDDEN response with the authenticated user 
data, including the
+ * {@code shouldRenewPassword} flag set to true.
+ */
+@Provider
+@Component
+@Scope("singleton")
+@Slf4j
+@RequiredArgsConstructor
+public class PasswordResetRequiredExceptionMapper implements 
ExceptionMapper<PasswordResetRequiredException> {
+
+    private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializer;
+
+    @Override
+    public Response toResponse(final PasswordResetRequiredException exception) 
{
+        log.warn("Exception occurred", 
ErrorHandler.findMostSpecificException(exception));
+        return 
Response.status(Status.FORBIDDEN).entity(apiJsonSerializer.serialize(exception.getAuthenticatedUserData()))
+                .type(MediaType.APPLICATION_JSON).build();
+    }
+}
diff --git 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.java
 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.java
index 31630595f3..4493acd1cc 100644
--- 
a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.java
+++ 
b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.java
@@ -49,7 +49,7 @@ public class SpringSecurityPlatformSecurityContext implements 
PlatformSecurityCo
     private final ConfigurationDomainService configurationDomainService;
 
     protected static final List<CommandWrapper> 
EXEMPT_FROM_PASSWORD_RESET_CHECK = new ArrayList<CommandWrapper>(
-            List.of(new CommandWrapperBuilder().updateUser(null).build()));
+            List.of(new 
CommandWrapperBuilder().changeUserPassword(null).build()));
 
     @Override
     public AppUser authenticatedUser() {
@@ -121,7 +121,7 @@ public class SpringSecurityPlatformSecurityContext 
implements PlatformSecurityCo
             throw new UnAuthenticatedUserException();
         }
 
-        if (this.shouldCheckForPasswordForceReset(commandWrapper) && 
this.doesPasswordHasToBeRenewed(currentUser)) {
+        if (this.shouldCheckForPasswordForceReset(commandWrapper, currentUser) 
&& this.doesPasswordHasToBeRenewed(currentUser)) {
             throw new ResetPasswordException(currentUser.getId());
         }
 
@@ -149,6 +149,10 @@ public class SpringSecurityPlatformSecurityContext 
implements PlatformSecurityCo
     @Override
     public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
 
+        if (currentUser.isPasswordResetRequired()) {
+            return true;
+        }
+
         if (this.configurationDomainService.isPasswordForcedResetEnable() && 
!currentUser.getPasswordNeverExpires()) {
 
             Long passwordDurationDays = 
this.configurationDomainService.retrievePasswordLiveTime();
@@ -164,11 +168,11 @@ public class SpringSecurityPlatformSecurityContext 
implements PlatformSecurityCo
 
     }
 
-    private boolean shouldCheckForPasswordForceReset(CommandWrapper 
commandWrapper) {
+    private boolean shouldCheckForPasswordForceReset(CommandWrapper 
commandWrapper, AppUser currentUser) {
         for (CommandWrapper commandItem : EXEMPT_FROM_PASSWORD_RESET_CHECK) {
             if (commandItem.actionName().equals(commandWrapper.actionName())
                     && 
commandItem.getEntityName().equals(commandWrapper.getEntityName())) {
-                return false;
+                return commandWrapper.getEntityId() == null || 
!commandWrapper.getEntityId().equals(currentUser.getId());
             }
         }
         return true;
diff --git 
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/PasswordResetIntegrationTest.java
 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/PasswordResetIntegrationTest.java
new file mode 100644
index 0000000000..834860b188
--- /dev/null
+++ 
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/PasswordResetIntegrationTest.java
@@ -0,0 +1,126 @@
+/**
+ * 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.integrationtests;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import io.restassured.RestAssured;
+import io.restassured.builder.RequestSpecBuilder;
+import io.restassured.builder.ResponseSpecBuilder;
+import io.restassured.http.ContentType;
+import io.restassured.response.Response;
+import io.restassured.specification.RequestSpecification;
+import io.restassured.specification.ResponseSpecification;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.fineract.client.models.PostUsersRequest;
+import org.apache.fineract.client.models.PostUsersResponse;
+import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
+import 
org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants;
+import org.apache.fineract.integrationtests.client.IntegrationTest;
+import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
+import org.apache.fineract.integrationtests.common.Utils;
+import 
org.apache.fineract.integrationtests.useradministration.users.UserHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class PasswordResetIntegrationTest extends IntegrationTest {
+
+    private RequestSpecification requestSpec;
+    private ResponseSpecification responseSpec;
+    private GlobalConfigurationHelper globalConfigurationHelper;
+    private List<Integer> transientUsers = new ArrayList<>();
+
+    @BeforeEach
+    public void setup() {
+        Utils.initializeRESTAssured();
+        this.requestSpec = new 
RequestSpecBuilder().setContentType(ContentType.JSON).build();
+        this.requestSpec.header("Authorization", "Basic " + 
Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
+        this.responseSpec = new 
ResponseSpecBuilder().expectStatusCode(200).build();
+        this.globalConfigurationHelper = new GlobalConfigurationHelper();
+    }
+
+    @AfterEach
+    public void tearDown() {
+        
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
+                new PutGlobalConfigurationsRequest().value(0L).enabled(false));
+
+        for (Integer userId : this.transientUsers) {
+            UserHelper.deleteUser(this.requestSpec, this.responseSpec, userId);
+        }
+        this.transientUsers.clear();
+    }
+
+    @Test
+    public void testPasswordResetEnforcement() {
+        
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
+                new PutGlobalConfigurationsRequest().value(0L).enabled(true));
+
+        String password = "Abcdef1#2$3%XYZ";
+        PostUsersRequest userRequest = 
UserHelper.buildUserRequest(responseSpec, requestSpec, password);
+        PostUsersResponse userResponse = UserHelper.createUser(requestSpec, 
responseSpec, userRequest);
+        Long userId = userResponse.getResourceId();
+        assertNotNull(userId, "User creation failed to return an ID!");
+        this.transientUsers.add(userId.intValue());
+        String username = userRequest.getUsername();
+
+        Response loginResponse = attemptLogin(username, password);
+        assertEquals(403, loginResponse.getStatusCode(), "User should be 
forced to change password");
+
+        String newPassword = "Abcdef1#2$3%XYZ_NEW";
+        Response changePasswordResponse = changePassword(username, password, 
userId, newPassword);
+        assertEquals(200, changePasswordResponse.getStatusCode(), "Password 
change should succeed");
+
+        loginResponse = attemptLogin(username, newPassword);
+        assertEquals(200, loginResponse.getStatusCode(), "User should be able 
to login after reset");
+    }
+
+    @Test
+    public void testFeatureDisabledByDefault() {
+        
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
+                new PutGlobalConfigurationsRequest().value(0L).enabled(false));
+
+        String password = "Abcdef1#2$3%XYZ";
+        PostUsersRequest userRequest = 
UserHelper.buildUserRequest(responseSpec, requestSpec, password);
+        PostUsersResponse userResponse = UserHelper.createUser(requestSpec, 
responseSpec, userRequest);
+        assertNotNull(userResponse.getResourceId(), "User creation failed!");
+        this.transientUsers.add(userResponse.getResourceId().intValue());
+        String username = userRequest.getUsername();
+
+        Response loginResponse = attemptLogin(username, password);
+        assertEquals(200, loginResponse.getStatusCode(), "User should login 
normally when feature is disabled");
+    }
+
+    private Response attemptLogin(String username, String password) {
+        return RestAssured.given().contentType(ContentType.JSON)
+                .body("{\"username\":\"" + username + "\", \"password\":\"" + 
password + "\"}")
+                .post("/fineract-provider/api/v1/authentication?" + 
Utils.TENANT_IDENTIFIER);
+    }
+
+    private Response changePassword(String username, String password, Long 
userId, String newPassword) {
+        String authKey = 
java.util.Base64.getEncoder().encodeToString((username + ":" + 
password).getBytes(UTF_8));
+        return 
RestAssured.given().contentType(ContentType.JSON).header("Authorization", 
"Basic " + authKey)
+                .header("Fineract-Platform-TenantId", "default")
+                .body("{\"password\":\"" + newPassword + "\", 
\"repeatPassword\":\"" + newPassword + "\"}")
+                .post("/fineract-provider/api/v1/users/" + userId + "/pwd?" + 
Utils.TENANT_IDENTIFIER);
+    }
+}
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 a92d47a1f3..3b42d373e4 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
@@ -231,6 +231,13 @@ public class GlobalConfigurationHelper {
         graceOnPenaltyPostingDefault.put("trapDoor", false);
         defaults.add(graceOnPenaltyPostingDefault);
 
+        HashMap<String, Object> forcePasswordResetOnFirstLoginDefault = new 
HashMap<>();
+        forcePasswordResetOnFirstLoginDefault.put("name", 
GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN);
+        forcePasswordResetOnFirstLoginDefault.put("value", 0L);
+        forcePasswordResetOnFirstLoginDefault.put("enabled", false);
+        forcePasswordResetOnFirstLoginDefault.put("trapDoor", false);
+        defaults.add(forcePasswordResetOnFirstLoginDefault);
+
         HashMap<String, Object> savingsInterestPostingCurrentPeriodEndDefault 
= new HashMap<>();
         savingsInterestPostingCurrentPeriodEndDefault.put("name", 
GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END);
         savingsInterestPostingCurrentPeriodEndDefault.put("value", 0L);


Reply via email to