From 7973b6c5731a028fa39950da4eab5581583622cf Mon Sep 17 00:00:00 2001 From: airajena Date: Thu, 19 Mar 2026 09:06:54 +0530 Subject: [PATCH] FINERACT-2006: Forgot password on login page refactoring --- .../useradministration/domain/AppUser.java | 61 ++++++++- .../service/AppUserConstants.java | 1 + .../core/config/SecurityConfig.java | 4 +- .../GmailBackedPlatformEmailService.java | 20 ++- .../core/service/PlatformEmailService.java | 2 + .../config/AuthorizationServerConfig.java | 13 +- ...ryPasswordAwareAuthenticationProvider.java | 55 +++++++++ .../api/ForgotPasswordApiResource.java | 62 ++++++++++ .../api/UsersApiResource.java | 2 +- .../api/UsersApiResourceSwagger.java | 2 + .../domain/AppUserRepository.java | 3 + .../service/ForgotPasswordService.java | 24 ++++ .../service/ForgotPasswordServiceImpl.java | 73 +++++++++++ .../service/UserDataValidator.java | 34 ++++- .../db/changelog/tenant/changelog-tenant.xml | 1 + .../tenant/parts/0222_add_forgot_password.xml | 42 +++++++ ...sswordAwareAuthenticationProviderTest.java | 90 ++++++++++++++ .../ForgotPasswordServiceImplTest.java | 116 ++++++++++++++++++ 18 files changed, 587 insertions(+), 18 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProvider.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/api/ForgotPasswordApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_forgot_password.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProviderTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImplTest.java 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 409b05e911c..55be1a1a8f3 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 @@ -30,6 +30,8 @@ import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -141,6 +143,17 @@ public void updatePasswordResetRequired(final boolean required) { this.passwordResetRequired = required; } + @Getter + @Column(name = "temporary_password") + private String temporaryPassword; + + @Column(name = "temporary_password_expiry_time") + private OffsetDateTime temporaryPasswordExpiryTime; + + @Getter + @Column(name = "is_password_reset_enabled", nullable = false) + private boolean passwordResetAllowed = false; + public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final Set allRoles, final JsonCommand command) { final String username = command.stringValueOfParameterNamed("username"); @@ -180,6 +193,9 @@ public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, cannotChangePassword); appUser.updateLoginRetryLimitEnabled(resolveLoginRetryLimitEnabled(username, loginRetryLimitEnabled)); + if (command.parameterExists(AppUserConstants.IS_PASSWORD_RESET_ALLOWED)) { + appUser.updatePasswordResetAllowed(command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED)); + } return appUser; } @@ -211,6 +227,7 @@ public AppUser(final Office office, final User user, final Set roles, fina this.cannotChangePassword = cannotChangePassword; this.failedLoginAttempts = 0; this.loginRetryLimitEnabled = false; + this.passwordResetAllowed = false; } public EnumOptionData organisationalRoleData() { @@ -245,11 +262,41 @@ public void updatePassword(final String encodePassword) { } this.password = encodePassword; + clearTemporaryPassword(); this.firstTimeLoginRemaining = false; this.lastTimePasswordUpdated = DateUtils.getBusinessLocalDate(); } + public void updateTemporaryPassword(final String encodedPassword, final OffsetDateTime expiryTime) { + this.temporaryPassword = encodedPassword; + this.temporaryPasswordExpiryTime = expiryTime; + } + + public boolean isTemporaryPasswordExpired() { + if (this.temporaryPasswordExpiryTime == null) { + return false; + } + return OffsetDateTime.now(ZoneOffset.UTC).isAfter(this.temporaryPasswordExpiryTime); + } + + public boolean hasValidTemporaryPassword() { + return StringUtils.isNotBlank(this.temporaryPassword) && !isTemporaryPasswordExpired(); + } + + public void clearTemporaryPasswordExpiry() { + clearTemporaryPassword(); + } + + public void clearTemporaryPassword() { + this.temporaryPassword = null; + this.temporaryPasswordExpiryTime = null; + } + + public void updatePasswordResetAllowed(final boolean passwordResetAllowed) { + this.passwordResetAllowed = passwordResetAllowed && !isSystemUser() && !Boolean.TRUE.equals(this.cannotChangePassword); + } + public void changeOffice(final Office differentOffice) { this.office = differentOffice; } @@ -340,6 +387,13 @@ public Map update(final JsonCommand command, final PlatformPassw updateLoginRetryLimitEnabled(effectiveValue); } } + + if (command.hasParameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED) + && command.isChangeInBooleanParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, this.passwordResetAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED); + actualChanges.put(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, newValue); + updatePasswordResetAllowed(newValue); + } return actualChanges; } @@ -551,8 +605,9 @@ public void validateHasDeletePermission(final String resourceType) { } private void validateHasPermission(final String prefix, final String resourceType) { - final String authorizationMessage = "User has no authority to " + prefix + " " + resourceType.toLowerCase() + "s"; - final String matchPermission = prefix + "_" + resourceType.toUpperCase(); + final String authorizationMessage = "User has no authority to " + prefix + " " + resourceType.toLowerCase(java.util.Locale.ROOT) + + "s"; + final String matchPermission = prefix + "_" + resourceType.toUpperCase(java.util.Locale.ROOT); if (!hasNotPermissionForAnyOf("ALL_FUNCTIONS", "ALL_FUNCTIONS_READ", matchPermission)) { return; @@ -635,7 +690,7 @@ public void validateHasReadPermission(final String function, final Long userId) } public void validateHasCheckerPermissionTo(final String function) { - final String checkerPermissionName = function.toUpperCase() + "_CHECKER"; + final String checkerPermissionName = function.toUpperCase(java.util.Locale.ROOT) + "_CHECKER"; if (hasNotPermissionTo("CHECKER_SUPER_USER") && hasNotPermissionTo(checkerPermissionName)) { final String authorizationMessage = "User has no authority to be a checker for: " + function; throw new NoAuthorizationException(authorizationMessage); diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java index 0af7d62d390..0dbcae5f700 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java @@ -28,6 +28,7 @@ private AppUserConstants() { public static final String REPEAT_PASSWORD = "repeatPassword"; public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires"; public static final String IS_LOGIN_RETRIES_ENABLED = "isLoginRetriesEnabled"; + public static final String IS_PASSWORD_RESET_ALLOWED = "isPasswordResetAllowed"; // TODO: Remove hard coding of system user name and make this a configurable parameter public static final String SYSTEM_USER_NAME = "system"; 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 6e5d895a488..46a04b51518 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.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.TemporaryPasswordAwareAuthenticationProvider; import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService; import org.apache.fineract.infrastructure.security.service.TwoFactorService; import org.apache.fineract.notification.service.UserNotificationService; @@ -134,6 +135,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/password/forgot")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll() // businessdate .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*")) @@ -439,7 +441,7 @@ public BasicAuthenticationEntryPoint basicAuthenticationEntryPoint() { @Bean(name = "customAuthenticationProvider") public DaoAuthenticationProvider authProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + DaoAuthenticationProvider authProvider = new TemporaryPasswordAwareAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService); authProvider.setPasswordEncoder(passwordEncoder()); authProvider.setPostAuthenticationChecks(platformUserDetailsChecker); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java index fd1018c3f82..54db42e16f3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java @@ -73,13 +73,7 @@ public void sendDefinedEmail(EmailDetail emailDetails) { props.put("mail.smtp.auth", "true"); props.put("mail.debug", "true"); - // these are the added lines props.put("mail.smtp.starttls.enable", "true"); - // props.put("mail.smtp.ssl.enable", "true"); - - props.put("mail.smtp.socketFactory.port", Integer.parseInt(smtpCredentialsData.getPort())); - props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR - props.put("mail.smtp.socketFactory.fallback", "true"); try { SimpleMailMessage message = new SimpleMailMessage(); @@ -93,4 +87,18 @@ public void sendDefinedEmail(EmailDetail emailDetails) { throw new PlatformEmailSendException(e); } } + + @Override + public void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username, + String temporaryPassword) { + final String subject = "Password Reset Request - " + organisationName; + final String body = "Dear " + contactName + ",\n\n" + "You have requested to reset your password for your account on " + + organisationName + ".\n\n" + "Your temporary password is: " + temporaryPassword + "\n\n" + + "This temporary password will expire in 1 hour.\n\n" + "Please login with your username: " + username + + " and this temporary password.\n" + "You will be required to change your password immediately after logging in.\n\n" + + "If you did not request this password reset, please contact your system administrator.\n\n" + "Thank you."; + + final EmailDetail emailDetail = new EmailDetail(subject, body, address, contactName); + sendDefinedEmail(emailDetail); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/PlatformEmailService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/PlatformEmailService.java index b2f0d1bb489..ad30f2ec66d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/PlatformEmailService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/PlatformEmailService.java @@ -26,4 +26,6 @@ public interface PlatformEmailService { void sendDefinedEmail(EmailDetail emailDetails); + void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username, String temporaryPassword); + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java index ce70a02cd96..db59825d206 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java @@ -42,6 +42,7 @@ import org.apache.fineract.infrastructure.security.filter.TenantAwareAuthenticationFilter; import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter; import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService; +import org.apache.fineract.infrastructure.security.service.TemporaryPasswordAwareAuthenticationProvider; import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService; import org.apache.fineract.infrastructure.security.service.TwoFactorService; import org.apache.fineract.useradministration.domain.AppUser; @@ -56,6 +57,7 @@ import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -166,7 +168,8 @@ public SecurityFilterChain protectedEndpoints(HttpSecurity http) throws Exceptio if (fineractProperties.getSecurity().getTwoFactor().isEnabled()) { auth.anyRequest().hasAuthority("TWOFACTOR_AUTHENTICATED"); } - }).formLogin(form -> form.loginPage("/login").authenticationDetailsSource(tenantAuthDetailsSource()).permitAll()) + }).authenticationProvider(customAuthenticationProvider()) + .formLogin(form -> form.loginPage("/login").authenticationDetailsSource(tenantAuthDetailsSource()).permitAll()) .oauth2ResourceServer( resourceServer -> resourceServer.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter()))) .addFilterAfter(tenantAwareAuthenticationFilter(), SecurityContextHolderFilter.class)// @@ -230,6 +233,14 @@ public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } + @Bean + public DaoAuthenticationProvider customAuthenticationProvider() { + DaoAuthenticationProvider authProvider = new TemporaryPasswordAwareAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + @Bean public RegisteredClientRepository registeredClientRepository(FineractProperties fineractProperties) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProvider.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProvider.java new file mode 100644 index 00000000000..55588a4719c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProvider.java @@ -0,0 +1,55 @@ +/** + * 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.apache.fineract.useradministration.domain.AppUser; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Supports authentication with either the permanent password or a non-expired temporary password. + */ +public class TemporaryPasswordAwareAuthenticationProvider extends DaoAuthenticationProvider { + + @Override + @SuppressWarnings("java:S1874") + protected void additionalAuthenticationChecks(final UserDetails userDetails, final UsernamePasswordAuthenticationToken authentication) + throws AuthenticationException { + if (authentication.getCredentials() == null) { + throw new BadCredentialsException( + messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); + } + + final String presentedPassword = authentication.getCredentials().toString(); + if (getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) { + return; + } + + if (userDetails instanceof AppUser appUser && appUser.hasValidTemporaryPassword() + && getPasswordEncoder().matches(presentedPassword, appUser.getTemporaryPassword())) { + return; + } + + throw new BadCredentialsException( + messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/ForgotPasswordApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/ForgotPasswordApiResource.java new file mode 100644 index 00000000000..7354dfdfd23 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/ForgotPasswordApiResource.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.api; + +import io.swagger.v3.oas.annotations.Operation; +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.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.useradministration.service.ForgotPasswordService; +import org.springframework.stereotype.Component; + +@Path("/v1/password") +@Component +@Tag(name = "Password Management", description = "APIs for password management operations including forgot password functionality.") +@RequiredArgsConstructor +public class ForgotPasswordApiResource { + + private final ForgotPasswordService forgotPasswordService; + + @POST + @Path("/forgot") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Request password reset", description = """ + Requests a password reset for the user with the given email. + If the email exists and the user is active, a temporary password will be sent to the email address. + The temporary password expires in 1 hour.""") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = ForgotPasswordRequest.class))) + @ApiResponse(responseCode = "200", description = "OK") + public Response forgotPassword(final ForgotPasswordRequest request) { + this.forgotPasswordService.requestPasswordReset(request.email()); + return Response.ok().build(); + } + + public record ForgotPasswordRequest(String email) { + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java index 34d3bbc6c98..54d0ef0008d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java @@ -151,7 +151,7 @@ public String template(@Context final UriInfo uriInfo) { @Operation(summary = "Create a User", operationId = "createUser", description = "Adds new application user.\n" + "\n" + "Note: Password information is not required (or processed). Password details at present are auto-generated and then sent to the email account given (which is why it can take a few seconds to complete).\n" + "\n" + "Mandatory Fields: \n" + "username, firstname, lastname, email, officeId, roles, sendPasswordToEmail\n" + "\n" - + "Optional Fields: \n" + "staffId,passwordNeverExpires,isLoginRetriesEnabled") + + "Optional Fields: \n" + "staffId,passwordNeverExpires,isLoginRetriesEnabled,isPasswordResetAllowed") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PostUsersRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PostUsersResponse.class))) }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java index 5e24a3f706a..4146737309a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java @@ -135,6 +135,7 @@ private PostUsersRequest() { public Boolean passwordNeverExpires; @Schema(example = "true") public Boolean isLoginRetriesEnabled; + public Boolean isPasswordResetAllowed; } @Schema(description = "PostUsersResponse") @@ -216,6 +217,7 @@ private PutUsersUserIdRequest() { public Boolean sendPasswordToEmail; @Schema(example = "true") public Boolean isLoginRetriesEnabled; + public Boolean isPasswordResetAllowed; } @Schema(description = "PutUsersUserIdResponse") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserRepository.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserRepository.java index f7481d7bbbd..b1e04f227ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/domain/AppUserRepository.java @@ -31,4 +31,7 @@ public interface AppUserRepository extends JpaRepository, JpaSpec AppUser findAppUserByName(@Param("username") String username); Collection findByOfficeId(Long officeId); + + @Query("Select appUser from AppUser appUser where appUser.email = :email and appUser.enabled = true and appUser.deleted = false") + AppUser findActiveUserByEmail(@Param("email") String email); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordService.java new file mode 100644 index 00000000000..6800b5970f8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordService.java @@ -0,0 +1,24 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +public interface ForgotPasswordService { + + void requestPasswordReset(String email); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImpl.java new file mode 100644 index 00000000000..dbb3787379a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImpl.java @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.PlatformEmailService; +import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ForgotPasswordServiceImpl implements ForgotPasswordService { + + private static final int TEMPORARY_PASSWORD_LENGTH = 13; + private static final int TEMPORARY_PASSWORD_EXPIRY_HOURS = 1; + + private final AppUserRepository appUserRepository; + private final PasswordEncoder passwordEncoder; + private final PlatformEmailService emailService; + + @Override + @Transactional + public void requestPasswordReset(final String email) { + final AppUser user = this.appUserRepository.findActiveUserByEmail(email); + if (user == null) { + log.debug("Password reset requested for non-existent or inactive email: {}", email); + return; + } + + if (!user.isPasswordResetAllowed()) { + log.debug("Password reset is disabled for user: {}", user.getUsername()); + return; + } + + final String temporaryPassword = new RandomPasswordGenerator(TEMPORARY_PASSWORD_LENGTH).generate(); + final String encodedPassword = this.passwordEncoder.encode(temporaryPassword); + final OffsetDateTime expiryTime = OffsetDateTime.now(ZoneOffset.UTC).plusHours(TEMPORARY_PASSWORD_EXPIRY_HOURS); + + user.updateTemporaryPassword(encodedPassword, expiryTime); + this.appUserRepository.saveAndFlush(user); + + final String organisationName = user.getOffice().getName(); + final String contactName = user.getFirstname() + " " + user.getLastname(); + + this.emailService.sendForgotPasswordEmail(organisationName, contactName, email, user.getUsername(), temporaryPassword); + + log.info("Password reset email sent to user: {}", user.getUsername()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java index 22aa2cade0b..befe6cba925 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java @@ -59,12 +59,12 @@ public final class UserDataValidator { /** * The parameters supported for this command. */ - private static final Set CREATE_SUPPORTED_PARAMETERS = new HashSet<>( - Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, - SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_LOGIN_RETRIES_ENABLED)); - private static final Set UPDATE_SUPPORTED_PARAMETERS = new HashSet<>( - Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, - SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_LOGIN_RETRIES_ENABLED)); + private static final Set CREATE_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, + REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, + AppUserConstants.IS_LOGIN_RETRIES_ENABLED, AppUserConstants.IS_PASSWORD_RESET_ALLOWED)); + private static final Set UPDATE_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, + REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, + AppUserConstants.IS_LOGIN_RETRIES_ENABLED, AppUserConstants.IS_PASSWORD_RESET_ALLOWED)); private static final Set CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire"; @@ -138,6 +138,17 @@ public void validateForCreate(final String json) { .validateForBooleanValue(); } } + + if (this.fromApiJsonHelper.parameterExists(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, element)) { + final Boolean passwordResetAllowed = this.fromApiJsonHelper.extractBooleanNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, + element); + if (passwordResetAllowed == null) { + baseDataValidator.reset().parameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED).trueOrFalseRequired(false); + } else { + baseDataValidator.reset().parameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED).value(passwordResetAllowed) + .validateForBooleanValue(); + } + } final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element); baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty(); @@ -261,6 +272,17 @@ public void validateForUpdate(final String json, AppUser authenticatedUser) { .validateForBooleanValue(); } } + + if (this.fromApiJsonHelper.parameterExists(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, element)) { + final Boolean passwordResetAllowed = this.fromApiJsonHelper.extractBooleanNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, + element); + if (passwordResetAllowed == null) { + baseDataValidator.reset().parameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED).trueOrFalseRequired(false); + } else { + baseDataValidator.reset().parameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED).value(passwordResetAllowed) + .validateForBooleanValue(); + } + } throwExceptionIfValidationWarningsExist(dataValidationErrors); validateFieldLevelACL(json, authenticatedUser); } 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 5597a2e341e..0b55fcdc045 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 @@ -240,4 +240,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_forgot_password.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_forgot_password.xml new file mode 100644 index 00000000000..a5d4fe88c25 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_forgot_password.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProviderTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProviderTest.java new file mode 100644 index 00000000000..491f5aabf9b --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/TemporaryPasswordAwareAuthenticationProviderTest.java @@ -0,0 +1,90 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; + +class TemporaryPasswordAwareAuthenticationProviderTest { + + private TemporaryPasswordAwareAuthenticationProvider subject; + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + passwordEncoder = mock(PasswordEncoder.class); + subject = new TemporaryPasswordAwareAuthenticationProvider(); + subject.setPasswordEncoder(passwordEncoder); + } + + @Test + void authenticateShouldSucceedWithPermanentPassword() { + AppUser user = mockEnabledUser(); + when(user.getPassword()).thenReturn("{bcrypt}main"); + when(passwordEncoder.matches("secret", "{bcrypt}main")).thenReturn(true); + subject.setUserDetailsService(username -> user); + + subject.authenticate(UsernamePasswordAuthenticationToken.unauthenticated("demo", "secret")); + } + + @Test + void authenticateShouldSucceedWithValidTemporaryPassword() { + AppUser user = mockEnabledUser(); + when(user.getPassword()).thenReturn("{bcrypt}main"); + when(user.hasValidTemporaryPassword()).thenReturn(true); + when(user.getTemporaryPassword()).thenReturn("{bcrypt}temp"); + when(passwordEncoder.matches("temporary-secret", "{bcrypt}main")).thenReturn(false); + when(passwordEncoder.matches("temporary-secret", "{bcrypt}temp")).thenReturn(true); + subject.setUserDetailsService(username -> user); + + subject.authenticate(UsernamePasswordAuthenticationToken.unauthenticated("demo", "temporary-secret")); + } + + @Test + void authenticateShouldFailWithExpiredTemporaryPassword() { + AppUser user = mockEnabledUser(); + when(user.getPassword()).thenReturn("{bcrypt}main"); + when(user.hasValidTemporaryPassword()).thenReturn(false); + when(passwordEncoder.matches("temporary-secret", "{bcrypt}main")).thenReturn(false); + subject.setUserDetailsService(username -> user); + + assertThrows(BadCredentialsException.class, + () -> subject.authenticate(UsernamePasswordAuthenticationToken.unauthenticated("demo", "temporary-secret"))); + } + + private AppUser mockEnabledUser() { + AppUser user = mock(AppUser.class); + when(user.getUsername()).thenReturn("demo"); + when(user.getAuthorities()).thenReturn(Collections.emptyList()); + when(user.isAccountNonExpired()).thenReturn(true); + when(user.isAccountNonLocked()).thenReturn(true); + when(user.isCredentialsNonExpired()).thenReturn(true); + when(user.isEnabled()).thenReturn(true); + return user; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImplTest.java new file mode 100644 index 00000000000..4760dc5ab0a --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/useradministration/service/ForgotPasswordServiceImplTest.java @@ -0,0 +1,116 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.useradministration.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.apache.fineract.infrastructure.core.service.PlatformEmailService; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.useradministration.domain.AppUserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class ForgotPasswordServiceImplTest { + + private static final String EMAIL = "user@example.com"; + + @Mock + private AppUserRepository appUserRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private PlatformEmailService emailService; + @Mock + private AppUser user; + @Mock + private Office office; + @Captor + private ArgumentCaptor expiryCaptor; + + private ForgotPasswordServiceImpl subject; + + @BeforeEach + void setUp() { + subject = new ForgotPasswordServiceImpl(appUserRepository, passwordEncoder, emailService); + } + + @Test + void requestPasswordResetShouldBeNoopWhenUserNotFound() { + when(appUserRepository.findActiveUserByEmail(EMAIL)).thenReturn(null); + + subject.requestPasswordReset(EMAIL); + + verify(passwordEncoder, never()).encode(anyString()); + verify(emailService, never()).sendForgotPasswordEmail(anyString(), anyString(), anyString(), anyString(), anyString()); + verify(appUserRepository, never()).saveAndFlush(any(AppUser.class)); + } + + @Test + void requestPasswordResetShouldBeNoopWhenResetIsDisabledForUser() { + when(appUserRepository.findActiveUserByEmail(EMAIL)).thenReturn(user); + when(user.isPasswordResetAllowed()).thenReturn(false); + + subject.requestPasswordReset(EMAIL); + + verify(passwordEncoder, never()).encode(anyString()); + verify(emailService, never()).sendForgotPasswordEmail(anyString(), anyString(), anyString(), anyString(), anyString()); + verify(appUserRepository, never()).saveAndFlush(any(AppUser.class)); + } + + @Test + void requestPasswordResetShouldGenerateTemporaryPasswordAndSendMail() { + when(appUserRepository.findActiveUserByEmail(EMAIL)).thenReturn(user); + when(user.isPasswordResetAllowed()).thenReturn(true); + when(passwordEncoder.encode(anyString())).thenReturn("{bcrypt}encoded-temporary-password"); + when(user.getOffice()).thenReturn(office); + when(office.getName()).thenReturn("Head Office"); + when(user.getFirstname()).thenReturn("Demo"); + when(user.getLastname()).thenReturn("User"); + when(user.getUsername()).thenReturn("demo"); + + OffsetDateTime minExpected = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).minusSeconds(5); + subject.requestPasswordReset(EMAIL); + OffsetDateTime maxExpected = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).plusSeconds(5); + + verify(user).updateTemporaryPassword(eq("{bcrypt}encoded-temporary-password"), expiryCaptor.capture()); + verify(appUserRepository).saveAndFlush(user); + verify(emailService).sendForgotPasswordEmail(eq("Head Office"), eq("Demo User"), eq(EMAIL), eq("demo"), anyString()); + + OffsetDateTime actualExpiry = expiryCaptor.getValue(); + assertFalse(actualExpiry.isBefore(minExpected)); + assertTrue(actualExpiry.isBefore(maxExpected)); + } +}