Skip to content

Commit edae333

Browse files
authored
Merge pull request #5369
FINERACT-2006: Forgot password on login page
2 parents 4279b8a + 455033f commit edae333

18 files changed

Lines changed: 582 additions & 15 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import jakarta.persistence.Table;
3131
import jakarta.persistence.UniqueConstraint;
3232
import java.time.LocalDate;
33+
import java.time.OffsetDateTime;
34+
import java.time.ZoneOffset;
3335
import java.util.ArrayList;
3436
import java.util.Arrays;
3537
import java.util.Collection;
@@ -142,6 +144,17 @@ public void updatePasswordResetRequired(final boolean required) {
142144
this.passwordResetRequired = required;
143145
}
144146

147+
@Getter
148+
@Column(name = "temporary_password")
149+
private String temporaryPassword;
150+
151+
@Column(name = "temporary_password_expiry_time")
152+
private OffsetDateTime temporaryPasswordExpiryTime;
153+
154+
@Getter
155+
@Column(name = "is_password_reset_enabled", nullable = false)
156+
private boolean passwordResetAllowed = false;
157+
145158
public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final Set<Role> allRoles, final JsonCommand command) {
146159

147160
final String username = command.stringValueOfParameterNamed("username");
@@ -181,6 +194,9 @@ public static AppUser fromJson(final Office userOffice, final Staff linkedStaff,
181194
final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire,
182195
cannotChangePassword);
183196
appUser.updateLoginRetryLimitEnabled(resolveLoginRetryLimitEnabled(username, loginRetryLimitEnabled));
197+
if (command.parameterExists(AppUserConstants.IS_PASSWORD_RESET_ALLOWED)) {
198+
appUser.updatePasswordResetAllowed(command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED));
199+
}
184200
return appUser;
185201
}
186202

@@ -212,6 +228,7 @@ public AppUser(final Office office, final User user, final Set<Role> roles, fina
212228
this.cannotChangePassword = cannotChangePassword;
213229
this.failedLoginAttempts = 0;
214230
this.loginRetryLimitEnabled = false;
231+
this.passwordResetAllowed = false;
215232
}
216233

217234
public EnumOptionData organisationalRoleData() {
@@ -246,11 +263,41 @@ public void updatePassword(final String encodePassword) {
246263
}
247264

248265
this.password = encodePassword;
266+
clearTemporaryPassword();
249267
this.firstTimeLoginRemaining = false;
250268
this.lastTimePasswordUpdated = DateUtils.getBusinessLocalDate();
251269

252270
}
253271

272+
public void updateTemporaryPassword(final String encodedPassword, final OffsetDateTime expiryTime) {
273+
this.temporaryPassword = encodedPassword;
274+
this.temporaryPasswordExpiryTime = expiryTime;
275+
}
276+
277+
public boolean isTemporaryPasswordExpired() {
278+
if (this.temporaryPasswordExpiryTime == null) {
279+
return false;
280+
}
281+
return OffsetDateTime.now(ZoneOffset.UTC).isAfter(this.temporaryPasswordExpiryTime);
282+
}
283+
284+
public boolean hasValidTemporaryPassword() {
285+
return StringUtils.isNotBlank(this.temporaryPassword) && !isTemporaryPasswordExpired();
286+
}
287+
288+
public void clearTemporaryPasswordExpiry() {
289+
clearTemporaryPassword();
290+
}
291+
292+
public void clearTemporaryPassword() {
293+
this.temporaryPassword = null;
294+
this.temporaryPasswordExpiryTime = null;
295+
}
296+
297+
public void updatePasswordResetAllowed(final boolean passwordResetAllowed) {
298+
this.passwordResetAllowed = passwordResetAllowed && !isSystemUser() && !Boolean.TRUE.equals(this.cannotChangePassword);
299+
}
300+
254301
public void changeOffice(final Office differentOffice) {
255302
this.office = differentOffice;
256303
}
@@ -341,6 +388,13 @@ public Map<String, Object> update(final JsonCommand command, final PlatformPassw
341388
updateLoginRetryLimitEnabled(effectiveValue);
342389
}
343390
}
391+
392+
if (command.hasParameter(AppUserConstants.IS_PASSWORD_RESET_ALLOWED)
393+
&& command.isChangeInBooleanParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, this.passwordResetAllowed)) {
394+
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_PASSWORD_RESET_ALLOWED);
395+
actualChanges.put(AppUserConstants.IS_PASSWORD_RESET_ALLOWED, newValue);
396+
updatePasswordResetAllowed(newValue);
397+
}
344398
return actualChanges;
345399
}
346400

fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ private AppUserConstants() {
2828
public static final String REPEAT_PASSWORD = "repeatPassword";
2929
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
3030
public static final String IS_LOGIN_RETRIES_ENABLED = "isLoginRetriesEnabled";
31+
public static final String IS_PASSWORD_RESET_ALLOWED = "isPasswordResetAllowed";
3132

3233
// TODO: Remove hard coding of system user name and make this a configurable parameter
3334
public static final String SYSTEM_USER_NAME = "system";

fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
4646
import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
4747
import org.apache.fineract.infrastructure.security.service.PlatformUserDetailsChecker;
48+
import org.apache.fineract.infrastructure.security.service.TemporaryPasswordAwareAuthenticationProvider;
4849
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
4950
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
5051
import org.apache.fineract.notification.service.UserNotificationService;
@@ -134,6 +135,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
134135
auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll()
135136
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll()
136137
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll()
138+
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/password/forgot")).permitAll()
137139
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
138140
// businessdate
139141
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*"))
@@ -453,7 +455,7 @@ public BasicAuthenticationEntryPoint basicAuthenticationEntryPoint() {
453455

454456
@Bean(name = "customAuthenticationProvider")
455457
public DaoAuthenticationProvider authProvider() {
456-
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
458+
DaoAuthenticationProvider authProvider = new TemporaryPasswordAwareAuthenticationProvider();
457459
authProvider.setUserDetailsService(userDetailsService);
458460
authProvider.setPasswordEncoder(passwordEncoder());
459461
authProvider.setPostAuthenticationChecks(platformUserDetailsChecker);

fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,7 @@ public void sendDefinedEmail(EmailDetail emailDetails) {
7373
props.put("mail.smtp.auth", "true");
7474
props.put("mail.debug", "true");
7575

76-
// these are the added lines
7776
props.put("mail.smtp.starttls.enable", "true");
78-
// props.put("mail.smtp.ssl.enable", "true");
79-
80-
props.put("mail.smtp.socketFactory.port", Integer.parseInt(smtpCredentialsData.getPort()));
81-
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR
82-
props.put("mail.smtp.socketFactory.fallback", "true");
8377

8478
try {
8579
SimpleMailMessage message = new SimpleMailMessage();
@@ -93,4 +87,18 @@ public void sendDefinedEmail(EmailDetail emailDetails) {
9387
throw new PlatformEmailSendException(e);
9488
}
9589
}
90+
91+
@Override
92+
public void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username,
93+
String temporaryPassword) {
94+
final String subject = "Password Reset Request - " + organisationName;
95+
final String body = "Dear " + contactName + ",\n\n" + "You have requested to reset your password for your account on "
96+
+ organisationName + ".\n\n" + "Your temporary password is: " + temporaryPassword + "\n\n"
97+
+ "This temporary password will expire in 1 hour.\n\n" + "Please login with your username: " + username
98+
+ " and this temporary password.\n" + "You will be required to change your password immediately after logging in.\n\n"
99+
+ "If you did not request this password reset, please contact your system administrator.\n\n" + "Thank you.";
100+
101+
final EmailDetail emailDetail = new EmailDetail(subject, body, address, contactName);
102+
sendDefinedEmail(emailDetail);
103+
}
96104
}

fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/PlatformEmailService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@ public interface PlatformEmailService {
2626

2727
void sendDefinedEmail(EmailDetail emailDetails);
2828

29+
void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username, String temporaryPassword);
30+
2931
}

fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import org.apache.fineract.infrastructure.security.filter.TenantAwareAuthenticationFilter;
4343
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
4444
import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
45+
import org.apache.fineract.infrastructure.security.service.TemporaryPasswordAwareAuthenticationProvider;
4546
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
4647
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
4748
import org.apache.fineract.useradministration.domain.AppUser;
@@ -56,6 +57,7 @@
5657
import org.springframework.core.annotation.Order;
5758
import org.springframework.security.authentication.AuthenticationDetailsSource;
5859
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
60+
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
5961
import org.springframework.security.config.Customizer;
6062
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
6163
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -166,7 +168,8 @@ public SecurityFilterChain protectedEndpoints(HttpSecurity http) throws Exceptio
166168
if (fineractProperties.getSecurity().getTwoFactor().isEnabled()) {
167169
auth.anyRequest().hasAuthority("TWOFACTOR_AUTHENTICATED");
168170
}
169-
}).formLogin(form -> form.loginPage("/login").authenticationDetailsSource(tenantAuthDetailsSource()).permitAll())
171+
}).authenticationProvider(customAuthenticationProvider())
172+
.formLogin(form -> form.loginPage("/login").authenticationDetailsSource(tenantAuthDetailsSource()).permitAll())
170173
.oauth2ResourceServer(
171174
resourceServer -> resourceServer.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter())))
172175
.addFilterAfter(tenantAwareAuthenticationFilter(), SecurityContextHolderFilter.class)//
@@ -230,6 +233,14 @@ public PasswordEncoder passwordEncoder() {
230233
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
231234
}
232235

