Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Role> allRoles, final JsonCommand command) {

final String username = command.stringValueOfParameterNamed("username");
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -211,6 +227,7 @@ public AppUser(final Office office, final User user, final Set<Role> roles, fina
this.cannotChangePassword = cannotChangePassword;
this.failedLoginAttempts = 0;
this.loginRetryLimitEnabled = false;
this.passwordResetAllowed = false;
}

public EnumOptionData organisationalRoleData() {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -340,6 +387,13 @@ public Map<String, Object> 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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/*"))
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use textblock instead as it was introduced in 15+ version

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using text blocks initially, but SpotBugs throws a VA_FORMAT_STRING_USES_NEWLINE error when using .formatted() with text blocks containing literal newlines. I've kept the string concatenation approach to match the existing
sendToUserAccount method pattern in the same file, which avoids the SpotBugs issue while maintaining consistency.

+ 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ public interface PlatformEmailService {

void sendDefinedEmail(EmailDetail emailDetails);

void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username, String temporaryPassword);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)//
Expand Down Expand Up @@ -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) {

Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to crosscheck if there is any rate-limit or any safeguards against abuse

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endpoint has several safeguards in many places but rate limiting is not implemnted.

this.forgotPasswordService.requestPasswordReset(request.email());
return Response.ok().build();
}

public record ForgotPasswordRequest(String email) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ private PostUsersRequest() {
public Boolean passwordNeverExpires;
@Schema(example = "true")
public Boolean isLoginRetriesEnabled;
public Boolean isPasswordResetAllowed;
}

@Schema(description = "PostUsersResponse")
Expand Down Expand Up @@ -216,6 +217,7 @@ private PutUsersUserIdRequest() {
public Boolean sendPasswordToEmail;
@Schema(example = "true")
public Boolean isLoginRetriesEnabled;
public Boolean isPasswordResetAllowed;
}

@Schema(description = "PutUsersUserIdResponse")
Expand Down
Loading
Loading