merge feature/update_code and feature/Backend-MFA-Product branch code

This commit is contained in:
Nitesh Yadav
2026-05-11 10:44:57 +05:30
98 changed files with 56127 additions and 120067 deletions
@@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.configuration.api;
public final class GlobalConfigurationConstants {
public static final String MAKER_CHECKER = "maker-checker";
public static final String MFA_FACTOR_ENFORCEMENT = "mfa-factor-enforcement";
public static final String AMAZON_S3 = "amazon-s3";
public static final String RESCHEDULE_FUTURE_REPAYMENTS = "reschedule-future-repayments";
public static final String RESCHEDULE_REPAYMENTS_ON_HOLIDAYS = "reschedule-repayments-on-holidays";
@@ -26,6 +26,13 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
/**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
* after password authentication. Default: false (off, safe rollback).
*/
boolean isMfaFactorEnforcementEnabled();
List<String> getAllowedLoanStatusesForExternalAssetTransfer();
List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer();
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
@@ -35,6 +37,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
@Table(name = "m_client_identifier", uniqueConstraints = {
@UniqueConstraint(columnNames = { "document_type_id", "document_key" }, name = "unique_identifier_key"),
@UniqueConstraint(columnNames = { "client_id", "document_key", "active" }, name = "unique_active_client_identifier") })
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@@ -57,6 +64,22 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
@Column(name = "active")
private Integer active;
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = Boolean.FALSE;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted_at")
private java.time.OffsetDateTime deletedAt;
@jakarta.persistence.Version
@Column(name = "version", nullable = false)
private Long version = 0L;
public static ClientIdentifier fromJson(final Client client, final CodeValue documentType, final JsonCommand command) {
final String documentKey = command.stringValueOfParameterNamed("documentKey");
final String description = command.stringValueOfParameterNamed("description");
@@ -127,4 +150,40 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
public Long documentTypeId() {
return this.documentType.getId();
}
public Boolean getIsActive() {
return this.isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public Boolean getIsDeleted() {
return this.isDeleted;
}
public void setIsDeleted(Boolean isDeleted) {
this.isDeleted = isDeleted;
}
public Long getDeletedBy() {
return this.deletedBy;
}
public void setDeletedBy(Long deletedBy) {
this.deletedBy = deletedBy;
}
public java.time.OffsetDateTime getDeletedAt() {
return this.deletedAt;
}
public void setDeletedAt(java.time.OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Long getVersion() {
return this.version;
}
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
@@ -46,6 +48,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
@Entity
@Table(name = "m_client_kyc", uniqueConstraints = {
@UniqueConstraint(columnNames = { "client_id" }, name = "uq_m_client_kyc_client_id") })
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@@ -777,4 +784,56 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public String getForm15Fy() {
return form15Fy;
}
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = Boolean.FALSE;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted_at")
private java.time.OffsetDateTime deletedAt;
@jakarta.persistence.Version
@Column(name = "version", nullable = false)
private Long version = 0L;
public Boolean getIsActive() {
return this.isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public Boolean getIsDeleted() {
return this.isDeleted;
}
public void setIsDeleted(Boolean isDeleted) {
this.isDeleted = isDeleted;
}
public Long getDeletedBy() {
return this.deletedBy;
}
public void setDeletedBy(Long deletedBy) {
this.deletedBy = deletedBy;
}
public java.time.OffsetDateTime getDeletedAt() {
return this.deletedAt;
}
public void setDeletedAt(java.time.OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Long getVersion() {
return this.version;
}
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -39,6 +41,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
*/
@Entity
@Table(name = "m_client_nominee")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -60,8 +67,21 @@ public class ClientNominee extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare;
@Column(name = "is_active")
private Boolean isActive;
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = Boolean.FALSE;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted_at")
private java.time.OffsetDateTime deletedAt;
@jakarta.persistence.Version
@Column(name = "version", nullable = false)
private Long version = 0L;
/** If nominee is a minor, guardian details */
@Column(name = "nominee_is_minor")
@@ -26,21 +26,21 @@ import org.springframework.data.repository.query.Param;
public interface ClientNomineeRepository extends JpaRepository<ClientNominee, Long> {
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId ORDER BY n.id")
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isDeleted = false ORDER BY n.id")
List<ClientNominee> findAllByClientId(@Param("clientId") Long clientId);
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.id = :nomineeId")
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.id = :nomineeId AND n.isDeleted = false")
Optional<ClientNominee> findByClientIdAndId(@Param("clientId") Long clientId, @Param("nomineeId") Long nomineeId);
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isActive = true")
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isActive = true AND n.isDeleted = false")
List<ClientNominee> findActiveByClientId(@Param("clientId") Long clientId);
@Query("SELECT n FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isActive = true")
@Query("SELECT n FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isActive = true AND n.isDeleted = false")
List<ClientNominee> findActiveByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.accountNo = :accountNo AND n.accountType = :accountType ORDER BY n.id")
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.accountNo = :accountNo AND n.accountType = :accountType AND n.isDeleted = false ORDER BY n.id")
List<ClientNominee> findByClientIdAndAccountNoAndAccountType(@Param("clientId") Long clientId, @Param("accountNo") String accountNo, @Param("accountType") String accountType);
@Query("SELECT COUNT(n) FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType")
@Query("SELECT COUNT(n) FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isDeleted = false")
long countByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
}
@@ -54,4 +54,11 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Modifying
@Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId")
void removeImageId(@Param("clientId") Long clientId);
/**
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
*/
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
long countByCifNumberPrefix(@Param("prefix") String prefix);
}
@@ -23,5 +23,5 @@ import org.springframework.data.domain.Pageable;
public interface SearchingClientRepository {
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy);
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly);
}
@@ -36,6 +36,7 @@ import org.apache.fineract.infrastructure.core.jpa.CriteriaQueryFactory;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientIdentifier;
import org.apache.fineract.portfolio.client.domain.ClientStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
@@ -51,7 +52,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
private final CriteriaQueryFactory criteriaQueryFactory;
@Override
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy) {
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly) {
/*
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
* this point the upgrade is too costly
@@ -79,6 +80,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
cb.like(r.get("displayName"), searchLikeValue), cb.like(r.get("externalId"), searchLikeValue),
cb.like(r.get("mobileNo"), searchLikeValue), cb.like(identity.get("documentKey"), searchLikeValue)));
// Linking flows (nominee, family-member, guardian, signatory) only see ACTIVE clients
if (activeOnly) {
predicates.add(cb.equal(r.get("status"), ClientStatus.ACTIVE.getValue()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/
@Entity
@Table(name = "m_client_eou")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -34,6 +36,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Entity
@Table(name = "m_client_msme")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/
@Entity
@Table(name = "m_client_mudra_borrower")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -36,6 +38,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/
@Entity
@Table(name = "m_client_non_priority_sector")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/
@Entity
@Table(name = "m_client_priority_sector")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/
@Entity
@Table(name = "m_client_startup_india")
@AttributeOverrides({
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
})
@Getter
@Setter
@NoArgsConstructor
@@ -40,6 +40,7 @@ public final class AppUserData {
private final String firstname;
private final String lastname;
private final String email;
private final String mobileNo;
private final Boolean passwordNeverExpires;
// import fields
@@ -76,6 +77,7 @@ public final class AppUserData {
this.firstname = firstname;
this.lastname = lastname;
this.email = email;
this.mobileNo = null;
this.passwordNeverExpires = passwordNeverExpires;
this.roles = roleIds;
this.sendPasswordToEmail = sendPasswordToEmail;
@@ -91,30 +93,31 @@ public final class AppUserData {
}
public static AppUserData template(final AppUserData user, final Collection<OfficeData> officesForDropdown) {
return new AppUserData(user.id, user.username, user.email, user.officeId, user.officeName, user.firstname, user.lastname,
user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff, user.passwordNeverExpires,
user.isSelfServiceUser);
return new AppUserData(user.id, user.username, user.email, user.mobileNo, user.officeId, user.officeName, user.firstname,
user.lastname, user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff,
user.passwordNeverExpires, user.isSelfServiceUser);
}
public static AppUserData template(final Collection<OfficeData> offices, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles) {
return new AppUserData(null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null, null);
return new AppUserData(null, null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null,
null);
}
public static AppUserData dropdown(final Long id, final String username) {
return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null);
return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null, null);
}
public static AppUserData instance(final Long id, final String username, final String email, final Long officeId,
public static AppUserData instance(final Long id, final String username, final String email, final String mobileNo, final Long officeId,
final String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles, final StaffData staff,
final Boolean passwordNeverExpire, final Boolean isSelfServiceUser) {
return new AppUserData(id, username, email, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles,
return new AppUserData(id, username, email, mobileNo, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles,
selectedRoles, null, staff, passwordNeverExpire, isSelfServiceUser);
}
private AppUserData(final Long id, final String username, final String email, final Long officeId, final String officeName,
final String firstname, final String lastname, final Collection<RoleData> availableRoles,
private AppUserData(final Long id, final String username, final String email, final String mobileNo, final Long officeId,
final String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles,
final Collection<OfficeData> allowedOffices, final StaffData staff, final Boolean passwordNeverExpire,
final Boolean isSelfServiceUser) {
@@ -125,6 +128,7 @@ public final class AppUserData {
this.firstname = firstname;
this.lastname = lastname;
this.email = email;
this.mobileNo = mobileNo;
this.allowedOffices = allowedOffices;
this.availableRoles = availableRoles;
this.selfServiceRoles = selfServiceRoles;
@@ -134,6 +138,10 @@ public final class AppUserData {
this.isSelfServiceUser = isSelfServiceUser;
}
public String getMobileNo() {
return this.mobileNo;
}
public boolean hasIdentifyOf(final Long createdById) {
return this.id.equals(createdById);
}
@@ -134,6 +134,17 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
@Column(name = "password_reset_required", nullable = false)
private boolean passwordResetRequired;
@Column(name = "mobile_no", length = 16)
private String mobileNo;
public String getMobileNo() {
return this.mobileNo;
}
public void setMobileNo(final String mobileNo) {
this.mobileNo = mobileNo;
}
public boolean isPasswordResetRequired() {
return this.passwordResetRequired;
}
@@ -174,11 +185,14 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
final String email = command.stringValueOfParameterNamed("email");
final String firstname = command.stringValueOfParameterNamed("firstname");
final String lastname = command.stringValueOfParameterNamed("lastname");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
final boolean isSelfServiceUser = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_SELF_SERVICE_USER);
return new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, isSelfServiceUser,
clients, cannotChangePassword);
final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire,
isSelfServiceUser, clients, cannotChangePassword);
appUser.setMobileNo(mobileNo);
return appUser;
}
protected AppUser() {
@@ -321,6 +335,13 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
this.email = newValue;
}
final String mobileNoParamName = "mobileNo";
if (command.isChangeInStringParameterNamed(mobileNoParamName, this.mobileNo)) {
final String newValue = command.stringValueOfParameterNamed(mobileNoParamName);
actualChanges.put(mobileNoParamName, newValue);
this.mobileNo = newValue;
}
final String passwordNeverExpire = "passwordNeverExpires";
if (command.hasParameter(passwordNeverExpire)
@@ -0,0 +1,38 @@
/**
* 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.useradministration.mfa.domain;
public enum MfaFactorType {
SMS,
EMAIL,
TOTP,
TOKEN;
public static MfaFactorType fromString(final String s) {
if (s == null) {
return null;
}
for (final MfaFactorType t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -0,0 +1,91 @@
/**
* 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.useradministration.mfa.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One row per (AppUser, factor type). Soft-disable via {@link #disabledOn}.
*/
@Entity
@Table(name = "m_user_mfa_factor", uniqueConstraints = {
@UniqueConstraint(columnNames = { "appuser_id", "factor_type" }, name = "uq_user_factor") })
@Getter
@Setter
@NoArgsConstructor
public class UserMfaFactor extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
@Enumerated(EnumType.STRING)
@Column(name = "factor_type", length = 20, nullable = false)
private MfaFactorType factorType;
@Column(name = "destination", length = 255)
private String destination;
@Column(name = "secret", length = 64)
private String secret;
@Column(name = "serial", length = 64)
private String serial;
@Column(name = "verified", nullable = false)
private boolean verified;
@Column(name = "verified_on")
private OffsetDateTime verifiedOn;
@Column(name = "otp_log_id")
private Long otpLogId;
@Column(name = "created_on", nullable = false, updatable = false)
private OffsetDateTime createdOn;
@Column(name = "disabled_on")
private OffsetDateTime disabledOn;
@Column(name = "disabled_by")
private Long disabledBy;
@PrePersist
private void prePersist() {
if (createdOn == null) {
createdOn = OffsetDateTime.now(ZoneOffset.UTC);
}
}
}
@@ -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.useradministration.mfa.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserMfaFactorRepository extends JpaRepository<UserMfaFactor, Long> {
List<UserMfaFactor> findByAppUser_IdAndDisabledOnIsNull(Long userId);
/** Returns every factor for the user — active and disabled — newest first. */
List<UserMfaFactor> findByAppUser_IdOrderByIdDesc(Long userId);
Optional<UserMfaFactor> findByAppUser_IdAndFactorType(Long userId, MfaFactorType type);
/**
* Returns true if the user has at least one verified, non-disabled MFA factor enrolled.
* Used by the auth flow to decide whether to gate the login with a 2FA challenge.
*/
boolean existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(Long userId);
}