Skip to content

Commit 592ce22

Browse files
committed
FINERACT-2003: Enforce password reset on first login
1 parent f3bda3a commit 592ce22

File tree

15 files changed

+263
-8
lines changed

15 files changed

+263
-8
lines changed

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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import jakarta.ws.rs.Produces;
3434
import jakarta.ws.rs.QueryParam;
3535
import jakarta.ws.rs.core.MediaType;
36+
import jakarta.ws.rs.core.Response;
3637
import java.nio.charset.StandardCharsets;
3738
import java.util.ArrayList;
3839
import java.util.Base64;
@@ -86,7 +87,8 @@ 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")
89-
public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson,
90+
@ApiResponse(responseCode = "403", description = "Password reset required")
91+
public Response 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
9294
// to be done explicitly via GSON here, but implicit by arg
@@ -137,6 +139,9 @@ 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+
// Return 403 Forbidden when password reset is required
143+
return Response.status(Response.Status.FORBIDDEN).entity(this.apiJsonSerializerService.serialize(authenticatedUserData))
144+
.type(MediaType.APPLICATION_JSON).build();
140145
} else {
141146

142147
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
@@ -151,6 +156,6 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody
151156

152157
}
153158

154-
return this.apiJsonSerializerService.serialize(authenticatedUserData);
159+
return Response.ok(this.apiJsonSerializerService.serialize(authenticatedUserData)).type(MediaType.APPLICATION_JSON).build();
155160
}
156161
}
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import jakarta.ws.rs.Path;
3131
import jakarta.ws.rs.Produces;
3232
import jakarta.ws.rs.core.MediaType;
33+
import jakarta.ws.rs.core.Response;
3334
import lombok.RequiredArgsConstructor;
3435
import org.apache.fineract.infrastructure.security.api.AuthenticationApiResource;
3536
import org.apache.fineract.infrastructure.security.api.AuthenticationApiResourceSwagger;
@@ -55,8 +56,9 @@ public class SelfAuthenticationApiResource {
5556
+ "Please visit this link for more info - https://fineract.apache.org/docs/legacy/#selfbasicauth")
5657
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
5758
@ApiResponses({
58-
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class))) })
59-
public String authenticate(final String apiRequestBodyAsJson) {
59+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class))),
60+
@ApiResponse(responseCode = "403", description = "Password reset required") })
61+
public Response authenticate(final String apiRequestBodyAsJson) {
6062
return this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true);
6163
}
6264
}

fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import lombok.extern.slf4j.Slf4j;
3333
import org.apache.commons.lang3.exception.ExceptionUtils;
3434
import org.apache.fineract.commands.service.CommandWrapperBuilder;
35+
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
3536
import org.apache.fineract.infrastructure.core.api.JsonCommand;
3637
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
3738
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
@@ -83,6 +84,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
8384
private final AppUserPreviousPasswordRepository appUserPreviewPasswordRepository;
8485
private final StaffRepositoryWrapper staffRepositoryWrapper;
8586
private final ClientRepositoryWrapper clientRepositoryWrapper;
87+
private final ConfigurationDomainService configurationDomainService;
8688

8789
@Override
8890
@Transactional
@@ -126,6 +128,9 @@ public CommandProcessingResult createUser(final JsonCommand command) {
126128
}
127129

128130
AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, command);
131+
if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
132+
appUser.updatePasswordResetRequired(true);
133+
}
129134

130135
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
131136
this.userDomainService.create(appUser, sendPasswordToEmail);
@@ -165,6 +170,7 @@ public CommandProcessingResult changeUserPassword(final Long userId, final JsonC
165170
final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
166171
final Map<String, Object> changes = userToUpdate.changePassword(command, this.platformPasswordEncoder);
167172
if (!changes.isEmpty()) {
173+
userToUpdate.updatePasswordResetRequired(false);
168174
this.appUserRepository.saveAndFlush(userToUpdate);
169175
if (currentPasswordToSaveAsPreview != null) {
170176
this.appUserPreviewPasswordRepository.save(currentPasswordToSaveAsPreview);
@@ -189,9 +195,9 @@ public CommandProcessingResult changeUserPassword(final Long userId, final JsonC
189195
@Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) })
190196
public CommandProcessingResult updateUser(final Long userId, final JsonCommand command) {
191197
try {
192-
this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build());
198+
final AppUser currentUser = this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build());
193199

194-
this.fromApiJsonDeserializer.validateForUpdate(command.json(), this.context.authenticatedUser());
200+
this.fromApiJsonDeserializer.validateForUpdate(command.json(), currentUser);
195201

196202
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
197203

@@ -237,6 +243,13 @@ public CommandProcessingResult updateUser(final Long userId, final JsonCommand c
237243
}
238244

239245
if (!changes.isEmpty()) {
246+
if (changes.containsKey("password") || changes.containsKey("passwordEncoded")) {
247+
if (!currentUser.getId().equals(userId) && this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
248+
userToUpdate.updatePasswordResetRequired(true);
249+
} else if (currentUser.getId().equals(userId)) {
250+
userToUpdate.updatePasswordResetRequired(false);
251+
}
252+
}
240253
this.appUserRepository.saveAndFlush(userToUpdate);
241254

242255
if (currentPasswordToSaveAsPreview != null) {

0 commit comments

Comments
 (0)