FINERACT-2003: Enforce password reset on first login

:wq
This commit is contained in:
DeathGun44
2026-02-19 11:14:17 +00:00
committed by Adam Saghy
parent 8b92beb473
commit ca3e980543
16 changed files with 367 additions and 10 deletions
@@ -43,6 +43,7 @@ import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
import org.apache.fineract.infrastructure.security.exception.PasswordResetRequiredException;
import org.apache.fineract.infrastructure.security.service.SpringSecurityPlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.useradministration.data.RoleData;
@@ -86,6 +87,7 @@ public class AuthenticationApiResource {
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationResponse.class)))
@ApiResponse(responseCode = "400", description = "Unauthenticated. Please login")
@ApiResponse(responseCode = "403", description = "Password reset required")
public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson,
@QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList) {
// TODO FINERACT-819: sort out Jersey so JSON conversion does not have
@@ -137,6 +139,7 @@ public class AuthenticationApiResource {
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setUserId(userId)
.setBase64EncodedAuthenticationKey(new String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8))
.setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired);
throw new PasswordResetRequiredException(authenticatedUserData);
} else {
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
@@ -0,0 +1,43 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
import org.springframework.security.core.AuthenticationException;
/**
* Exception thrown when a user must reset their password before proceeding.
*
* This exception is thrown during authentication when the user's credentials are valid but they are required to change
* their password (e.g., on first login or after an admin reset). It carries the authenticated user data so the client
* receives enough information to proceed with the password reset flow.
*/
public class PasswordResetRequiredException extends AuthenticationException {
private final AuthenticatedUserData authenticatedUserData;
public PasswordResetRequiredException(AuthenticatedUserData authenticatedUserData) {
super("Password reset required");
this.authenticatedUserData = authenticatedUserData;
}
public AuthenticatedUserData getAuthenticatedUserData() {
return authenticatedUserData;
}
}
@@ -0,0 +1,57 @@
/**
* 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.exception;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
/**
* An {@link ExceptionMapper} to map {@link PasswordResetRequiredException} thrown during authentication into a HTTP API
* friendly format.
*
* The exception is thrown when a user's credentials are valid but they must reset their password before proceeding.
* This mapper returns a 403 FORBIDDEN response with the authenticated user data, including the
* {@code shouldRenewPassword} flag set to true.
*/
@Provider
@Component
@Scope("singleton")
@Slf4j
@RequiredArgsConstructor
public class PasswordResetRequiredExceptionMapper implements ExceptionMapper<PasswordResetRequiredException> {
private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializer;
@Override
public Response toResponse(final PasswordResetRequiredException exception) {
log.warn("Exception occurred", ErrorHandler.findMostSpecificException(exception));
return Response.status(Status.FORBIDDEN).entity(apiJsonSerializer.serialize(exception.getAuthenticatedUserData()))
.type(MediaType.APPLICATION_JSON).build();
}
}
@@ -49,7 +49,7 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
private final ConfigurationDomainService configurationDomainService;
protected static final List<CommandWrapper> EXEMPT_FROM_PASSWORD_RESET_CHECK = new ArrayList<CommandWrapper>(
List.of(new CommandWrapperBuilder().updateUser(null).build()));
List.of(new CommandWrapperBuilder().changeUserPassword(null).build()));
@Override
public AppUser authenticatedUser() {
@@ -121,7 +121,7 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
throw new UnAuthenticatedUserException();
}
if (this.shouldCheckForPasswordForceReset(commandWrapper) && this.doesPasswordHasToBeRenewed(currentUser)) {
if (this.shouldCheckForPasswordForceReset(commandWrapper, currentUser) && this.doesPasswordHasToBeRenewed(currentUser)) {
throw new ResetPasswordException(currentUser.getId());
}
@@ -149,6 +149,10 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
@Override
public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
if (currentUser.isPasswordResetRequired()) {
return true;
}
if (this.configurationDomainService.isPasswordForcedResetEnable() && !currentUser.getPasswordNeverExpires()) {
Long passwordDurationDays = this.configurationDomainService.retrievePasswordLiveTime();
@@ -164,11 +168,11 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
}
private boolean shouldCheckForPasswordForceReset(CommandWrapper commandWrapper) {
private boolean shouldCheckForPasswordForceReset(CommandWrapper commandWrapper, AppUser currentUser) {
for (CommandWrapper commandItem : EXEMPT_FROM_PASSWORD_RESET_CHECK) {
if (commandItem.actionName().equals(commandWrapper.actionName())
&& commandItem.getEntityName().equals(commandWrapper.getEntityName())) {
return false;
return commandWrapper.getEntityId() == null || !commandWrapper.getEntityId().equals(currentUser.getId());
}
}
return true;