FINERACT-2003: Enforce password reset on first login
:wq
This commit is contained in:
+1
@@ -81,6 +81,7 @@ public final class GlobalConfigurationConstants {
|
|||||||
public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer";
|
public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer";
|
||||||
public static final String ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION = "enable-originator-creation-during-loan-application";
|
public static final String ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION = "enable-originator-creation-during-loan-application";
|
||||||
public static final String PASSWORD_REUSE_CHECK_HISTORY_COUNT = "password-reuse-check-history-count";
|
public static final String PASSWORD_REUSE_CHECK_HISTORY_COUNT = "password-reuse-check-history-count";
|
||||||
|
public static final String FORCE_PASSWORD_RESET_ON_FIRST_LOGIN = "force-password-reset-on-first-login";
|
||||||
|
|
||||||
private GlobalConfigurationConstants() {}
|
private GlobalConfigurationConstants() {}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -153,4 +153,6 @@ public interface ConfigurationDomainService {
|
|||||||
String getAssetOwnerTransferOustandingInterestStrategy();
|
String getAssetOwnerTransferOustandingInterestStrategy();
|
||||||
|
|
||||||
Integer getPasswordReuseRestrictionCount();
|
Integer getPasswordReuseRestrictionCount();
|
||||||
|
|
||||||
|
boolean isForcePasswordResetOnFirstLoginEnabled();
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -131,6 +131,17 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
|
|||||||
@Column(name = "cannot_change_password", nullable = true)
|
@Column(name = "cannot_change_password", nullable = true)
|
||||||
private Boolean cannotChangePassword;
|
private Boolean cannotChangePassword;
|
||||||
|
|
||||||
|
@Column(name = "password_reset_required", nullable = false)
|
||||||
|
private boolean passwordResetRequired;
|
||||||
|
|
||||||
|
public boolean isPasswordResetRequired() {
|
||||||
|
return this.passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updatePasswordResetRequired(final boolean required) {
|
||||||
|
this.passwordResetRequired = required;
|
||||||
|
}
|
||||||
|
|
||||||
public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final Set<Role> allRoles,
|
public static AppUser fromJson(final Office userOffice, final Staff linkedStaff, final Set<Role> allRoles,
|
||||||
final Collection<Client> clients, final JsonCommand command) {
|
final Collection<Client> clients, final JsonCommand command) {
|
||||||
|
|
||||||
|
|||||||
+5
@@ -559,4 +559,9 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
Long value = property.getValue();
|
Long value = property.getValue();
|
||||||
return value != null && value > 0 ? value.intValue() : 0;
|
return value != null && value > 0 ? value.intValue() : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isForcePasswordResetOnFirstLoginEnabled() {
|
||||||
|
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN).isEnabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -45,6 +45,7 @@ import org.apache.fineract.infrastructure.security.data.PlatformRequestLog;
|
|||||||
import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
|
import org.apache.fineract.infrastructure.security.filter.TenantAwareBasicAuthenticationFilter;
|
||||||
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
|
import org.apache.fineract.infrastructure.security.filter.TwoFactorAuthenticationFilter;
|
||||||
import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
|
import org.apache.fineract.infrastructure.security.service.AuthTenantDetailsService;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformUserDetailsChecker;
|
||||||
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
|
import org.apache.fineract.infrastructure.security.service.TenantAwareJpaPlatformUserDetailsService;
|
||||||
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
|
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
|
||||||
import org.apache.fineract.notification.service.UserNotificationService;
|
import org.apache.fineract.notification.service.UserNotificationService;
|
||||||
@@ -115,7 +116,9 @@ public class SecurityConfig {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private IdempotencyStoreHelper idempotencyStoreHelper;
|
private IdempotencyStoreHelper idempotencyStoreHelper;
|
||||||
@Autowired
|
@Autowired
|
||||||
ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
|
private ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
|
||||||
|
@Autowired
|
||||||
|
private PlatformUserDetailsChecker platformUserDetailsChecker;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
@@ -418,6 +421,7 @@ public class SecurityConfig {
|
|||||||
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
||||||
authProvider.setUserDetailsService(userDetailsService);
|
authProvider.setUserDetailsService(userDetailsService);
|
||||||
authProvider.setPasswordEncoder(passwordEncoder());
|
authProvider.setPasswordEncoder(passwordEncoder());
|
||||||
|
authProvider.setPostAuthenticationChecks(platformUserDetailsChecker);
|
||||||
return authProvider;
|
return authProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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.springframework.security.authentication.CredentialsExpiredException;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsChecker;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks user details during Spring Security authentication. Password reset enforcement is handled by
|
||||||
|
* SpringSecurityPlatformSecurityContext and AuthenticationApiResource after authentication succeeds.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PlatformUserDetailsChecker implements UserDetailsChecker {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void check(UserDetails userDetails) {
|
||||||
|
if (!userDetails.isCredentialsNonExpired()) {
|
||||||
|
throw new CredentialsExpiredException("User credentials have expired");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -23,6 +23,7 @@ import io.swagger.v3.oas.annotations.media.Content;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
@@ -53,7 +54,9 @@ public class SelfAuthenticationApiResource {
|
|||||||
@Operation(summary = "Verify authentication", description = "Authenticates the credentials provided and returns the set roles and permissions allowed.\n\n"
|
@Operation(summary = "Verify authentication", description = "Authenticates the credentials provided and returns the set roles and permissions allowed.\n\n"
|
||||||
+ "Please visit this link for more info - https://fineract.apache.org/docs/legacy/#selfbasicauth")
|
+ "Please visit this link for more info - https://fineract.apache.org/docs/legacy/#selfbasicauth")
|
||||||
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
|
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class)))
|
||||||
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class)))
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class))),
|
||||||
|
@ApiResponse(responseCode = "403", description = "Password reset required") })
|
||||||
public String authenticate(final String apiRequestBodyAsJson) {
|
public String authenticate(final String apiRequestBodyAsJson) {
|
||||||
return this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true);
|
return this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -127,6 +127,9 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, command);
|
AppUser appUser = AppUser.fromJson(userOffice, linkedStaff, allRoles, clients, command);
|
||||||
|
if (this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
|
||||||
|
appUser.updatePasswordResetRequired(true);
|
||||||
|
}
|
||||||
|
|
||||||
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
|
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
|
||||||
this.userDomainService.create(appUser, sendPasswordToEmail);
|
this.userDomainService.create(appUser, sendPasswordToEmail);
|
||||||
@@ -160,12 +163,14 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
@Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) })
|
@Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) })
|
||||||
public CommandProcessingResult changeUserPassword(final Long userId, final JsonCommand command) {
|
public CommandProcessingResult changeUserPassword(final Long userId, final JsonCommand command) {
|
||||||
try {
|
try {
|
||||||
this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build());
|
this.context.authenticatedUser(new CommandWrapperBuilder().changeUserPassword(userId).build());
|
||||||
this.fromApiJsonDeserializer.validateForChangePassword(command.json(), this.context.authenticatedUser());
|
this.fromApiJsonDeserializer.validateForChangePassword(command.json(),
|
||||||
|
this.context.authenticatedUser(new CommandWrapperBuilder().changeUserPassword(userId).build()));
|
||||||
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
|
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
|
||||||
final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
|
final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
|
||||||
final Map<String, Object> changes = userToUpdate.changePassword(command, this.platformPasswordEncoder);
|
final Map<String, Object> changes = userToUpdate.changePassword(command, this.platformPasswordEncoder);
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
|
userToUpdate.updatePasswordResetRequired(false);
|
||||||
this.appUserRepository.saveAndFlush(userToUpdate);
|
this.appUserRepository.saveAndFlush(userToUpdate);
|
||||||
if (currentPasswordToSaveAsPreview != null) {
|
if (currentPasswordToSaveAsPreview != null) {
|
||||||
this.appUserPreviewPasswordRepository.save(currentPasswordToSaveAsPreview);
|
this.appUserPreviewPasswordRepository.save(currentPasswordToSaveAsPreview);
|
||||||
@@ -190,9 +195,9 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
@Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) })
|
@Caching(evict = { @CacheEvict(value = "users", allEntries = true), @CacheEvict(value = "usersByUsername", allEntries = true) })
|
||||||
public CommandProcessingResult updateUser(final Long userId, final JsonCommand command) {
|
public CommandProcessingResult updateUser(final Long userId, final JsonCommand command) {
|
||||||
try {
|
try {
|
||||||
this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build());
|
final AppUser currentUser = this.context.authenticatedUser(new CommandWrapperBuilder().updateUser(null).build());
|
||||||
|
|
||||||
this.fromApiJsonDeserializer.validateForUpdate(command.json(), this.context.authenticatedUser());
|
this.fromApiJsonDeserializer.validateForUpdate(command.json(), currentUser);
|
||||||
|
|
||||||
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
|
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
|
||||||
|
|
||||||
@@ -238,6 +243,10 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
|
if ((changes.containsKey("password") || changes.containsKey("passwordEncoded")) && !currentUser.getId().equals(userId)
|
||||||
|
&& this.configurationDomainService.isForcePasswordResetOnFirstLoginEnabled()) {
|
||||||
|
userToUpdate.updatePasswordResetRequired(true);
|
||||||
|
}
|
||||||
this.appUserRepository.saveAndFlush(userToUpdate);
|
this.appUserRepository.saveAndFlush(userToUpdate);
|
||||||
|
|
||||||
if (currentPasswordToSaveAsPreview != null) {
|
if (currentPasswordToSaveAsPreview != null) {
|
||||||
|
|||||||
@@ -230,4 +230,5 @@
|
|||||||
<include file="parts/0209_transaction_summary_with_asset_owner_and_from_asset_owner_id_for_asset_sales.xml" relativeToChangelogFile="true" />
|
<include file="parts/0209_transaction_summary_with_asset_owner_and_from_asset_owner_id_for_asset_sales.xml" relativeToChangelogFile="true" />
|
||||||
<include file="parts/0210_add_configuration_password_reuse_check_history_count.xml" relativeToChangelogFile="true" />
|
<include file="parts/0210_add_configuration_password_reuse_check_history_count.xml" relativeToChangelogFile="true" />
|
||||||
<include file="parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml" relativeToChangelogFile="true" />
|
<include file="parts/0211_trial_balance_summary_fix_external_owners_and_aggregation.xml" relativeToChangelogFile="true" />
|
||||||
|
<include file="parts/0212_add_force_password_reset_config.xml" relativeToChangelogFile="true" />
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||||
|
<changeSet author="fineract" id="1">
|
||||||
|
<addColumn tableName="m_appuser">
|
||||||
|
<column name="password_reset_required" type="boolean" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="3">
|
||||||
|
<insert tableName="c_configuration">
|
||||||
|
<column name="name" value="force-password-reset-on-first-login"/>
|
||||||
|
<column name="value" valueNumeric="0"/>
|
||||||
|
<column name="enabled" valueBoolean="false"/>
|
||||||
|
<column name="is_trap_door" valueBoolean="false"/>
|
||||||
|
<column name="description" value="If enabled, users must reset their password upon first login or after an admin reset. Value is unused."/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
+3
@@ -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.core.serialization.ToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
|
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
|
||||||
import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData;
|
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.infrastructure.security.service.SpringSecurityPlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
|
||||||
import org.apache.fineract.useradministration.data.RoleData;
|
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)))
|
@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 = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationResponse.class)))
|
||||||
@ApiResponse(responseCode = "400", description = "Unauthenticated. Please login")
|
@ApiResponse(responseCode = "400", description = "Unauthenticated. Please login")
|
||||||
|
@ApiResponse(responseCode = "403", description = "Password reset required")
|
||||||
public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson,
|
public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson,
|
||||||
@QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList) {
|
@QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList) {
|
||||||
// TODO FINERACT-819: sort out Jersey so JSON conversion does not have
|
// 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)
|
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setUserId(userId)
|
||||||
.setBase64EncodedAuthenticationKey(new String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8))
|
.setBase64EncodedAuthenticationKey(new String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8))
|
||||||
.setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired);
|
.setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired);
|
||||||
|
throw new PasswordResetRequiredException(authenticatedUserData);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
|
authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId)
|
||||||
|
|||||||
+43
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-4
@@ -49,7 +49,7 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
|
|||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
|
||||||
protected static final List<CommandWrapper> EXEMPT_FROM_PASSWORD_RESET_CHECK = new ArrayList<CommandWrapper>(
|
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
|
@Override
|
||||||
public AppUser authenticatedUser() {
|
public AppUser authenticatedUser() {
|
||||||
@@ -121,7 +121,7 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
|
|||||||
throw new UnAuthenticatedUserException();
|
throw new UnAuthenticatedUserException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldCheckForPasswordForceReset(commandWrapper) && this.doesPasswordHasToBeRenewed(currentUser)) {
|
if (this.shouldCheckForPasswordForceReset(commandWrapper, currentUser) && this.doesPasswordHasToBeRenewed(currentUser)) {
|
||||||
throw new ResetPasswordException(currentUser.getId());
|
throw new ResetPasswordException(currentUser.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +149,10 @@ public class SpringSecurityPlatformSecurityContext implements PlatformSecurityCo
|
|||||||
@Override
|
@Override
|
||||||
public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
|
public boolean doesPasswordHasToBeRenewed(AppUser currentUser) {
|
||||||
|
|
||||||
|
if (currentUser.isPasswordResetRequired()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationDomainService.isPasswordForcedResetEnable() && !currentUser.getPasswordNeverExpires()) {
|
if (this.configurationDomainService.isPasswordForcedResetEnable() && !currentUser.getPasswordNeverExpires()) {
|
||||||
|
|
||||||
Long passwordDurationDays = this.configurationDomainService.retrievePasswordLiveTime();
|
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) {
|
for (CommandWrapper commandItem : EXEMPT_FROM_PASSWORD_RESET_CHECK) {
|
||||||
if (commandItem.actionName().equals(commandWrapper.actionName())
|
if (commandItem.actionName().equals(commandWrapper.actionName())
|
||||||
&& commandItem.getEntityName().equals(commandWrapper.getEntityName())) {
|
&& commandItem.getEntityName().equals(commandWrapper.getEntityName())) {
|
||||||
return false;
|
return commandWrapper.getEntityId() == null || !commandWrapper.getEntityId().equals(currentUser.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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.integrationtests;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import io.restassured.RestAssured;
|
||||||
|
import io.restassured.builder.RequestSpecBuilder;
|
||||||
|
import io.restassured.builder.ResponseSpecBuilder;
|
||||||
|
import io.restassured.http.ContentType;
|
||||||
|
import io.restassured.response.Response;
|
||||||
|
import io.restassured.specification.RequestSpecification;
|
||||||
|
import io.restassured.specification.ResponseSpecification;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.client.models.PostUsersRequest;
|
||||||
|
import org.apache.fineract.client.models.PostUsersResponse;
|
||||||
|
import org.apache.fineract.client.models.PutGlobalConfigurationsRequest;
|
||||||
|
import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants;
|
||||||
|
import org.apache.fineract.integrationtests.client.IntegrationTest;
|
||||||
|
import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper;
|
||||||
|
import org.apache.fineract.integrationtests.common.Utils;
|
||||||
|
import org.apache.fineract.integrationtests.useradministration.users.UserHelper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class PasswordResetIntegrationTest extends IntegrationTest {
|
||||||
|
|
||||||
|
private RequestSpecification requestSpec;
|
||||||
|
private ResponseSpecification responseSpec;
|
||||||
|
private GlobalConfigurationHelper globalConfigurationHelper;
|
||||||
|
private List<Integer> transientUsers = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setup() {
|
||||||
|
Utils.initializeRESTAssured();
|
||||||
|
this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build();
|
||||||
|
this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey());
|
||||||
|
this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build();
|
||||||
|
this.globalConfigurationHelper = new GlobalConfigurationHelper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
public void tearDown() {
|
||||||
|
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
|
||||||
|
new PutGlobalConfigurationsRequest().value(0L).enabled(false));
|
||||||
|
|
||||||
|
for (Integer userId : this.transientUsers) {
|
||||||
|
UserHelper.deleteUser(this.requestSpec, this.responseSpec, userId);
|
||||||
|
}
|
||||||
|
this.transientUsers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPasswordResetEnforcement() {
|
||||||
|
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
|
||||||
|
new PutGlobalConfigurationsRequest().value(0L).enabled(true));
|
||||||
|
|
||||||
|
String password = "Abcdef1#2$3%XYZ";
|
||||||
|
PostUsersRequest userRequest = UserHelper.buildUserRequest(responseSpec, requestSpec, password);
|
||||||
|
PostUsersResponse userResponse = UserHelper.createUser(requestSpec, responseSpec, userRequest);
|
||||||
|
Long userId = userResponse.getResourceId();
|
||||||
|
assertNotNull(userId, "User creation failed to return an ID!");
|
||||||
|
this.transientUsers.add(userId.intValue());
|
||||||
|
String username = userRequest.getUsername();
|
||||||
|
|
||||||
|
Response loginResponse = attemptLogin(username, password);
|
||||||
|
assertEquals(403, loginResponse.getStatusCode(), "User should be forced to change password");
|
||||||
|
|
||||||
|
String newPassword = "Abcdef1#2$3%XYZ_NEW";
|
||||||
|
Response changePasswordResponse = changePassword(username, password, userId, newPassword);
|
||||||
|
assertEquals(200, changePasswordResponse.getStatusCode(), "Password change should succeed");
|
||||||
|
|
||||||
|
loginResponse = attemptLogin(username, newPassword);
|
||||||
|
assertEquals(200, loginResponse.getStatusCode(), "User should be able to login after reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFeatureDisabledByDefault() {
|
||||||
|
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN,
|
||||||
|
new PutGlobalConfigurationsRequest().value(0L).enabled(false));
|
||||||
|
|
||||||
|
String password = "Abcdef1#2$3%XYZ";
|
||||||
|
PostUsersRequest userRequest = UserHelper.buildUserRequest(responseSpec, requestSpec, password);
|
||||||
|
PostUsersResponse userResponse = UserHelper.createUser(requestSpec, responseSpec, userRequest);
|
||||||
|
assertNotNull(userResponse.getResourceId(), "User creation failed!");
|
||||||
|
this.transientUsers.add(userResponse.getResourceId().intValue());
|
||||||
|
String username = userRequest.getUsername();
|
||||||
|
|
||||||
|
Response loginResponse = attemptLogin(username, password);
|
||||||
|
assertEquals(200, loginResponse.getStatusCode(), "User should login normally when feature is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response attemptLogin(String username, String password) {
|
||||||
|
return RestAssured.given().contentType(ContentType.JSON)
|
||||||
|
.body("{\"username\":\"" + username + "\", \"password\":\"" + password + "\"}")
|
||||||
|
.post("/fineract-provider/api/v1/authentication?" + Utils.TENANT_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response changePassword(String username, String password, Long userId, String newPassword) {
|
||||||
|
String authKey = java.util.Base64.getEncoder().encodeToString((username + ":" + password).getBytes(UTF_8));
|
||||||
|
return RestAssured.given().contentType(ContentType.JSON).header("Authorization", "Basic " + authKey)
|
||||||
|
.header("Fineract-Platform-TenantId", "default")
|
||||||
|
.body("{\"password\":\"" + newPassword + "\", \"repeatPassword\":\"" + newPassword + "\"}")
|
||||||
|
.post("/fineract-provider/api/v1/users/" + userId + "/pwd?" + Utils.TENANT_IDENTIFIER);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -231,6 +231,13 @@ public class GlobalConfigurationHelper {
|
|||||||
graceOnPenaltyPostingDefault.put("trapDoor", false);
|
graceOnPenaltyPostingDefault.put("trapDoor", false);
|
||||||
defaults.add(graceOnPenaltyPostingDefault);
|
defaults.add(graceOnPenaltyPostingDefault);
|
||||||
|
|
||||||
|
HashMap<String, Object> forcePasswordResetOnFirstLoginDefault = new HashMap<>();
|
||||||
|
forcePasswordResetOnFirstLoginDefault.put("name", GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN);
|
||||||
|
forcePasswordResetOnFirstLoginDefault.put("value", 0L);
|
||||||
|
forcePasswordResetOnFirstLoginDefault.put("enabled", false);
|
||||||
|
forcePasswordResetOnFirstLoginDefault.put("trapDoor", false);
|
||||||
|
defaults.add(forcePasswordResetOnFirstLoginDefault);
|
||||||
|
|
||||||
HashMap<String, Object> savingsInterestPostingCurrentPeriodEndDefault = new HashMap<>();
|
HashMap<String, Object> savingsInterestPostingCurrentPeriodEndDefault = new HashMap<>();
|
||||||
savingsInterestPostingCurrentPeriodEndDefault.put("name", GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END);
|
savingsInterestPostingCurrentPeriodEndDefault.put("name", GlobalConfigurationConstants.SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END);
|
||||||
savingsInterestPostingCurrentPeriodEndDefault.put("value", 0L);
|
savingsInterestPostingCurrentPeriodEndDefault.put("value", 0L);
|
||||||
|
|||||||
Reference in New Issue
Block a user