236+
@Bean
237+
public DaoAuthenticationProvider customAuthenticationProvider() {
238+
DaoAuthenticationProvider authProvider = new TemporaryPasswordAwareAuthenticationProvider();
239+
authProvider.setUserDetailsService(userDetailsService);
240+
authProvider.setPasswordEncoder(passwordEncoder());
241+
return authProvider;
242+
}
243+
233244
@Bean
234245
public RegisteredClientRepository registeredClientRepository(FineractProperties fineractProperties) {
235246

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.infrastructure.security.service;
20+
21+
import org.apache.fineract.useradministration.domain.AppUser;
22+
import org.springframework.security.authentication.BadCredentialsException;
23+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
24+
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
25+
import org.springframework.security.core.AuthenticationException;
26+
import org.springframework.security.core.userdetails.UserDetails;
27+
28+
/**
29+
* Supports authentication with either the permanent password or a non-expired temporary password.
30+
*/
31+
public class TemporaryPasswordAwareAuthenticationProvider extends DaoAuthenticationProvider {
32+
33+
@Override
34+
protected void additionalAuthenticationChecks(final UserDetails userDetails, final UsernamePasswordAuthenticationToken authentication)
35+
throws AuthenticationException {
36+
if (authentication.getCredentials() == null) {
37+
throw new BadCredentialsException(
38+
messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
39+
}
40+
41+
final String presentedPassword = authentication.getCredentials().toString();
42+
if (getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
43+
return;
44+
}
45+
46+
if (userDetails instanceof AppUser appUser && appUser.hasValidTemporaryPassword()
47+
&& getPasswordEncoder().matches(presentedPassword, appUser.getTemporaryPassword())) {
48+
return;
49+
}
50+
51+
throw new BadCredentialsException(
52+
messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
53+
}
54+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.useradministration.api;
20+
21+
import io.swagger.v3.oas.annotations.Operation;
22+
import io.swagger.v3.oas.annotations.media.Content;
23+
import io.swagger.v3.oas.annotations.media.Schema;
24+
import io.swagger.v3.oas.annotations.parameters.RequestBody;
25+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
26+
import io.swagger.v3.oas.annotations.tags.Tag;
27+
import jakarta.ws.rs.Consumes;
28+
import jakarta.ws.rs.POST;
29+
import jakarta.ws.rs.Path;
30+
import jakarta.ws.rs.Produces;
31+
import jakarta.ws.rs.core.MediaType;
32+
import jakarta.ws.rs.core.Response;
33+
import lombok.RequiredArgsConstructor;
34+
import org.apache.fineract.useradministration.service.ForgotPasswordService;
35+
import org.springframework.stereotype.Component;
36+
37+
@Path("/v1/password")
38+
@Component
39+
@Tag(name = "Password Management", description = "APIs for password management operations including forgot password functionality.")
40+
@RequiredArgsConstructor
41+
public class ForgotPasswordApiResource {
42+
43+
private final ForgotPasswordService forgotPasswordService;
44+
45+
@POST
46+
@Path("/forgot")
47+
@Consumes({ MediaType.APPLICATION_JSON })
48+
@Produces({ MediaType.APPLICATION_JSON })
49+
@Operation(summary = "Request password reset", description = """
50+
Requests a password reset for the user with the given email.
51+
If the email exists and the user is active, a temporary password will be sent to the email address.
52+
The temporary password expires in 1 hour.""")
53+
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ForgotPasswordRequest.class)))
54+
@ApiResponse(responseCode = "200", description = "OK")
55+
public Response forgotPassword(final ForgotPasswordRequest request) {
56+
this.forgotPasswordService.requestPasswordReset(request.email());
57+
return Response.ok().build();
58+
}
59+
60+
public record ForgotPasswordRequest(String email) {
61+
}
62+
}

fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public String template(@Context final UriInfo uriInfo) {
148148
@Operation(summary = "Create a User", operationId = "createUser", description = "Adds new application user.\n" + "\n"
149149
+ "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"
150150
+ "\n" + "Mandatory Fields: \n" + "username, firstname, lastname, email, officeId, roles, sendPasswordToEmail\n" + "\n"
151-
+ "Optional Fields: \n" + "staffId,passwordNeverExpires,isLoginRetriesEnabled")
151+
+ "Optional Fields: \n" + "staffId,passwordNeverExpires,isLoginRetriesEnabled,isPasswordResetAllowed")
152152
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PostUsersRequest.class)))
153153
@ApiResponses({
154154
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UsersApiResourceSwagger.PostUsersResponse.class))) })

fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ private PostUsersRequest() {
135135
public Boolean passwordNeverExpires;
136136
@Schema(example = "true")
137137
public Boolean isLoginRetriesEnabled;
138+
public Boolean isPasswordResetAllowed;
138139
}
139140

140141
@Schema(description = "PostUsersResponse")
@@ -216,6 +217,7 @@ private PutUsersUserIdRequest() {
216217
public Boolean sendPasswordToEmail;
217218
@Schema(example = "true")
218219
public Boolean isLoginRetriesEnabled;
220+
public Boolean isPasswordResetAllowed;
219221
}
220222

221223
@Schema(description = "PutUsersUserIdResponse")

0 commit comments

Comments
 (0)