Skip to content

Commit a704f91

Browse files
committed
FINERACT-2003: Enforce password reset on first login
1 parent 0ed6b7f commit a704f91

17 files changed

Lines changed: 370 additions & 11 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ public final class GlobalConfigurationConstants {
7979
public static final String ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY = "outstanding-interest-calculation-strategy-for-external-asset-transfer";
8080
public static final String ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-for-external-asset-transfer";
8181
public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer";
82+
public static final String FORCE_PASSWORD_RESET_ON_FIRST_LOGIN = "force-password-reset-on-first-login";
8283

8384
private GlobalConfigurationConstants() {}
8485
}

fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,6 @@ public interface ConfigurationDomainService {
151151
boolean isImmediateChargeAccrualPostMaturityEnabled();
152152

153153
String getAssetOwnerTransferOustandingInterestStrategy();
154+
155+
boolean isForcePasswordResetOnFirstLoginEnabled();
154156
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
131131
@Column(name = "cannot_change_password", nullable = true)
132132
private Boolean cannotChangePassword;
133133

134+
@Column(name = "password_reset_required", nullable = false)
135+
private boolean passwordResetRequired;
136+
137+
public boolean isPasswordResetRequired() {
138+
return this.passwordResetRequired;
139+
}
140+
141+
public void updatePasswordResetRequired(final boolean required) {
142+
this.passwordResetRequired = required;
143+
}
144+
134145
public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final Set<Role> allRoles,
135146
final Collection<Client> clients, final JsonCommand command) {
136147

fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,4 +548,9 @@ public String getAssetOwnerTransferOustandingInterestStrategy() {
548548
return getGlobalConfigurationPropertyData(
549549
GlobalConfigurationConstants.ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY).getStringValue();
550550
}
551+
552+
@Override
553+
public boolean isForcePasswordResetOnFirstLoginEnabled() {
554+
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN).isEnabled();
555+
}
551556
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
4545
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
4646
import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
47+
import org.apache.fineract.infrastructure.security.service.PlatformUserDetailsChecker;
4748
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
4849
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
4950
import org.apache.fineract.notification.service.UserNotificationService;
@@ -113,6 +114,8 @@ public class SecurityConfig {
113114
private LoanCOBFilterHelper loanCOBFilterHelper;
114115
@Autowired
115116
private IdempotencyStoreHelper idempotencyStoreHelper;
117+
@Autowired
118+
private PlatformUserDetailsChecker platformUserDetailsChecker;
116119

117120
@Bean
118121
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -248,6 +251,7 @@ public DaoAuthenticationProvider authProvider() {
248251
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
249252
authProvider.setUserDetailsService(userDetailsService);
250253
authProvider.setPasswordEncoder(passwordEncoder());
254+
authProvider.setPostAuthenticationChecks(platformUserDetailsChecker);
251255
return authProvider;
252256
}
253257

fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
4444
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
4545
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
46+
import org.apache.fineract.infrastructure.security.exception.PasswordResetRequiredException;
4647
import org.apache.fineract.infrastructure.security.service.SpringSecurityPlatformSecurityContext;
4748
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
4849
import org.apache.fineract.useradministration.data.RoleData;
@@ -86,6 +87,7 @@ public static class AuthenticateRequest {
8687
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
8788
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationResponse.class)))
8889
@ApiResponse(responseCode = "400", description = "Unauthenticated. Please login")
90+
@ApiResponse(responseCode = "403", description = "Password reset required")
8991
public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson,
9092
@QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList) {
9193
// TODO FINERACT-819: sort out Jersey so JSON conversion does not have
@@ -137,6 +139,7 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody
137139
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setUserId(userId)
138140
.setBase64EncodedAuthenticationKey(new String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8))
139141
.setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired);
142+
throw new PasswordResetRequiredException(authenticatedUserData);
140143
} else {
141144

142145
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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.exception;
20+
21+
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
22+
import org.springframework.security.core.AuthenticationException;
23+
24+
/**
25+
* Exception thrown when a user must reset their password before proceeding.
26+
*
27+
* This exception is thrown during authentication when the user's credentials are valid but they are required to change
28+
* their password (e.g., on first login or after an admin reset). It carries the authenticated user data so the client
29+
* receives enough information to proceed with the password reset flow.
30+
*/
31+
public class PasswordResetRequiredException extends AuthenticationException {
32+
33+
private final AuthenticatedUserData authenticatedUserData;
34+
35+
public PasswordResetRequiredException(AuthenticatedUserData authenticatedUserData) {
36+
super("Password reset required");
37+
this.authenticatedUserData = authenticatedUserData;
38+
}
39+
40+
public AuthenticatedUserData getAuthenticatedUserData() {
41+
return authenticatedUserData;
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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.exception;
20+
21+
import jakarta.ws.rs.core.MediaType;
22+
import jakarta.ws.rs.core.Response;
23+
import jakarta.ws.rs.core.Response.Status;
24+
import jakarta.ws.rs.ext.ExceptionMapper;
25+
import jakarta.ws.rs.ext.Provider;
26+
import lombok.RequiredArgsConstructor;
27+
import lombok.extern.slf4j.Slf4j;
28+
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
29+
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
30+
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
31+
import org.springframework.context.annotation.Scope;
32+
import org.springframework.stereotype.Component;
33+
34+
/**
35+
* An {@link ExceptionMapper} to map {@link PasswordResetRequiredException} thrown during authentication into a HTTP API
36+
* friendly format.
37+
*
38+
* The exception is thrown when a user's credentials are valid but they must reset their password before proceeding.
39+
* This mapper returns a 403 FORBIDDEN response with the authenticated user data, including the
40+
* {@code shouldRenewPassword} flag set to true.
41+
*/
42+
@Provider
43+
@Component
44+
@Scope("singleton")
45+
@Slf4j
46+
@RequiredArgsConstructor
47+
public class PasswordResetRequiredExceptionMapper implements ExceptionMapper<PasswordResetRequiredException> {
48+
49+
private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializer;
50+
51+
@Override
52+
public Response toResponse(final PasswordResetRequiredException exception) {
53+
log.warn("Exception occurred", ErrorHandler.findMostSpecificException(exception));
54+
return Response.status(Status.FORBIDDEN).entity(apiJsonSerializer.serialize(exception.getAuthenticatedUserData()))
55+
.type(MediaType.APPLICATION_JSON).build();
56+
}
57+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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.springframework.security.authentication.CredentialsExpiredException;
22+
import org.springframework.security.core.userdetails.UserDetails;
23+
import org.springframework.security.core.userdetails.UserDetailsChecker;
24+
import org.springframework.stereotype.Component;
25+
26+
/**
27+
* Checks user details during Spring Security authentication. Password reset enforcement is handled by
28+
* SpringSecurityPlatformSecurityContext and AuthenticationApiResource after authentication succeeds.
29+
*/
30+
@Component
31+
public class PlatformUserDetailsChecker implements UserDetailsChecker {
32+
33+
@Override
34+
public void check(UserDetails userDetails) {
35+
if (!userDetails.isCredentialsNonExpired()) {
36+
throw new CredentialsExpiredException("User credentials have expired");
37+
}
38+
}
39+
}

fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/SpringSecurityPlatformSecurityContext.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
4949
private final ConfigurationDomainService configurationDomainService;
5050

5151
protected static final List<CommandWrapper> EXEMPT_FROM_PASSWORD_RESET_CHECK = new ArrayList<CommandWrapper>(
52-
List.of(new CommandWrapperBuilder().updateUser(null).build()));
52+
List.of(new CommandWrapperBuilder().changeUserPassword(null).build()));
5353

5454
@Override
5555
public AppUser authenticatedUser() {
@@ -121,7 +121,7 @@ public AppUser authenticatedUser(CommandWrapper commandWrapper) {
121121
throw new UnAuthenticatedUserException();
122122
}
123123

124-
if (this.shouldCheckForPasswordForceReset(commandWrapper) && this.doesPasswordHasToBeRenewed(currentUser)) {
124+
if (this.shouldCheckForPasswordForceReset(commandWrapper, currentUser) && this.doesPasswordHasToBeRenewed(currentUser)) {
125125
throw new ResetPasswordException(currentUser.getId());
126126
}
127127

@@ -149,6 +149,10 @@ public String officeHierarchy() {
149149
@Override
150150
public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
151151

152+
if (currentUser.isPasswordResetRequired()) {
153+
return true;
154+
}
155+
152156
if (this.configurationDomainService.isPasswordForcedResetEnable() && !currentUser.getPasswordNeverExpires()) {
153157

154158
Long passwordDurationDays = this.configurationDomainService.retrievePasswordLiveTime();
@@ -164,11 +168,11 @@ public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
164168

165169
}
166170

167-
private boolean shouldCheckForPasswordForceReset(CommandWrapper commandWrapper) {
171+
private boolean shouldCheckForPasswordForceReset(CommandWrapper commandWrapper, AppUser currentUser) {
168172
for (CommandWrapper commandItem : EXEMPT_FROM_PASSWORD_RESET_CHECK) {
169173
if (commandItem.actionName().equals(commandWrapper.actionName())
170174
&& commandItem.getEntityName().equals(commandWrapper.getEntityName())) {
171-
return false;
175+
return commandWrapper.getEntityId() == null || !commandWrapper.getEntityId().equals(currentUser.getId());
172176
}
173177
}
174178
return true;

0 commit comments

Comments
 (0)