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);