This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 3c242a88e1 FINERACT-2004: Limit login retries
3c242a88e1 is described below
commit 3c242a88e11adfc33b57abf2a2f9bb1e3974e6ea
Author: airajena <[email protected]>
AuthorDate: Wed Feb 4 20:28:00 2026 +0530
FINERACT-2004: Limit login retries
---
.../api/GlobalConfigurationConstants.java | 1 +
.../domain/ConfigurationDomainService.java | 4 +
.../useradministration/domain/AppUser.java | 58 +++++-
.../service/AppUserConstants.java | 1 +
.../domain/ConfigurationDomainServiceJpa.java | 12 ++
.../service/LoginAttemptEventListener.java | 106 +++++++++++
.../useradministration/api/UsersApiResource.java | 2 +-
.../api/UsersApiResourceSwagger.java | 4 +
.../service/UserDataValidator.java | 30 ++-
.../db/changelog/tenant/changelog-tenant.xml | 1 +
.../parts/0220_add_login_retry_configuration.xml | 50 +++++
.../main/resources/static/legacy-docs/apiLive.htm | 1 +
.../service/LoginAttemptEventListenerTest.java | 204 +++++++++++++++++++++
.../common/GlobalConfigurationHelper.java | 6 +
14 files changed, 474 insertions(+), 6 deletions(-)
diff --git
a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java
index edaa38bc97..79ba77853c 100644
---
a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java
+++
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java
@@ -79,6 +79,7 @@ public final class GlobalConfigurationConstants {
public static final String
ASSET_OWNER_TRANSFER_OUTSTANDING_INTEREST_CALCULATION_STRATEGY =
"outstanding-interest-calculation-strategy-for-external-asset-transfer";
public static final String
ALLOWED_LOAN_STATUSES_FOR_EXTERNAL_ASSET_TRANSFER =
"allowed-loan-statuses-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 MAX_LOGIN_RETRY_ATTEMPTS =
"max-login-retry-attempts";
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 FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT =
"allow-force-withdrawal-on-savings-account";
diff --git
a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java
index 2f88120cf2..f091017bd4 100644
---
a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java
+++
b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java
@@ -159,4 +159,8 @@ public interface ConfigurationDomainService {
Integer getPasswordReuseRestrictionCount();
boolean isForcePasswordResetOnFirstLoginEnabled();
+
+ boolean isMaxLoginRetriesEnabled();
+
+ Integer retrieveMaxLoginRetries();
}
diff --git
a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java
b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java
index 528b8804a5..409b05e911 100644
---
a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java
+++
b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java
@@ -83,6 +83,14 @@ public class AppUser extends AbstractPersistableCustom<Long>
implements Platform
@Column(name = "nonlocked", nullable = false)
private boolean accountNonLocked;
+ @Getter
+ @Column(name = "failed_login_attempts", nullable = false)
+ private int failedLoginAttempts;
+
+ @Getter
+ @Column(name = "is_login_retries_enabled", nullable = false)
+ private boolean loginRetryLimitEnabled;
+
@Column(name = "nonexpired_credentials", nullable = false)
private boolean credentialsNonExpired;
@@ -154,6 +162,10 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
final boolean userCredentialsNonExpired = true;
final boolean userAccountNonLocked = true;
final boolean cannotChangePassword = false;
+ boolean loginRetryLimitEnabled = false;
+ if
(command.parameterExists(AppUserConstants.IS_LOGIN_RETRIES_ENABLED)) {
+ loginRetryLimitEnabled =
command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_LOGIN_RETRIES_ENABLED);
+ }
final Collection<SimpleGrantedAuthority> authorities = new
ArrayList<>();
authorities.add(new
SimpleGrantedAuthority("DUMMY_ROLE_NOT_USED_OR_PERSISTED_TO_AVOID_EXCEPTION"));
@@ -165,13 +177,18 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
final String firstname =
command.stringValueOfParameterNamed("firstname");
final String lastname =
command.stringValueOfParameterNamed("lastname");
- return new AppUser(userOffice, user, allRoles, email, firstname,
lastname, linkedStaff, passwordNeverExpire, cannotChangePassword);
+ final AppUser appUser = new AppUser(userOffice, user, allRoles, email,
firstname, lastname, linkedStaff, passwordNeverExpire,
+ cannotChangePassword);
+
appUser.updateLoginRetryLimitEnabled(resolveLoginRetryLimitEnabled(username,
loginRetryLimitEnabled));
+ return appUser;
}
protected AppUser() {
this.accountNonLocked = false;
this.credentialsNonExpired = false;
this.roles = new HashSet<>();
+ this.failedLoginAttempts = 0;
+ this.loginRetryLimitEnabled = false;
}
public AppUser(final Office office, final User user, final Set<Role>
roles, final String email, final String firstname,
@@ -192,6 +209,8 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
this.staff = staff;
this.passwordNeverExpires = passwordNeverExpire;
this.cannotChangePassword = cannotChangePassword;
+ this.failedLoginAttempts = 0;
+ this.loginRetryLimitEnabled = false;
}
public EnumOptionData organisationalRoleData() {
@@ -313,6 +332,14 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
this.passwordNeverExpires = newValue;
}
+ if (command.hasParameter(AppUserConstants.IS_LOGIN_RETRIES_ENABLED)) {
+ final boolean requestedValue =
command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_LOGIN_RETRIES_ENABLED);
+ final boolean effectiveValue =
resolveLoginRetryLimitEnabled(this.username, requestedValue);
+ if (effectiveValue != this.loginRetryLimitEnabled) {
+ actualChanges.put(AppUserConstants.IS_LOGIN_RETRIES_ENABLED,
effectiveValue);
+ updateLoginRetryLimitEnabled(effectiveValue);
+ }
+ }
return actualChanges;
}
@@ -400,6 +427,28 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
return this.accountNonLocked;
}
+ public void registerFailedLoginAttempt(int maxRetries) {
+ if (!this.loginRetryLimitEnabled) {
+ return;
+ }
+ this.failedLoginAttempts = this.failedLoginAttempts + 1;
+ if (maxRetries > 0 && this.failedLoginAttempts >= maxRetries) {
+ this.accountNonLocked = false;
+ }
+ }
+
+ public void resetFailedLoginAttempts() {
+ this.failedLoginAttempts = 0;
+ }
+
+ public void updateLoginRetryLimitEnabled(final boolean
loginRetryLimitEnabled) {
+ this.loginRetryLimitEnabled =
resolveLoginRetryLimitEnabled(this.username, loginRetryLimitEnabled);
+ if (!this.loginRetryLimitEnabled) {
+ this.failedLoginAttempts = 0;
+ this.accountNonLocked = true;
+ }
+ }
+
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
@@ -644,6 +693,13 @@ public class AppUser extends
AbstractPersistableCustom<Long> implements Platform
return !isEnabled();
}
+ private static boolean resolveLoginRetryLimitEnabled(final String
username, final boolean requestedValue) {
+ if (AppUserConstants.SYSTEM_USER_NAME.equalsIgnoreCase(username)) {
+ return false;
+ }
+ return requestedValue;
+ }
+
@Override
public String toString() {
return "AppUser [username=" + this.username + ", getId()=" +
this.getId() + "]";
diff --git
a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java
b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java
index 86af3f81e3..0af7d62d39 100644
---
a/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java
+++
b/fineract-core/src/main/java/org/apache/fineract/useradministration/service/AppUserConstants.java
@@ -27,6 +27,7 @@ public final class AppUserConstants {
public static final String PASSWORD = "password";
public static final String REPEAT_PASSWORD = "repeatPassword";
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
+ public static final String IS_LOGIN_RETRIES_ENABLED =
"isLoginRetriesEnabled";
// TODO: Remove hard coding of system user name and make this a
configurable parameter
public static final String SYSTEM_USER_NAME = "system";
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java
index 42838c3064..1b0d7fa725 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java
@@ -574,4 +574,16 @@ public class ConfigurationDomainServiceJpa implements
ConfigurationDomainService
public boolean isForcePasswordResetOnFirstLoginEnabled() {
return
getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FORCE_PASSWORD_RESET_ON_FIRST_LOGIN).isEnabled();
}
+
+ @Override
+ public boolean isMaxLoginRetriesEnabled() {
+ return
getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAX_LOGIN_RETRY_ATTEMPTS).isEnabled();
+ }
+
+ @Override
+ public Integer retrieveMaxLoginRetries() {
+ final GlobalConfigurationPropertyData property =
getGlobalConfigurationPropertyData(
+ GlobalConfigurationConstants.MAX_LOGIN_RETRY_ATTEMPTS);
+ return property.getValue() == null ? null :
property.getValue().intValue();
+ }
}
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java
new file mode 100644
index 0000000000..8fef98d16d
--- /dev/null
+++
b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListener.java
@@ -0,0 +1,106 @@
+/**
+ * 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 lombok.RequiredArgsConstructor;
+import
org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.apache.fineract.useradministration.domain.AppUserRepository;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.context.event.EventListener;
+import org.springframework.security.authentication.LockedException;
+import
org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
+import
org.springframework.security.authentication.event.AuthenticationSuccessEvent;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Component
+@RequiredArgsConstructor
+public class LoginAttemptEventListener {
+
+ private static final String USERS_CACHE = "users";
+ private static final String USERS_BY_USERNAME_CACHE = "usersByUsername";
+
+ private final ConfigurationDomainService configurationDomainService;
+ private final AppUserRepository appUserRepository;
+ private final CacheManager cacheManager;
+
+ @Transactional
+ @EventListener
+ public void onAuthenticationFailure(final
AbstractAuthenticationFailureEvent event) {
+ if (!configurationDomainService.isMaxLoginRetriesEnabled()) {
+ return;
+ }
+ final Integer maxRetries =
configurationDomainService.retrieveMaxLoginRetries();
+ if (maxRetries == null || maxRetries <= 0) {
+ return;
+ }
+
+ Authentication authentication = event.getAuthentication();
+ if (authentication == null ||
!StringUtils.hasText(authentication.getName())) {
+ return;
+ }
+
+ AuthenticationException exception = event.getException();
+ if (exception instanceof LockedException) {
+ return;
+ }
+
+ AppUser user =
appUserRepository.findAppUserByName(authentication.getName());
+ if (user == null || !user.isAccountNonLocked() ||
!user.isLoginRetryLimitEnabled()) {
+ return;
+ }
+
+ user.registerFailedLoginAttempt(maxRetries);
+ appUserRepository.saveAndFlush(user);
+ evictUserCaches();
+ }
+
+ @Transactional
+ @EventListener
+ public void onAuthenticationSuccess(final AuthenticationSuccessEvent
event) {
+ Authentication authentication = event.getAuthentication();
+ if (authentication == null || !(authentication.getPrincipal()
instanceof AppUser user)) {
+ return;
+ }
+
+ if (user.getFailedLoginAttempts() <= 0) {
+ return;
+ }
+
+ user.resetFailedLoginAttempts();
+ appUserRepository.saveAndFlush(user);
+ evictUserCaches();
+ }
+
+ private void evictUserCaches() {
+ Cache usersCache = cacheManager.getCache(USERS_CACHE);
+ if (usersCache != null) {
+ usersCache.clear();
+ }
+ Cache usersByUsernameCache =
cacheManager.getCache(USERS_BY_USERNAME_CACHE);
+ if (usersByUsernameCache != null) {
+ usersByUsernameCache.clear();
+ }
+ }
+}
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java
index 22df6270e0..d2a40afdd2 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java
@@ -151,7 +151,7 @@ public class UsersApiResource {
@Operation(summary = "Create a User", description = "Adds new application
user.\n" + "\n"
+ "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"
+ "\n" + "Mandatory Fields: \n" + "username, firstname, lastname,
email, officeId, roles, sendPasswordToEmail\n" + "\n"
- + "Optional Fields: \n" + "staffId,passwordNeverExpires")
+ + "Optional Fields: \n" +
"staffId,passwordNeverExpires,isLoginRetriesEnabled")
@RequestBody(required = true, content = @Content(schema =
@Schema(implementation = UsersApiResourceSwagger.PostUsersRequest.class)))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content =
@Content(schema = @Schema(implementation =
UsersApiResourceSwagger.PostUsersResponse.class))) })
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java
index 2acaff4999..5e24a3f706 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java
@@ -133,6 +133,8 @@ final class UsersApiResourceSwagger {
public Boolean sendPasswordToEmail;
@Schema(example = "true")
public Boolean passwordNeverExpires;
+ @Schema(example = "true")
+ public Boolean isLoginRetriesEnabled;
}
@Schema(description = "PostUsersResponse")
@@ -212,6 +214,8 @@ final class UsersApiResourceSwagger {
public String repeatPassword;
@Schema(example = "true")
public Boolean sendPasswordToEmail;
+ @Schema(example = "true")
+ public Boolean isLoginRetriesEnabled;
}
@Schema(description = "PutUsersUserIdResponse")
diff --git
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java
index fe133e714b..22aa2cade0 100644
---
a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java
+++
b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java
@@ -59,10 +59,12 @@ public final class UserDataValidator {
/**
* The parameters supported for this command.
*/
- private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new
HashSet<>(Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD,
- REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES));
- private static final Set<String> UPDATE_SUPPORTED_PARAMETERS = new
HashSet<>(Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD,
- REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES));
+ private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new
HashSet<>(
+ Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD,
REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
+ SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES,
AppUserConstants.IS_LOGIN_RETRIES_ENABLED));
+ private static final Set<String> UPDATE_SUPPORTED_PARAMETERS = new
HashSet<>(
+ Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD,
REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
+ SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES,
AppUserConstants.IS_LOGIN_RETRIES_ENABLED));
private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS =
new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
@@ -126,6 +128,16 @@ public final class UserDataValidator {
baseDataValidator.reset().parameter(PASSWORD_NEVER_EXPIRE).value(passwordNeverExpire).validateForBooleanValue();
}
+ if
(this.fromApiJsonHelper.parameterExists(AppUserConstants.IS_LOGIN_RETRIES_ENABLED,
element)) {
+ final Boolean isLoginRetriesEnabled =
this.fromApiJsonHelper.extractBooleanNamed(AppUserConstants.IS_LOGIN_RETRIES_ENABLED,
+ element);
+ if (isLoginRetriesEnabled == null) {
+
baseDataValidator.reset().parameter(AppUserConstants.IS_LOGIN_RETRIES_ENABLED).trueOrFalseRequired(false);
+ } else {
+
baseDataValidator.reset().parameter(AppUserConstants.IS_LOGIN_RETRIES_ENABLED).value(isLoginRetriesEnabled)
+ .validateForBooleanValue();
+ }
+ }
final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES,
element);
baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty();
@@ -239,6 +251,16 @@ public final class UserDataValidator {
baseDataValidator.reset().parameter(PASSWORD_NEVER_EXPIRE).value(passwordNeverExpire).validateForBooleanValue();
}
+ if
(this.fromApiJsonHelper.parameterExists(AppUserConstants.IS_LOGIN_RETRIES_ENABLED,
element)) {
+ final Boolean isLoginRetriesEnabled =
this.fromApiJsonHelper.extractBooleanNamed(AppUserConstants.IS_LOGIN_RETRIES_ENABLED,
+ element);
+ if (isLoginRetriesEnabled == null) {
+
baseDataValidator.reset().parameter(AppUserConstants.IS_LOGIN_RETRIES_ENABLED).trueOrFalseRequired(false);
+ } else {
+
baseDataValidator.reset().parameter(AppUserConstants.IS_LOGIN_RETRIES_ENABLED).value(isLoginRetriesEnabled)
+ .validateForBooleanValue();
+ }
+ }
throwExceptionIfValidationWarningsExist(dataValidationErrors);
validateFieldLevelACL(json, authenticatedUser);
}
diff --git
a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
index 6ce8d437d8..146469611c 100644
---
a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
+++
b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
@@ -238,4 +238,5 @@
<include file="parts/0217_force_withdrawal_configs.xml"
relativeToChangelogFile="true" />
<include file="parts/0218_standardize_character_set_and_collation.xml"
relativeToChangelogFile="true" />
<include file="parts/0219_remove_self_service_feature.xml"
relativeToChangelogFile="true"/>
+ <include file="parts/0220_add_login_retry_configuration.xml"
relativeToChangelogFile="true" />
</databaseChangeLog>
diff --git
a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_login_retry_configuration.xml
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_login_retry_configuration.xml
new file mode 100644
index 0000000000..5c80dc73af
--- /dev/null
+++
b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_login_retry_configuration.xml
@@ -0,0 +1,50 @@
+<!--
+
+ 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" context="postgresql">
+ <sql>
+ SELECT SETVAL('c_configuration_id_seq', COALESCE(MAX(id), 0)+1,
false ) FROM c_configuration;
+ </sql>
+ </changeSet>
+ <changeSet author="fineract" id="2">
+ <addColumn tableName="m_appuser">
+ <column name="failed_login_attempts" type="INT"
defaultValueNumeric="0">
+ <constraints nullable="false"/>
+ </column>
+ <column name="is_login_retries_enabled" type="BOOLEAN"
defaultValueBoolean="false">
+ <constraints nullable="false"/>
+ </column>
+ </addColumn>
+ </changeSet>
+ <changeSet author="fineract" id="3">
+ <insert tableName="c_configuration">
+ <column name="name" value="max-login-retry-attempts"/>
+ <column name="value" valueNumeric="5"/>
+ <column name="date_value"/>
+ <column name="string_value"/>
+ <column name="enabled" valueBoolean="false"/>
+ <column name="is_trap_door" valueBoolean="false"/>
+ <column name="description" value="Maximum number of failed login
attempts before an account is locked"/>
+ </insert>
+ </changeSet>
+</databaseChangeLog>
diff --git
a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm
b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm
index de43963827..bbebbe7aa3 100644
--- a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm
+++ b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm
@@ -18436,6 +18436,7 @@ Request Body:
<li><b>savings-interest-posting-current-period-end</b> - Set it at the database
level before any savings interest is posted. When set as false(default),
interest will be posted on the first date of next period. If set as true,
interest will be posted on last date of current period. There is no difference
in the interest amount posted.</li>
<li><b>financial-year-beginning-month</b> - Set it at the database level before
any savings interest is posted. Allowed values 1 - 12 (January - December).
Interest posting periods are evaluated based on this configuration.</li>
<li><b>meetings-mandatory-for-jlg-loans</b> - if set to true, enforces all JLG
loans to follow a meeting schedule belonging to either the parent group or
Center.</li>
+
<li><b>max-login-retry-attempts</b> - defaults to 5 - when enabled, limits
failed login attempts and locks the user after N failed attempts.</li>
</ol>
</div>
</div>
diff --git
a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java
b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java
new file mode 100644
index 0000000000..058d9fa52b
--- /dev/null
+++
b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/service/LoginAttemptEventListenerTest.java
@@ -0,0 +1,204 @@
+/**
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+import java.util.List;
+import
org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
+import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
+import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
+import org.apache.fineract.organisation.office.domain.Office;
+import org.apache.fineract.useradministration.domain.AppUser;
+import org.apache.fineract.useradministration.domain.AppUserRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import
org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import
org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
+import
org.springframework.security.authentication.event.AuthenticationSuccessEvent;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
+
+@ExtendWith(MockitoExtension.class)
+class LoginAttemptEventListenerTest {
+
+ @Mock
+ private ConfigurationDomainService configurationDomainService;
+ @Mock
+ private AppUserRepository appUserRepository;
+ @Mock
+ private CacheManager cacheManager;
+ @Mock
+ private Cache usersCache;
+ @Mock
+ private Cache usersByUsernameCache;
+
+ private LoginAttemptEventListener listener;
+
+ @BeforeEach
+ void setUp() {
+ ThreadLocalContextUtil
+
.setTenant(FineractPlatformTenant.builder().id(1L).tenantIdentifier("default").name("default").timezoneId("UTC").build());
+ listener = new LoginAttemptEventListener(configurationDomainService,
appUserRepository, cacheManager);
+ }
+
+ @AfterEach
+ void tearDown() {
+ ThreadLocalContextUtil.reset();
+ }
+
+ @Test
+ void shouldIncrementFailedAttemptsAndLockAccountWhenMaxReached() {
+
when(configurationDomainService.isMaxLoginRetriesEnabled()).thenReturn(true);
+
when(configurationDomainService.retrieveMaxLoginRetries()).thenReturn(3);
+ when(cacheManager.getCache("users")).thenReturn(usersCache);
+
when(cacheManager.getCache("usersByUsername")).thenReturn(usersByUsernameCache);
+
+ AppUser user = buildUser("user");
+ user.updateLoginRetryLimitEnabled(true);
+ when(appUserRepository.findAppUserByName("user")).thenReturn(user);
+
+ AuthenticationFailureBadCredentialsEvent failureEvent = new
AuthenticationFailureBadCredentialsEvent(
+ new UsernamePasswordAuthenticationToken("user", "bad"), new
BadCredentialsException("bad"));
+
+ listener.onAuthenticationFailure(failureEvent);
+ listener.onAuthenticationFailure(failureEvent);
+ listener.onAuthenticationFailure(failureEvent);
+
+ assertEquals(3, user.getFailedLoginAttempts());
+ assertFalse(user.isAccountNonLocked());
+ verify(appUserRepository, times(3)).saveAndFlush(user);
+ }
+
+ @Test
+ void shouldResetFailedAttemptsOnSuccessfulLogin() {
+ AppUser user = buildUser("user");
+ user.updateLoginRetryLimitEnabled(true);
+ user.registerFailedLoginAttempt(10);
+ user.registerFailedLoginAttempt(10);
+ when(cacheManager.getCache("users")).thenReturn(usersCache);
+
when(cacheManager.getCache("usersByUsername")).thenReturn(usersByUsernameCache);
+
+ AuthenticationSuccessEvent successEvent = new
AuthenticationSuccessEvent(
+ new UsernamePasswordAuthenticationToken(user, "pass",
user.getAuthorities()));
+
+ listener.onAuthenticationSuccess(successEvent);
+
+ assertEquals(0, user.getFailedLoginAttempts());
+ assertTrue(user.isAccountNonLocked());
+ verify(appUserRepository).saveAndFlush(user);
+ }
+
+ @Test
+ void shouldNotUpdateWhenLimitDisabled() {
+
when(configurationDomainService.isMaxLoginRetriesEnabled()).thenReturn(false);
+
+ AuthenticationFailureBadCredentialsEvent failureEvent = new
AuthenticationFailureBadCredentialsEvent(
+ new UsernamePasswordAuthenticationToken("user", "bad"), new
BadCredentialsException("bad"));
+
+ listener.onAuthenticationFailure(failureEvent);
+
+ verifyNoInteractions(appUserRepository);
+ verifyNoInteractions(cacheManager);
+ }
+
+ @Test
+ void shouldNotUpdateWhenLoginRetriesDisabledForUser() {
+
when(configurationDomainService.isMaxLoginRetriesEnabled()).thenReturn(true);
+
when(configurationDomainService.retrieveMaxLoginRetries()).thenReturn(3);
+
+ AppUser user = buildUser("user");
+ user.updateLoginRetryLimitEnabled(false);
+ when(appUserRepository.findAppUserByName("user")).thenReturn(user);
+
+ AuthenticationFailureBadCredentialsEvent failureEvent = new
AuthenticationFailureBadCredentialsEvent(
+ new UsernamePasswordAuthenticationToken("user", "bad"), new
BadCredentialsException("bad"));
+
+ listener.onAuthenticationFailure(failureEvent);
+
+ assertEquals(0, user.getFailedLoginAttempts());
+ assertTrue(user.isAccountNonLocked());
+ verify(appUserRepository, times(0)).saveAndFlush(user);
+ }
+
+ @Test
+ void shouldNotLockSystemUser() {
+
when(configurationDomainService.isMaxLoginRetriesEnabled()).thenReturn(true);
+
when(configurationDomainService.retrieveMaxLoginRetries()).thenReturn(3);
+
+ AppUser user = buildUser("system");
+ user.updateLoginRetryLimitEnabled(true);
+ when(appUserRepository.findAppUserByName("system")).thenReturn(user);
+
+ AuthenticationFailureBadCredentialsEvent failureEvent = new
AuthenticationFailureBadCredentialsEvent(
+ new UsernamePasswordAuthenticationToken("system", "bad"), new
BadCredentialsException("bad"));
+
+ listener.onAuthenticationFailure(failureEvent);
+
+ assertEquals(0, user.getFailedLoginAttempts());
+ assertTrue(user.isAccountNonLocked());
+ verify(appUserRepository, times(0)).saveAndFlush(user);
+ }
+
+ @Test
+ void shouldLockUserWithCannotChangePasswordWhenEnabled() {
+
when(configurationDomainService.isMaxLoginRetriesEnabled()).thenReturn(true);
+
when(configurationDomainService.retrieveMaxLoginRetries()).thenReturn(1);
+ when(cacheManager.getCache("users")).thenReturn(usersCache);
+
when(cacheManager.getCache("usersByUsername")).thenReturn(usersByUsernameCache);
+
+ AppUser user = buildUser("api-user", true);
+ user.updateLoginRetryLimitEnabled(true);
+ when(appUserRepository.findAppUserByName("api-user")).thenReturn(user);
+
+ AuthenticationFailureBadCredentialsEvent failureEvent = new
AuthenticationFailureBadCredentialsEvent(
+ new UsernamePasswordAuthenticationToken("api-user", "bad"),
new BadCredentialsException("bad"));
+
+ listener.onAuthenticationFailure(failureEvent);
+
+ assertEquals(1, user.getFailedLoginAttempts());
+ assertFalse(user.isAccountNonLocked());
+ verify(appUserRepository, times(1)).saveAndFlush(user);
+ }
+
+ private AppUser buildUser(String username) {
+ return buildUser(username, false);
+ }
+
+ private AppUser buildUser(String username, boolean cannotChangePassword) {
+ Office office = mock(Office.class);
+ User springUser = new User(username, "pass", true, true, true, true,
List.of(new SimpleGrantedAuthority("ALL_FUNCTIONS")));
+ return new AppUser(office, springUser, new HashSet<>(),
"[email protected]", "First", "Last", null, false, cannotChangePassword);
+ }
+}
diff --git
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java
index 63ff5a9fbe..53e401e324 100644
---
a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java
+++
b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/GlobalConfigurationHelper.java
@@ -600,6 +600,12 @@ public class GlobalConfigurationHelper {
"ACTIVE,TRANSFER_IN_PROGRESS,TRANSFER_ON_HOLD,OVERPAID,CLOSED_OBLIGATIONS_MET");
defaults.add(allowedLoanStatusesForDelayedSettlementExternalAssetTransfer);
+ HashMap<String, Object> maxLoginRetryAttempts = new HashMap<>();
+ maxLoginRetryAttempts.put("name",
GlobalConfigurationConstants.MAX_LOGIN_RETRY_ATTEMPTS);
+ maxLoginRetryAttempts.put("value", 5L);
+ maxLoginRetryAttempts.put("enabled", false);
+ maxLoginRetryAttempts.put("trapDoor", false);
+ defaults.add(maxLoginRetryAttempts);
HashMap<String, Object> enableOriginatorCreationDuringLoanApplication
= new HashMap<>();
enableOriginatorCreationDuringLoanApplication.put("name",
GlobalConfigurationConstants.ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION);