From ca3e98054321f72af2a5d3d9c8368454f452370b Mon Sep 17 00:00:00 2001 From: DeathGun44 Date: Tue, 27 Jan 2026 17:18:11 +0530 Subject: [PATCH] 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 + .../core/config/SecurityConfig.java | 6 +- .../service/PlatformUserDetailsChecker.java | 39 ++++++ .../api/SelfAuthenticationApiResource.java | 5 +- ...WritePlatformServiceJpaRepositoryImpl.java | 17 ++- .../db/changelog/tenant/changelog-tenant.xml | 1 + .../0212_add_force_password_reset_config.xml | 42 ++++++ .../api/AuthenticationApiResource.java | 3 + .../PasswordResetRequiredException.java | 43 ++++++ .../PasswordResetRequiredExceptionMapper.java | 57 ++++++++ ...SpringSecurityPlatformSecurityContext.java | 12 +- .../PasswordResetIntegrationTest.java | 126 ++++++++++++++++++ .../common/GlobalConfigurationHelper.java | 7 + 16 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/PlatformUserDetailsChecker.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_force_password_reset_config.xml create mode 100644 fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredException.java create mode 100644 fineract-security/src/main/java/org/apache/fineract/infrastructure/security/exception/PasswordResetRequiredExceptionMapper.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/PasswordResetIntegrationTest.java 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 88ec4bc0d..a9336214d 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 83c21afd2..b2a897b46 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 661564d52..5d7ab2305 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 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 allRoles, final Collection 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 94695f2f2..e43ee3396 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 fffee05b9..6168cfc63 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 000000000..f840da55f --- /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 bd277d6e8..3c41df7b5 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 9c5ec5c80..ccf0e7b6e 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 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 297426a0a..81aa2fa1e 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 @@ + 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 000000000..16354740e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0212_add_force_password_reset_config.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + 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 cca62de45..81d8c3117 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 000000000..ecfb705e0 --- /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 000000000..5d808e2d0 --- /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 { + + private final ToApiJsonSerializer 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 31630595f..4493acd1c 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 EXEMPT_FROM_PASSWORD_RESET_CHECK = new ArrayList( - 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 000000000..834860b18 --- /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 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 a92d47a1f..3b42d373e 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 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 savingsInterestPostingCurrentPeriodEndDefault = new HashMap<>(); savingsInterestPostingCurrentPeriodEndDefault.put("name", GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END); savingsInterestPostingCurrentPeriodEndDefault.put("value", 0L);