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 final class GlobalConfigurationConstants {
public static final String MAKER_CHECKER = "maker-checker"; 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 AMAZON_S3 = "amazon-s3";
public static final String RESCHEDULE_FUTURE_REPAYMENTS = "reschedule-future-repayments"; public static final String RESCHEDULE_FUTURE_REPAYMENTS = "reschedule-future-repayments";
public static final String RESCHEDULE_REPAYMENTS_ON_HOLIDAYS = "reschedule-repayments-on-holidays"; public static final String RESCHEDULE_REPAYMENTS_ON_HOLIDAYS = "reschedule-repayments-on-holidays";
@@ -26,6 +26,13 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode); 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> getAllowedLoanStatusesForExternalAssetTransfer();
List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer(); List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer();
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.domain; package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
@@ -35,6 +37,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
@Table(name = "m_client_identifier", uniqueConstraints = { @Table(name = "m_client_identifier", uniqueConstraints = {
@UniqueConstraint(columnNames = { "document_type_id", "document_key" }, name = "unique_identifier_key"), @UniqueConstraint(columnNames = { "document_type_id", "document_key" }, name = "unique_identifier_key"),
@UniqueConstraint(columnNames = { "client_id", "document_key", "active" }, name = "unique_active_client_identifier") }) @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> { public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne @ManyToOne
@@ -57,6 +64,22 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
@Column(name = "active") @Column(name = "active")
private Integer 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) { public static ClientIdentifier fromJson(final Client client, final CodeValue documentType, final JsonCommand command) {
final String documentKey = command.stringValueOfParameterNamed("documentKey"); final String documentKey = command.stringValueOfParameterNamed("documentKey");
final String description = command.stringValueOfParameterNamed("description"); final String description = command.stringValueOfParameterNamed("description");
@@ -127,4 +150,40 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
public Long documentTypeId() { public Long documentTypeId() {
return this.documentType.getId(); 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; package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
@@ -46,6 +48,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
@Entity @Entity
@Table(name = "m_client_kyc", uniqueConstraints = { @Table(name = "m_client_kyc", uniqueConstraints = {
@UniqueConstraint(columnNames = { "client_id" }, name = "uq_m_client_kyc_client_id") }) @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> { public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne @ManyToOne
@@ -777,4 +784,56 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public String getForm15Fy() { public String getForm15Fy() {
return form15Fy; 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; package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -39,6 +41,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
*/ */
@Entity @Entity
@Table(name = "m_client_nominee") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -60,8 +67,21 @@ public class ClientNominee extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "percentage_share", precision = 5, scale = 2) @Column(name = "percentage_share", precision = 5, scale = 2)
private BigDecimal percentageShare; private BigDecimal percentageShare;
@Column(name = "is_active") @Column(name = "is_active", nullable = false)
private Boolean isActive; 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 */ /** If nominee is a minor, guardian details */
@Column(name = "nominee_is_minor") @Column(name = "nominee_is_minor")
@@ -26,21 +26,21 @@ import org.springframework.data.repository.query.Param;
public interface ClientNomineeRepository extends JpaRepository<ClientNominee, Long> { 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); 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); 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); 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); 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); 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); long countByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
} }
@@ -54,4 +54,11 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Modifying @Modifying
@Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId") @Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId")
void removeImageId(@Param("clientId") Long 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 { 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.organisation.office.domain.Office;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientIdentifier; 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.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
@@ -51,7 +52,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
private final CriteriaQueryFactory criteriaQueryFactory; private final CriteriaQueryFactory criteriaQueryFactory;
@Override @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 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 * 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("displayName"), searchLikeValue), cb.like(r.get("externalId"), searchLikeValue),
cb.like(r.get("mobileNo"), searchLikeValue), cb.like(identity.get("documentKey"), 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])); return cb.and(predicates.toArray(new Predicate[0]));
}; };
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query); criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/ */
@Entity @Entity
@Table(name = "m_client_eou") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -34,6 +36,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Entity @Entity
@Table(name = "m_client_msme") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/ */
@Entity @Entity
@Table(name = "m_client_mudra_borrower") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -36,6 +38,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/ */
@Entity @Entity
@Table(name = "m_client_non_priority_sector") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/ */
@Entity @Entity
@Table(name = "m_client_priority_sector") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -18,6 +18,8 @@
*/ */
package org.apache.fineract.portfolio.client.entityclassification.domain; package org.apache.fineract.portfolio.client.entityclassification.domain;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client;
*/ */
@Entity @Entity
@Table(name = "m_client_startup_india") @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 @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@@ -40,6 +40,7 @@ public final class AppUserData {
private final String firstname; private final String firstname;
private final String lastname; private final String lastname;
private final String email; private final String email;
private final String mobileNo;
private final Boolean passwordNeverExpires; private final Boolean passwordNeverExpires;
// import fields // import fields
@@ -76,6 +77,7 @@ public final class AppUserData {
this.firstname = firstname; this.firstname = firstname;
this.lastname = lastname; this.lastname = lastname;
this.email = email; this.email = email;
this.mobileNo = null;
this.passwordNeverExpires = passwordNeverExpires; this.passwordNeverExpires = passwordNeverExpires;
this.roles = roleIds; this.roles = roleIds;
this.sendPasswordToEmail = sendPasswordToEmail; this.sendPasswordToEmail = sendPasswordToEmail;
@@ -91,30 +93,31 @@ public final class AppUserData {
} }
public static AppUserData template(final AppUserData user, final Collection<OfficeData> officesForDropdown) { 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, return new AppUserData(user.id, user.username, user.email, user.mobileNo, user.officeId, user.officeName, user.firstname,
user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff, user.passwordNeverExpires, user.lastname, user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff,
user.isSelfServiceUser); user.passwordNeverExpires, user.isSelfServiceUser);
} }
public static AppUserData template(final Collection<OfficeData> offices, final Collection<RoleData> availableRoles, public static AppUserData template(final Collection<OfficeData> offices, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles) { 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) { 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 String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles, final StaffData staff, final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles, final StaffData staff,
final Boolean passwordNeverExpire, final Boolean isSelfServiceUser) { 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); selectedRoles, null, staff, passwordNeverExpire, isSelfServiceUser);
} }
private AppUserData(final Long id, final String username, final String email, final Long officeId, final String officeName, private AppUserData(final Long id, final String username, final String email, final String mobileNo, final Long officeId,
final String firstname, final String lastname, final Collection<RoleData> availableRoles, final String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles, final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles,
final Collection<OfficeData> allowedOffices, final StaffData staff, final Boolean passwordNeverExpire, final Collection<OfficeData> allowedOffices, final StaffData staff, final Boolean passwordNeverExpire,
final Boolean isSelfServiceUser) { final Boolean isSelfServiceUser) {
@@ -125,6 +128,7 @@ public final class AppUserData {
this.firstname = firstname; this.firstname = firstname;
this.lastname = lastname; this.lastname = lastname;
this.email = email; this.email = email;
this.mobileNo = mobileNo;
this.allowedOffices = allowedOffices; this.allowedOffices = allowedOffices;
this.availableRoles = availableRoles; this.availableRoles = availableRoles;
this.selfServiceRoles = selfServiceRoles; this.selfServiceRoles = selfServiceRoles;
@@ -134,6 +138,10 @@ public final class AppUserData {
this.isSelfServiceUser = isSelfServiceUser; this.isSelfServiceUser = isSelfServiceUser;
} }
public String getMobileNo() {
return this.mobileNo;
}
public boolean hasIdentifyOf(final Long createdById) { public boolean hasIdentifyOf(final Long createdById) {
return this.id.equals(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) @Column(name = "password_reset_required", nullable = false)
private boolean passwordResetRequired; 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() { public boolean isPasswordResetRequired() {
return this.passwordResetRequired; return this.passwordResetRequired;
} }
@@ -174,11 +185,14 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
final String email = command.stringValueOfParameterNamed("email"); final String email = command.stringValueOfParameterNamed("email");
final String firstname = command.stringValueOfParameterNamed("firstname"); final String firstname = command.stringValueOfParameterNamed("firstname");
final String lastname = command.stringValueOfParameterNamed("lastname"); final String lastname = command.stringValueOfParameterNamed("lastname");
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
final boolean isSelfServiceUser = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_SELF_SERVICE_USER); final boolean isSelfServiceUser = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_SELF_SERVICE_USER);
return new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, isSelfServiceUser, final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire,
clients, cannotChangePassword); isSelfServiceUser, clients, cannotChangePassword);
appUser.setMobileNo(mobileNo);
return appUser;
} }
protected AppUser() { protected AppUser() {
@@ -321,6 +335,13 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
this.email = newValue; 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"; final String passwordNeverExpire = "passwordNeverExpires";
if (command.hasParameter(passwordNeverExpire) 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);
}
@@ -0,0 +1,53 @@
/**
* 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.documentmanagement.exceptionmapper;
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.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse;
import org.apache.fineract.infrastructure.core.exceptionmapper.FineractExceptionMapper;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
@Provider
@Component
@Scope("singleton")
@Slf4j
public class DocumentNotFoundExceptionMapper implements FineractExceptionMapper, ExceptionMapper<DocumentNotFoundException> {
@Override
public Response toResponse(final DocumentNotFoundException exception) {
// Missing entity images are an expected normal case (e.g. client without a profile picture);
// log at DEBUG to avoid flooding the WARN log with stack traces.
log.debug("Document not found: {}", exception.getDefaultUserMessage());
final ApiGlobalErrorResponse notFoundErrorResponse = ApiGlobalErrorResponse.notFound(exception.getGlobalisationMessageCode(),
exception.getDefaultUserMessage(), exception.getDefaultUserMessageArgs());
return Response.status(Status.NOT_FOUND).entity(notFoundErrorResponse).type(MediaType.APPLICATION_JSON).build();
}
@Override
public int errorCode() {
return 1002;
}
}
@@ -25,6 +25,7 @@ import org.apache.commons.io.FilenameUtils;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext; import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorContext;
import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager; import org.apache.fineract.infrastructure.contentstore.detector.ContentDetectorManager;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntityImageIdAdapter; import org.apache.fineract.infrastructure.documentmanagement.adapter.EntityImageIdAdapter;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent; import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository; import org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository;
@@ -62,6 +63,8 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
.getMimeType()) .getMimeType())
.stream(storeService.download(image.getLocation())).build())) .stream(storeService.download(image.getLocation())).build()))
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, -1L)); .orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, -1L));
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) { } catch (final Exception e) {
throw new DocumentInvalidRequestException(e); throw new DocumentInvalidRequestException(e);
} }
+1
View File
@@ -119,6 +119,7 @@ dependencies {
'com.sun.activation:jakarta.activation', 'com.sun.activation:jakarta.activation',
'com.sun.mail:jakarta.mail', 'com.sun.mail:jakarta.mail',
'org.springframework.boot:spring-boot-starter-mail',
'org.springdoc:springdoc-openapi-starter-webmvc-ui', 'org.springdoc:springdoc-openapi-starter-webmvc-ui',
'org.mapstruct:mapstruct', 'org.mapstruct:mapstruct',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -80,6 +80,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.ENABLE_SAME_MAKER_CHECKER).isEnabled(); return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.ENABLE_SAME_MAKER_CHECKER).isEnabled();
} }
@Override
public boolean isMfaFactorEnforcementEnabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MFA_FACTOR_ENFORCEMENT).isEnabled();
}
@Override @Override
public boolean isAmazonS3Enabled() { public boolean isAmazonS3Enabled() {
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.AMAZON_S3).isEnabled(); return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.AMAZON_S3).isEnabled();
@@ -0,0 +1,49 @@
/**
* 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.core.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
/**
* Returns a 401 JSON response WITHOUT the {@code WWW-Authenticate: Basic ...} header so that
* browsers don't show the native Basic auth popup. Used for SPA-only paths where the frontend
* handles auth failures by redirecting to its own login screen — the popup is unwanted UX there.
*/
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(final HttpServletRequest request, final HttpServletResponse response,
final AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// NOTE: deliberately NOT calling response.setHeader("WWW-Authenticate", "Basic ...")
final String message = authException != null && authException.getMessage() != null ? authException.getMessage() : "Unauthorized";
// Crude JSON escape — message comes from Spring Security and won't normally contain quotes,
// but escape just in case.
final String safe = message.replace("\\", "\\\\").replace("\"", "\\\"");
response.getWriter().write("{\"error\":\"unauthorized\",\"message\":\"" + safe + "\"}");
}
}
@@ -25,6 +25,7 @@ import static org.springframework.security.authorization.AuthorityAuthorizationM
import static org.springframework.security.authorization.AuthorizationManagers.allOf; import static org.springframework.security.authorization.AuthorizationManagers.allOf;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService; import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.cache.service.CacheWritePlatformService; import org.apache.fineract.infrastructure.cache.service.CacheWritePlatformService;
@@ -67,10 +68,13 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint; import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
@@ -142,6 +146,12 @@ public class SecurityConfig {
auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll() auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll()
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
// no Fineract auth required.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/generate")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
// businessdate // businessdate
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*")) .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*"))
@@ -388,7 +398,11 @@ public class SecurityConfig {
public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() { public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() {
TwoFactorService twoFactorService = applicationContext.getBean(TwoFactorService.class); TwoFactorService twoFactorService = applicationContext.getBean(TwoFactorService.class);
return new TwoFactorAuthenticationFilter(twoFactorService); org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService = applicationContext
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
} }
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() { public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -417,10 +431,22 @@ public class SecurityConfig {
} }
@Bean @Bean
public BasicAuthenticationEntryPoint basicAuthenticationEntryPoint() { public AuthenticationEntryPoint basicAuthenticationEntryPoint() {
BasicAuthenticationEntryPoint basicAuthenticationEntryPoint = new BasicAuthenticationEntryPoint(); // The default Basic-auth entry point is kept for the rest of the API — it emits
basicAuthenticationEntryPoint.setRealmName("Fineract Platform API"); // `WWW-Authenticate: Basic ...` which is useful for tooling and programmatic clients.
return basicAuthenticationEntryPoint; final BasicAuthenticationEntryPoint basicEntryPoint = new BasicAuthenticationEntryPoint();
basicEntryPoint.setRealmName("Fineract Platform API");
// Silent JSON 401 (no WWW-Authenticate) for paths the browser-based SPA owns. Without
// this, a 401 from those paths makes Chromium-family browsers pop up the native Basic
// auth dialog, which is jarring and the SPA can't suppress it client-side.
final AuthenticationEntryPoint silentJsonEntryPoint = new JsonAuthenticationEntryPoint();
final LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
entryPoints.put(API_MATCHER.matcher("/api/*/mfa/**"), silentJsonEntryPoint);
final DelegatingAuthenticationEntryPoint delegating = new DelegatingAuthenticationEntryPoint(entryPoints);
delegating.setDefaultEntryPoint(basicEntryPoint);
return delegating;
} }
@Bean(name = "customAuthenticationProvider") @Bean(name = "customAuthenticationProvider")
@@ -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.email.service;
/**
* Reusable email transport. Used for OTPs, welcome emails, password reset,
* alerts, and any other transactional email. Implementations talk to SMTP
* configured via spring.mail.* properties.
*/
public interface EmailSenderService {
/**
* Send an HTML email.
*
* @return true if SMTP accepted the message; false on any failure (logged).
*/
boolean sendHtml(String to, String subject, String htmlBody);
/**
* Send a plain-text email.
*/
boolean sendText(String to, String subject, String textBody);
}
@@ -0,0 +1,89 @@
/**
* 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.email.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Service
public class EmailSenderServiceImpl implements EmailSenderService {
private static final Logger LOG = LoggerFactory.getLogger(EmailSenderServiceImpl.class);
private final JavaMailSender mailSender;
@Value("${fineract.email.from-address:no-reply@cbsnexora.com}")
private String fromAddress;
@Value("${fineract.email.from-name:CBSnexora}")
private String fromName;
@Autowired
public EmailSenderServiceImpl(final JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public boolean sendHtml(final String to, final String subject, final String htmlBody) {
return send(to, subject, htmlBody, true);
}
@Override
public boolean sendText(final String to, final String subject, final String textBody) {
return send(to, subject, textBody, false);
}
private boolean send(final String to, final String subject, final String body, final boolean html) {
if (to == null || to.isBlank()) {
LOG.warn("Email send skipped — empty recipient. subject={}", subject);
return false;
}
try {
final MimeMessage message = mailSender.createMimeMessage();
final MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
try {
helper.setFrom(new InternetAddress(fromAddress, fromName, StandardCharsets.UTF_8.name()));
} catch (final UnsupportedEncodingException e) {
helper.setFrom(fromAddress);
}
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body, html);
mailSender.send(message);
LOG.info("Email sent to {} subject=\"{}\"", to, subject);
return true;
} catch (final MessagingException e) {
LOG.error("MessagingException sending email to {}: {}", to, e.getMessage());
return false;
} catch (final Exception e) {
LOG.error("Unexpected error sending email to {}", to, e);
return false;
}
}
}
@@ -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.email.service;
/**
* Builds branded HTML email bodies. New templates should be added as methods here so
* call sites stay in services and the rendering stays in one place. All templates
* share the same shell (header / body / footer) defined in the implementation.
*/
public interface EmailTemplateService {
/** OTP delivery for login or sensitive actions. */
String renderOtp(String recipientName, String otp, int validityMinutes);
/** Welcome / account-created email. */
String renderWelcome(String recipientName, String username, String loginUrl);
/** Password reset link. */
String renderPasswordReset(String recipientName, String resetLink, int validityMinutes);
/** Generic security alert (suspicious login, profile change, etc). */
String renderAlert(String recipientName, String alertTitle, String alertMessage);
}
@@ -0,0 +1,155 @@
/**
* 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.email.service;
import java.time.Year;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class EmailTemplateServiceImpl implements EmailTemplateService {
@Value("${fineract.email.from-name:CBSnexora}")
private String brandName;
@Value("${fineract.email.support-address:support@cbsnexora.com}")
private String supportEmail;
@Override
public String renderOtp(final String recipientName, final String otp, final int validityMinutes) {
final String greeting = greeting(recipientName);
final String body = ""
+ "<p style=\"margin:0 0 16px;\">" + greeting + "</p>"
+ "<p style=\"margin:0 0 16px;\">Use the one-time password (OTP) below to complete your sign-in. "
+ "It is valid for <strong>" + validityMinutes + " minutes</strong>.</p>"
+ "<div style=\"margin:24px 0;text-align:center;\">"
+ " <span style=\"display:inline-block;font-family:'Courier New',monospace;font-size:32px;letter-spacing:8px;"
+ "background:#f4f6fb;border:1px solid #d8dde8;border-radius:6px;padding:14px 28px;color:#0f172a;font-weight:700;\">"
+ escape(otp) + "</span>"
+ "</div>"
+ "<p style=\"margin:0 0 16px;\">If you did not request this code, please ignore this email or "
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a> immediately.</p>";
return wrap("Your " + brandName + " Sign-in OTP", body);
}
@Override
public String renderWelcome(final String recipientName, final String username, final String loginUrl) {
final String safeUrl = loginUrl == null ? "#" : loginUrl;
final String body = ""
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
+ "<p style=\"margin:0 0 16px;\">Welcome to <strong>" + escape(brandName) + "</strong>. Your account has been created.</p>"
+ "<p style=\"margin:0 0 8px;\">Username: <strong>" + escape(username) + "</strong></p>"
+ "<div style=\"margin:24px 0;\"><a href=\"" + escape(safeUrl) + "\" "
+ "style=\"display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 24px;"
+ "border-radius:6px;font-weight:600;\">Sign in to your account</a></div>"
+ "<p style=\"margin:0 0 16px;\">If you did not expect this email, "
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a>.</p>";
return wrap("Welcome to " + brandName, body);
}
@Override
public String renderPasswordReset(final String recipientName, final String resetLink, final int validityMinutes) {
final String safeLink = resetLink == null ? "#" : resetLink;
final String body = ""
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
+ "<p style=\"margin:0 0 16px;\">We received a request to reset your password. The link below is valid for "
+ "<strong>" + validityMinutes + " minutes</strong>.</p>"
+ "<div style=\"margin:24px 0;\"><a href=\"" + escape(safeLink) + "\" "
+ "style=\"display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 24px;"
+ "border-radius:6px;font-weight:600;\">Reset password</a></div>"
+ "<p style=\"margin:0 0 16px;color:#475569;font-size:13px;\">If the button does not work, paste this URL into your browser:<br/>"
+ "<span style=\"word-break:break-all;\">" + escape(safeLink) + "</span></p>"
+ "<p style=\"margin:0 0 16px;\">If you did not request a password reset, you can safely ignore this email.</p>";
return wrap("Reset your " + brandName + " password", body);
}
@Override
public String renderAlert(final String recipientName, final String alertTitle, final String alertMessage) {
final String body = ""
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
+ "<div style=\"margin:16px 0;padding:16px;background:#fff7ed;border-left:4px solid #f97316;border-radius:4px;\">"
+ " <p style=\"margin:0 0 6px;font-weight:700;color:#9a3412;\">" + escape(alertTitle) + "</p>"
+ " <p style=\"margin:0;color:#7c2d12;\">" + escape(alertMessage) + "</p>"
+ "</div>"
+ "<p style=\"margin:0 0 16px;\">If this does not look right, please "
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a>.</p>";
return wrap(alertTitle + " — " + brandName, body);
}
// ── Shared shell ─────────────────────────────────────────────────────────
private String wrap(final String previewTitle, final String innerHtml) {
return ""
+ "<!DOCTYPE html>"
+ "<html><head>"
+ "<meta charset=\"utf-8\"/>"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>"
+ "<title>" + escape(previewTitle) + "</title>"
+ "</head>"
+ "<body style=\"margin:0;padding:0;background:#f1f5f9;font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0f172a;\">"
+ " <div style=\"display:none;visibility:hidden;opacity:0;height:0;width:0;overflow:hidden;\">" + escape(previewTitle) + "</div>"
+ " <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#f1f5f9;\">"
+ " <tr><td align=\"center\" style=\"padding:32px 16px;\">"
+ " <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" "
+ " style=\"max-width:600px;background:#ffffff;border-radius:8px;overflow:hidden;"
+ " box-shadow:0 1px 3px rgba(15,23,42,0.08);\">"
// Header / brand
+ " <tr><td style=\"background:#0f172a;padding:24px 28px;\">"
+ " <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>"
+ " <td style=\"font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;\">"
+ escape(brandName) + "</td>"
+ " <td style=\"text-align:right;font-size:12px;color:#94a3b8;\">Core Banking</td>"
+ " </tr></table>"
+ " </td></tr>"
// Body
+ " <tr><td style=\"padding:32px 28px;font-size:15px;line-height:1.6;\">"
+ innerHtml
+ " </td></tr>"
// Footer
+ " <tr><td style=\"padding:20px 28px;background:#f8fafc;border-top:1px solid #e2e8f0;\">"
+ " <p style=\"margin:0 0 6px;font-size:12px;color:#64748b;\">"
+ " Need help? Reach our team at "
+ " <a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">"
+ escape(supportEmail) + "</a>"
+ " </p>"
+ " <p style=\"margin:0;font-size:12px;color:#94a3b8;\">"
+ " &copy; " + Year.now().getValue() + " " + escape(brandName) + ". All rights reserved."
+ " </p>"
+ " </td></tr>"
+ " </table>"
+ " </td></tr>"
+ " </table>"
+ "</body></html>";
}
private String greeting(final String recipientName) {
return "Hi " + (recipientName == null || recipientName.isBlank() ? "there" : escape(recipientName)) + ",";
}
private String escape(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
}
@@ -287,7 +287,11 @@ public class AuthorizationServerConfig {
public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() { public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() {
TwoFactorService twoFactorService = applicationContext.getBean(TwoFactorService.class); TwoFactorService twoFactorService = applicationContext.getBean(TwoFactorService.class);
return new TwoFactorAuthenticationFilter(twoFactorService); org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService configurationDomainService = applicationContext
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
} }
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() { public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -0,0 +1,162 @@
/**
* 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.otp.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogData;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogPage;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogSearchCriteria;
import org.apache.fineract.infrastructure.security.otp.service.OtpLogReadPlatformService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.stereotype.Component;
/**
* Admin-only viewer for the {@code m_otp_log} audit trail.
*
* <ul>
* <li>GET /v1/otp-logs — paged search across OTP rows joined with m_appuser; destination is
* masked server-side, channel is normalised (SMS/EMAIL/TOTP)</li>
* <li>GET /v1/otp-logs/{id} — single row, optionally with raw request/response payloads</li>
* </ul>
*
* <p>Permission: {@code READ_OTP_LOG} (granted to {@code Super user} by migration 0289).
*/
@Path("/v1/otp-logs")
@Component
@Tag(name = "OTP Logs (Admin)", description = "Read access to the m_otp_log audit trail")
@RequiredArgsConstructor
public class AdminOtpLogApiResource {
private static final String RESOURCE = "OTP_LOG";
private static final int DEFAULT_PAGE_SIZE = 25;
private static final int MAX_PAGE_SIZE = 500;
private final PlatformSecurityContext context;
private final OtpLogReadPlatformService readService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Paged list of OTP audit rows. Filters: search, status, channel, "
+ "dateFrom, dateTo. Destination is masked server-side; payloads are excluded by default "
+ "— use the /{id} endpoint with includePayload=true for the full record.")
public OtpLogPage list(
@QueryParam("page") @DefaultValue("0") final int page,
@QueryParam("pageSize") @DefaultValue("25") final int pageSize,
@QueryParam("search") final String search,
@QueryParam("status") final String status,
@QueryParam("channel") final String channel,
@QueryParam("purpose") final String purpose,
@QueryParam("userId") final Long userId,
@QueryParam("username") final String username,
@QueryParam("dateFrom") final String dateFrom,
@QueryParam("dateTo") final String dateTo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
final int sanitizedPageSize = (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : pageSize;
final int sanitizedPage = Math.max(0, page);
final int offset = sanitizedPage * sanitizedPageSize;
final OtpLogSearchCriteria criteria = OtpLogSearchCriteria.builder()
.userId(userId)
.username(blankToNull(username))
.purpose(blankToNull(purpose))
.channel(blankToNull(channel))
.status(blankToNull(status))
.search(blankToNull(search))
.fromCreatedAt(parseFromDate(dateFrom))
.toCreatedAt(parseToDate(dateTo))
.includePayload(false)
.offset(offset)
.limit(sanitizedPageSize)
.build();
return readService.retrieveAll(criteria);
}
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Single OTP audit row by id. includePayload=true (default) also returns "
+ "raw request/response payloads and the OTP hash for forensics.")
public OtpLogData getById(@PathParam("id") final Long id,
@QueryParam("includePayload") @DefaultValue("true") final boolean includePayload) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
final OtpLogData row = readService.retrieveOne(id, includePayload);
if (row == null) {
throw new NotFoundException("OTP log " + id + " not found");
}
return row;
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static String blankToNull(final String s) {
return s == null || s.isBlank() ? null : s;
}
/**
* Parse a yyyy-MM-dd date as the start of that day (00:00:00). Lenient — also accepts a full
* ISO datetime if the caller sends one.
*/
private static LocalDateTime parseFromDate(final String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s).atStartOfDay();
} catch (final Exception ignored) {
try {
return LocalDateTime.parse(s);
} catch (final Exception ignored2) {
return null;
}
}
}
/**
* Parse a yyyy-MM-dd date as the end of that day (23:59:59.999) so dateTo is inclusive.
*/
private static LocalDateTime parseToDate(final String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s).atTime(23, 59, 59, 999_000_000);
} catch (final Exception ignored) {
try {
return LocalDateTime.parse(s);
} catch (final Exception ignored2) {
return null;
}
}
}
}
@@ -0,0 +1,114 @@
/**
* 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.otp.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.service.EmailLoginOtpService;
import org.springframework.stereotype.Component;
/**
* Public endpoints for sending and verifying email-based login OTPs.
*
* <p>POST /v1/login/otp/email/generate body: { "username": "alice" } — email looked up from m_appuser
* <p>POST /v1/login/otp/email/verify body: { "otpId": 1234, "otp": "123456" }
*/
@Path("/v1/login/otp/email")
@Component
@Tag(name = "Login Email OTP", description = "Email-based OTP send/verify for 2FA login")
@RequiredArgsConstructor
public class EmailLoginOtpApiResource {
private final EmailLoginOtpService emailLoginOtpService;
@POST
@Path("generate")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Send OTP to the user's registered email")
public OtpGenerateResponse generate(final String body, @Context final HttpServletRequest httpRequest) {
final JsonObject payload = parse(body);
final String username = stringOrNull(payload, "username");
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
return emailLoginOtpService.generate(username, ipAddress, userAgent);
}
@POST
@Path("verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Verify the email OTP")
public OtpVerifyResponse verify(final String body) {
final JsonObject payload = parse(body);
final Long otpId = longOrNull(payload, "otpId");
final String otp = stringOrNull(payload, "otp");
return emailLoginOtpService.verify(otpId, otp);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final Exception e) {
return new JsonObject();
}
}
private String stringOrNull(final JsonObject obj, final String key) {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
private Long longOrNull(final JsonObject obj, final String key) {
try {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final Exception e) {
return null;
}
}
private String clientIp(final HttpServletRequest req) {
if (req == null) {
return null;
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,114 @@
/**
* 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.otp.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.service.LoginOtpService;
import org.springframework.stereotype.Component;
/**
* Public endpoints for sending and verifying login OTPs (no auth required, similar to PAN/GSTIN flows).
*
* <p>POST /v1/login/otp/generate body: { "username": "alice" } ← mobile is looked up from m_appuser
* <p>POST /v1/login/otp/verify body: { "otpId": 1234, "otp": "123456" }
*/
@Path("/v1/login/otp")
@Component
@Tag(name = "Login OTP", description = "OTP send/verify for 2FA login (LincPay-backed)")
@RequiredArgsConstructor
public class LoginOtpApiResource {
private final LoginOtpService loginOtpService;
@POST
@Path("generate")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Send OTP to the user's registered mobile (looked up from m_appuser) via LincPay")
public OtpGenerateResponse generate(final String body, @Context final HttpServletRequest httpRequest) {
final JsonObject payload = parse(body);
final String username = stringOrNull(payload, "username");
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
return loginOtpService.generate(username, ipAddress, userAgent);
}
@POST
@Path("verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Verify the OTP that was sent")
public OtpVerifyResponse verify(final String body) {
final JsonObject payload = parse(body);
final Long otpId = longOrNull(payload, "otpId");
final String otp = stringOrNull(payload, "otp");
return loginOtpService.verify(otpId, otp);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final Exception e) {
return new JsonObject();
}
}
private String stringOrNull(final JsonObject obj, final String key) {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
private Long longOrNull(final JsonObject obj, final String key) {
try {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final Exception e) {
return null;
}
}
private String clientIp(final HttpServletRequest req) {
if (req == null) {
return null;
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,36 @@
/**
* 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.otp.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class OtpGenerateResponse {
private final boolean success;
private final Long otpId;
private final String otpReference;
private final String maskedMobile;
private final Integer expiresInSeconds;
private final String message;
}
@@ -0,0 +1,69 @@
/**
* 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.otp.data;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
/**
* Admin-facing audit row for an OTP send/verify cycle. Designed for the OTP-logs grid:
* destination is masked, channel is normalised (SMS/EMAIL/TOTP), message is human-readable.
*
* <p>Heavy raw fields (request/response payloads, OTP hash) live on the detail endpoint only.
*/
@Getter
@Builder
@AllArgsConstructor
public class OtpLogData {
private final Long id;
private final String username;
/** Full name from m_appuser (firstname + lastname). Useful in detail view; can be hidden in grid. */
private final String userFullName;
/** SMS / EMAIL / TOTP — UI-facing channel name (provider mapped: LINCPAY → SMS). */
private final String channel;
/** Masked recipient (e.g. {@code +91 XXXXXX2076} for SMS, {@code adXXXX@gmail.com} for email). */
private final String destination;
private final String otpReference;
/** PENDING / SENT / VERIFIED / FAILED / EXPIRED / LOCKED. */
private final String status;
private final String purpose;
private final Integer attempts;
private final Integer maxAttempts;
private final LocalDateTime createdOn;
private final OffsetDateTime expiresOn;
private final OffsetDateTime verifiedOn;
private final String ipAddress;
private final String userAgent;
/** Human-readable summary — error_message on failure, synthesised text on success. */
private final String message;
// Heavy / privacy-sensitive fields — only on detail endpoint with includePayload=true.
private final String requestPayload;
private final String responsePayload;
private final String otpHash;
// Underlying provider value (LINCPAY, EMAIL, ...) — kept for forensics, optional in grid.
private final String provider;
/** Raw user id from m_otp_log.user_id — useful for filter "show all by user". */
private final Long userId;
}
@@ -0,0 +1,32 @@
/**
* 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.otp.data;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class OtpLogPage {
/** Total rows matching the filter (across all pages). Frontend uses this to size pagination. */
private final long totalElements;
private final List<OtpLogData> pageItems;
}
@@ -0,0 +1,48 @@
/**
* 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.otp.data;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
/**
* Filter knobs for the admin OTP-log search. All fields optional; null means "don't filter on this".
*/
@Getter
@Builder
public class OtpLogSearchCriteria {
private final Long userId;
private final String username;
private final String purpose;
private final String provider;
/** UI-facing channel filter — SMS / EMAIL / TOTP. Mapped to underlying provider value. */
private final String channel;
private final String status;
private final String mobileNumber;
private final String emailAddress;
/** LIKE-match across username, destination (mobile/email) and otp_reference. */
private final String search;
private final LocalDateTime fromCreatedAt;
private final LocalDateTime toCreatedAt;
private final boolean includePayload;
private final int offset;
private final int limit;
}
@@ -0,0 +1,33 @@
/**
* 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.otp.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class OtpVerifyResponse {
private final boolean verified;
private final String message;
private final Integer attemptsRemaining;
}
@@ -0,0 +1,129 @@
/**
* 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.otp.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* Audit log of every OTP issued (login 2FA, KYC, future flows). One row per OTP request.
*
* <p>Status transitions: PENDING → SENT (provider accepted) → VERIFIED / EXPIRED / FAILED.
*
* <p>Secrets are NEVER stored — {@link #requestPayload} has them redacted before save.
*/
@Entity
@Table(name = "m_otp_log")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@NoArgsConstructor
public class OtpLog extends AbstractPersistableCustom<Long> {
@Column(name = "purpose", length = 30, nullable = false)
private String purpose;
@Column(name = "provider", length = 30, nullable = false)
private String provider;
@Column(name = "user_id")
private Long userId;
@Column(name = "username", length = 100)
private String username;
@Column(name = "mobile_number", length = 20)
private String mobileNumber;
@Column(name = "email_address", length = 255)
private String emailAddress;
@Column(name = "otp_reference", length = 100)
private String otpReference;
@Column(name = "otp_hash", length = 255)
private String otpHash;
@Column(name = "status", length = 20, nullable = false)
private String status = "PENDING";
@Column(name = "attempts", nullable = false)
private Integer attempts = 0;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "request_payload", columnDefinition = "LONGTEXT")
private String requestPayload;
@Column(name = "response_payload", columnDefinition = "LONGTEXT")
private String responsePayload;
@Column(name = "error_message", length = 1000)
private String errorMessage;
@Column(name = "expires_at")
private OffsetDateTime expiresAt;
@Column(name = "verified_at")
private OffsetDateTime verifiedAt;
@Column(name = "is_active", nullable = false)
private Boolean isActive = Boolean.TRUE;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = Boolean.FALSE;
@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
}
@@ -0,0 +1,30 @@
/**
* 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.otp.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OtpLogRepository extends JpaRepository<OtpLog, Long> {
Optional<OtpLog> findFirstByIdAndIsDeletedFalseOrderByIdDesc(Long id);
List<OtpLog> findByMobileNumberAndPurposeAndIsDeletedFalseOrderByIdDesc(String mobileNumber, String purpose);
}
@@ -0,0 +1,37 @@
/**
* 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.otp.service;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
public interface EmailLoginOtpService {
/**
* Generate a 6-digit OTP, store its hash in m_otp_log, and email the plaintext OTP
* to the user's registered email address (looked up from m_appuser).
*/
OtpGenerateResponse generate(String username, String ipAddress, String userAgent);
/**
* Verify the user-submitted OTP against the stored hash. Increments attempts and
* updates status on the audit row.
*/
OtpVerifyResponse verify(Long otpId, String otp);
}
@@ -0,0 +1,255 @@
/**
* 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.otp.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
private static final Logger LOG = LoggerFactory.getLogger(EmailLoginOtpServiceImpl.class);
private static final String PURPOSE_LOGIN_2FA_EMAIL = "LOGIN_2FA_EMAIL";
private static final String PROVIDER_EMAIL = "EMAIL";
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_SENT = "SENT";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXPIRED = "EXPIRED";
@Value("${fineract.security.otp.ttl-seconds:300}")
private int ttlSeconds;
@Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts;
private final OtpLogRepository otpLogRepository;
private final AppUserRepository appUserRepository;
private final EmailSenderService emailSenderService;
private final EmailTemplateService emailTemplateService;
private final UserMfaFactorRepository userMfaFactorRepository;
private final SecureRandom secureRandom = new SecureRandom();
@Autowired
public EmailLoginOtpServiceImpl(final OtpLogRepository otpLogRepository, final AppUserRepository appUserRepository,
final EmailSenderService emailSenderService, final EmailTemplateService emailTemplateService,
final UserMfaFactorRepository userMfaFactorRepository) {
this.otpLogRepository = otpLogRepository;
this.appUserRepository = appUserRepository;
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
this.userMfaFactorRepository = userMfaFactorRepository;
}
@Override
@Transactional
public OtpGenerateResponse generate(final String username, final String ipAddress, final String userAgent) {
if (username == null || username.isBlank()) {
return OtpGenerateResponse.builder().success(false).message("username is required").build();
}
final AppUser user = appUserRepository.findAppUserByName(username);
if (user == null) {
// Don't leak whether the user exists.
LOG.info("Email login OTP requested for unknown username: {}", username);
return OtpGenerateResponse.builder().success(false).message("Unable to send OTP").build();
}
final String email = user.getEmail();
if (email == null || email.isBlank()) {
LOG.warn("Email login OTP requested for user without email: {}", username);
return OtpGenerateResponse.builder().success(false)
.message("No email registered for this user — contact admin").build();
}
// Generate 6-digit OTP locally; store the SHA-256 hash, send the plaintext via email.
final String otp = generateSixDigitOtp();
final int validityMinutes = Math.max(1, ttlSeconds / 60);
final String recipientName = displayName(user);
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_LOGIN_2FA_EMAIL);
log.setProvider(PROVIDER_EMAIL);
log.setUserId(user.getId());
log.setUsername(username);
log.setEmailAddress(email);
log.setOtpHash(sha256(otp));
log.setStatus(STATUS_PENDING);
log.setIpAddress(ipAddress);
log.setUserAgent(userAgent);
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
// Render the branded template — same look and feel for OTP, welcome, password-reset, alerts.
final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes);
final String subject = "Your CBSnexora Sign-in OTP";
final boolean sent = emailSenderService.sendHtml(email, subject, html);
if (sent) {
log.setStatus(STATUS_SENT);
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, mask(email));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.maskedMobile(mask(email)) // reusing the field; UI just shows where it was sent
.expiresInSeconds(ttlSeconds)
.message("OTP sent to your registered email")
.build();
}
log.setStatus(STATUS_FAILED);
log.setErrorMessage("SMTP send failed");
otpLogRepository.saveAndFlush(log);
return OtpGenerateResponse.builder().success(false).message("OTP send failed").build();
}
@Override
@Transactional
public OtpVerifyResponse verify(final Long otpId, final String otp) {
if (otpId == null || otp == null || otp.isBlank()) {
return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build();
}
final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
if (row == null) {
return OtpVerifyResponse.builder().verified(false).message("OTP not found").build();
}
if (!PURPOSE_LOGIN_2FA_EMAIL.equals(row.getPurpose())) {
return OtpVerifyResponse.builder().verified(false).message("OTP purpose mismatch").build();
}
if (STATUS_VERIFIED.equals(row.getStatus())) {
return OtpVerifyResponse.builder().verified(false).message("OTP already used").build();
}
if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("OTP expired").build();
}
if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build();
}
row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1);
final boolean matched = row.getOtpHash() != null && row.getOtpHash().equals(sha256(otp));
if (matched) {
row.setStatus(STATUS_VERIFIED);
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
autoPromoteFactor(row);
LOG.info("Email login OTP verified. otpId={}, user={}", row.getId(), row.getUsername());
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
otpLogRepository.saveAndFlush(row);
final int remaining = Math.max(0, maxAttempts - row.getAttempts());
return OtpVerifyResponse.builder().verified(false).message("Invalid OTP").attemptsRemaining(remaining).build();
}
/**
* Upsert the user's EMAIL MFA factor on a successful OTP verify. Creates the row if it's the
* user's first verification (acts as enrollment); otherwise refreshes verified_on + otp_log_id.
* The user proved possession of the destination by completing the OTP cycle, so the factor row
* is the natural side-effect of that proof.
*/
private void autoPromoteFactor(final OtpLog row) {
if (row.getUserId() == null) {
return;
}
final AppUser user = appUserRepository.findById(row.getUserId()).orElse(null);
if (user == null) {
return;
}
final UserMfaFactor factor = userMfaFactorRepository
.findByAppUser_IdAndFactorType(row.getUserId(), MfaFactorType.EMAIL)
.orElseGet(() -> {
final UserMfaFactor f = new UserMfaFactor();
f.setAppUser(user);
f.setFactorType(MfaFactorType.EMAIL);
return f;
});
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
factor.setOtpLogId(row.getId());
factor.setDisabledOn(null);
factor.setDisabledBy(null);
factor.setDestination(row.getEmailAddress());
userMfaFactorRepository.saveAndFlush(factor);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private String generateSixDigitOtp() {
final int n = secureRandom.nextInt(1_000_000);
return String.format("%06d", n);
}
private String sha256(final String input) {
try {
final MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
private String mask(final String email) {
if (email == null || !email.contains("@")) {
return "****";
}
final int at = email.indexOf('@');
final String local = email.substring(0, at);
final String domain = email.substring(at);
if (local.length() <= 2) {
return "*" + local.substring(local.length() - 1) + domain;
}
return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain;
}
private String displayName(final AppUser user) {
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
return user.getFirstname();
}
return user.getUsername();
}
}
@@ -0,0 +1,36 @@
/**
* 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.otp.service;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
public interface LoginOtpService {
/**
* Issue a fresh OTP for the user identified by {@code username}. Mobile number is looked up
* from {@code m_appuser.mobile_no}. Persists an audit row in m_otp_log for every attempt.
*/
OtpGenerateResponse generate(String username, String ipAddress, String userAgent);
/**
* Verify an OTP entered by the user against the stored hash. Increments attempts and updates status.
*/
OtpVerifyResponse verify(Long otpId, String otp);
}
@@ -0,0 +1,380 @@
/**
* 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.otp.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
@Service
public class LoginOtpServiceImpl implements LoginOtpService {
private static final Logger LOG = LoggerFactory.getLogger(LoginOtpServiceImpl.class);
private static final String PURPOSE_LOGIN_2FA = "LOGIN_2FA";
private static final String PROVIDER_LINCPAY = "LINCPAY";
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_SENT = "SENT";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXPIRED = "EXPIRED";
@Value("${fineract.kyc.lincpay.api-key}")
private String apiKey;
@Value("${fineract.kyc.lincpay.secret}")
private String secret;
@Value("${fineract.kyc.lincpay.base-url}")
private String baseUrl;
@Value("${fineract.security.otp.lincpay.generate-path:/mobile/generate-otp}")
private String generateOtpPath;
@Value("${fineract.security.otp.lincpay.verify-path:/verify-mobile-otp}")
private String verifyOtpPath;
@Value("${fineract.security.otp.ttl-seconds:300}")
private int ttlSeconds;
@Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts;
private final RestTemplate restTemplate;
private final OtpLogRepository otpLogRepository;
private final AppUserRepository appUserRepository;
private final UserMfaFactorRepository userMfaFactorRepository;
@Autowired
public LoginOtpServiceImpl(final OtpLogRepository otpLogRepository, final AppUserRepository appUserRepository,
final UserMfaFactorRepository userMfaFactorRepository) {
this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository;
this.appUserRepository = appUserRepository;
this.userMfaFactorRepository = userMfaFactorRepository;
}
@Override
@Transactional
public OtpGenerateResponse generate(final String username, final String ipAddress, final String userAgent) {
if (username == null || username.isBlank()) {
return OtpGenerateResponse.builder().success(false).message("username is required").build();
}
// Look up the user and their registered mobile number — single source of truth is m_appuser.
final AppUser appUser = appUserRepository.findAppUserByName(username);
if (appUser == null) {
// Don't leak whether the user exists. Generic message + no DB row written.
LOG.info("Login OTP requested for unknown username: {}", username);
return OtpGenerateResponse.builder().success(false).message("Unable to send OTP").build();
}
final String mobileNumber = appUser.getMobileNo();
if (mobileNumber == null || mobileNumber.isBlank()) {
LOG.warn("Login OTP requested for user without registered mobile: {}", username);
return OtpGenerateResponse.builder().success(false)
.message("No mobile number registered for this user — contact admin").build();
}
// LincPay generates the OTP on their side and SMS-delivers it. We track the request via reference;
// verification is delegated to LincPay's verify-mobile-otp endpoint.
final OtpLog log = new OtpLog();
log.setUserId(appUser.getId());
log.setPurpose(PURPOSE_LOGIN_2FA);
log.setProvider(PROVIDER_LINCPAY);
log.setUsername(username);
log.setMobileNumber(mobileNumber);
log.setStatus(STATUS_PENDING);
log.setIpAddress(ipAddress);
log.setUserAgent(userAgent);
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
final JsonObject requestBody = new JsonObject();
requestBody.addProperty("apiKey", apiKey);
requestBody.addProperty("mobileNumber", mobileNumber);
requestBody.addProperty("secretKey", secret);
// Stored copy has secrets redacted to avoid leaking credentials in audit logs
final JsonObject redactedRequest = new JsonObject();
redactedRequest.addProperty("apiKey", "***REDACTED***");
redactedRequest.addProperty("mobileNumber", mobileNumber);
redactedRequest.addProperty("secretKey", "***REDACTED***");
log.setRequestPayload(redactedRequest.toString());
try {
final String url = baseUrl + generateOtpPath;
final String rawResponse = post(url, requestBody.toString());
log.setResponsePayload(truncate(rawResponse, 65000));
final JsonObject response = parseJson(rawResponse);
if (isSuccess(response)) {
log.setStatus(STATUS_SENT);
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number",
"transactionId", "txnId", "requestId"));
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), mask(mobileNumber));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.otpReference(saved.getOtpReference())
.maskedMobile(mask(mobileNumber))
.expiresInSeconds(ttlSeconds)
.message("OTP sent")
.build();
}
final String errorMsg = extractField(response, "message", "errorMessage", "error", "description");
log.setStatus(STATUS_FAILED);
log.setErrorMessage(errorMsg);
otpLogRepository.saveAndFlush(log);
LOG.warn("LincPay login OTP failed: {}", errorMsg);
return OtpGenerateResponse.builder().success(false).message(errorMsg != null ? errorMsg : "OTP send failed").build();
} catch (final HttpClientErrorException | HttpServerErrorException e) {
log.setStatus(STATUS_FAILED);
log.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
log.setResponsePayload(truncate(e.getResponseBodyAsString(), 65000));
otpLogRepository.saveAndFlush(log);
LOG.error("LincPay login OTP HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return OtpGenerateResponse.builder().success(false).message("OTP send failed (HTTP " + e.getStatusCode() + ")").build();
} catch (final Exception e) {
log.setStatus(STATUS_FAILED);
log.setErrorMessage(truncate(e.getMessage(), 900));
otpLogRepository.saveAndFlush(log);
LOG.error("LincPay login OTP unexpected error", e);
return OtpGenerateResponse.builder().success(false).message("OTP send failed: " + e.getMessage()).build();
}
}
@Override
@Transactional
public OtpVerifyResponse verify(final Long otpId, final String otp) {
if (otpId == null || otp == null || otp.isBlank()) {
return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build();
}
final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
if (row == null) {
return OtpVerifyResponse.builder().verified(false).message("OTP not found").build();
}
if (STATUS_VERIFIED.equals(row.getStatus())) {
return OtpVerifyResponse.builder().verified(false).message("OTP already used").build();
}
if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("OTP expired").build();
}
if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build();
}
row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1);
// Hash of the submitted OTP — useful for audit (we never store the plaintext)
row.setOtpHash(sha256(otp));
// Delegate verification to LincPay
final JsonObject requestBody = new JsonObject();
requestBody.addProperty("apiKey", apiKey);
requestBody.addProperty("mobileNumber", row.getMobileNumber());
requestBody.addProperty("otp", otp);
requestBody.addProperty("secretKey", secret);
try {
final String url = baseUrl + verifyOtpPath;
final String rawResponse = post(url, requestBody.toString());
// Append latest verify response to existing payload for audit
final String existing = row.getResponsePayload() == null ? "" : row.getResponsePayload();
row.setResponsePayload(truncate(existing + "\n--verify--\n" + rawResponse, 65000));
final JsonObject response = parseJson(rawResponse);
if (isSuccess(response)) {
row.setStatus(STATUS_VERIFIED);
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
autoPromoteFactor(row);
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), mask(row.getMobileNumber()));
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
otpLogRepository.saveAndFlush(row);
final String providerMsg = extractField(response, "message", "errorMessage", "error", "description");
final int remaining = Math.max(0, maxAttempts - row.getAttempts());
return OtpVerifyResponse.builder().verified(false)
.message(providerMsg != null ? providerMsg : "Invalid OTP")
.attemptsRemaining(remaining).build();
} catch (final HttpClientErrorException | HttpServerErrorException e) {
row.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
otpLogRepository.saveAndFlush(row);
LOG.error("LincPay verify-mobile-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return OtpVerifyResponse.builder().verified(false)
.message("OTP verification failed (HTTP " + e.getStatusCode() + ")").build();
} catch (final Exception e) {
row.setErrorMessage(truncate(e.getMessage(), 900));
otpLogRepository.saveAndFlush(row);
LOG.error("LincPay verify-mobile-otp unexpected error", e);
return OtpVerifyResponse.builder().verified(false)
.message("OTP verification failed: " + e.getMessage()).build();
}
}
/**
* Upsert the user's SMS MFA factor on a successful OTP verify. Creates the row if it's the
* user's first verification (acts as enrollment); otherwise refreshes verified_on + otp_log_id.
* The user proved possession of the destination by completing the OTP cycle, so the factor row
* is the natural side-effect of that proof.
*/
private void autoPromoteFactor(final OtpLog row) {
if (row.getUserId() == null) {
return;
}
final AppUser user = appUserRepository.findById(row.getUserId()).orElse(null);
if (user == null) {
return;
}
final UserMfaFactor factor = userMfaFactorRepository
.findByAppUser_IdAndFactorType(row.getUserId(), MfaFactorType.SMS)
.orElseGet(() -> {
final UserMfaFactor f = new UserMfaFactor();
f.setAppUser(user);
f.setFactorType(MfaFactorType.SMS);
return f;
});
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
factor.setOtpLogId(row.getId());
factor.setDisabledOn(null);
factor.setDisabledBy(null);
factor.setDestination(row.getMobileNumber());
userMfaFactorRepository.saveAndFlush(factor);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private String post(final String url, final String requestBodyJson) {
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("accept", "*/*");
final HttpEntity<String> entity = new HttpEntity<>(requestBodyJson, headers);
final ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
return response.getBody();
}
private JsonObject parseJson(final String raw) {
if (raw == null || raw.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(raw);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private boolean isSuccess(final JsonObject response) {
for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) {
if (response.has(key)) {
final JsonElement v = response.get(key);
if (v.isJsonPrimitive()) {
final String s = v.getAsString();
if ("200".equals(s) || "0".equals(s) || "true".equalsIgnoreCase(s)
|| "success".equalsIgnoreCase(s) || "SUCCESS".equalsIgnoreCase(s)) {
return true;
}
}
}
}
if (response.has("success") && response.get("success").isJsonPrimitive()) {
return response.get("success").getAsBoolean();
}
return false;
}
private String extractField(final JsonObject obj, final String... keys) {
if (obj == null) {
return null;
}
for (final String key : keys) {
if (obj.has(key) && obj.get(key).isJsonPrimitive()) {
return obj.get(key).getAsString();
}
}
// Also look one level deep in nested "data" object
if (obj.has("data") && obj.get("data").isJsonObject()) {
final JsonObject data = obj.getAsJsonObject("data");
for (final String key : keys) {
if (data.has(key) && data.get(key).isJsonPrimitive()) {
return data.get(key).getAsString();
}
}
}
return null;
}
private String sha256(final String input) {
try {
final MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
private String mask(final String mobile) {
if (mobile == null || mobile.length() < 4) {
return "****";
}
return "******" + mobile.substring(mobile.length() - 4);
}
private String truncate(final String s, final int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -0,0 +1,32 @@
/**
* 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.otp.service;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogData;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogPage;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogSearchCriteria;
public interface OtpLogReadPlatformService {
/** Paginated, filterable read of m_otp_log joined with m_appuser. Admin-only. */
OtpLogPage retrieveAll(OtpLogSearchCriteria criteria);
/** Single row by id; null if not found. */
OtpLogData retrieveOne(Long id, boolean includePayload);
}
@@ -0,0 +1,359 @@
/**
* 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.otp.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogData;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogPage;
import org.apache.fineract.infrastructure.security.otp.data.OtpLogSearchCriteria;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OtpLogReadPlatformServiceImpl implements OtpLogReadPlatformService {
private static final String CHANNEL_SMS = "SMS";
private static final String CHANNEL_EMAIL = "EMAIL";
private static final String CHANNEL_TOTP = "TOTP";
private static final String PROVIDER_LINCPAY = "LINCPAY";
private static final String PROVIDER_EMAIL = "EMAIL";
@Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts;
private final JdbcTemplate jdbcTemplate;
@Override
public OtpLogPage retrieveAll(final OtpLogSearchCriteria criteria) {
final List<Object> params = new ArrayList<>();
final StringBuilder where = new StringBuilder(" WHERE 1=1 ");
if (criteria.getUserId() != null) {
where.append(" AND o.user_id = ? ");
params.add(criteria.getUserId());
}
if (notBlank(criteria.getUsername())) {
where.append(" AND o.username = ? ");
params.add(criteria.getUsername());
}
if (notBlank(criteria.getPurpose())) {
where.append(" AND o.purpose = ? ");
params.add(criteria.getPurpose());
}
// channel (UI-facing) takes precedence over provider (raw)
if (notBlank(criteria.getChannel())) {
final String mapped = mapChannelToProvider(criteria.getChannel());
if (mapped != null) {
where.append(" AND o.provider = ? ");
params.add(mapped);
}
} else if (notBlank(criteria.getProvider())) {
where.append(" AND o.provider = ? ");
params.add(criteria.getProvider());
}
if (notBlank(criteria.getStatus())) {
where.append(" AND o.status = ? ");
params.add(criteria.getStatus());
}
if (notBlank(criteria.getMobileNumber())) {
where.append(" AND o.mobile_number = ? ");
params.add(criteria.getMobileNumber());
}
if (notBlank(criteria.getEmailAddress())) {
where.append(" AND o.email_address = ? ");
params.add(criteria.getEmailAddress());
}
if (notBlank(criteria.getSearch())) {
// LIKE-match across username, mobile, email, otp_reference
where.append(" AND (o.username LIKE ? OR o.mobile_number LIKE ? OR o.email_address LIKE ? OR o.otp_reference LIKE ?) ");
final String like = "%" + criteria.getSearch() + "%";
params.add(like);
params.add(like);
params.add(like);
params.add(like);
}
if (criteria.getFromCreatedAt() != null) {
where.append(" AND o.created_at >= ? ");
params.add(Timestamp.valueOf(criteria.getFromCreatedAt()));
}
if (criteria.getToCreatedAt() != null) {
where.append(" AND o.created_at <= ? ");
params.add(Timestamp.valueOf(criteria.getToCreatedAt()));
}
final String countSql = "SELECT COUNT(*) FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
+ where.toString();
final Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); // NOSONAR
if (total == null || total == 0L) {
return new OtpLogPage(0L, Collections.emptyList());
}
final String dataSql = "SELECT " + selectList(criteria.isIncludePayload())
+ " FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
+ where.toString()
+ " ORDER BY o.id DESC LIMIT ? OFFSET ? ";
final List<Object> dataParams = new ArrayList<>(params);
dataParams.add(criteria.getLimit() <= 0 ? 25 : criteria.getLimit());
dataParams.add(Math.max(0, criteria.getOffset()));
final List<OtpLogData> rows = jdbcTemplate.query(dataSql, rowMapper(criteria.isIncludePayload()), dataParams.toArray()); // NOSONAR
return new OtpLogPage(total, rows);
}
@Override
public OtpLogData retrieveOne(final Long id, final boolean includePayload) {
if (id == null) {
return null;
}
final String sql = "SELECT " + selectList(includePayload)
+ " FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
+ " WHERE o.id = ? ";
final List<OtpLogData> rows = jdbcTemplate.query(sql, rowMapper(includePayload), id); // NOSONAR
return rows.isEmpty() ? null : rows.get(0);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private String selectList(final boolean includePayload) {
final StringBuilder sb = new StringBuilder();
sb.append(" o.id, o.purpose, o.provider, o.user_id, o.username, ");
sb.append(" u.firstname AS u_firstname, u.lastname AS u_lastname, ");
sb.append(" o.mobile_number, o.email_address, o.otp_reference, o.status, o.attempts, ");
sb.append(" o.ip_address, o.user_agent, o.error_message, o.expires_at, o.verified_at, ");
sb.append(" o.created_at ");
if (includePayload) {
sb.append(" , o.request_payload, o.response_payload, o.otp_hash ");
}
return sb.toString();
}
private RowMapper<OtpLogData> rowMapper(final boolean includePayload) {
return (final ResultSet rs, final int rowNum) -> {
final String firstname = rs.getString("u_firstname");
final String lastname = rs.getString("u_lastname");
final String fullName = composeFullName(firstname, lastname);
final String provider = rs.getString("provider");
final String channel = mapProviderToChannel(provider);
final String mobile = rs.getString("mobile_number");
final String email = rs.getString("email_address");
final String destination = mask(channel, mobile, email);
final String status = rs.getString("status");
final String errorMessage = rs.getString("error_message");
final String otpReference = rs.getString("otp_reference");
final String message = composeMessage(status, provider, otpReference, errorMessage);
return OtpLogData.builder()
.id(rs.getLong("id"))
.userId(getNullableLong(rs, "user_id"))
.username(rs.getString("username"))
.userFullName(fullName)
.channel(channel)
.destination(destination)
.otpReference(otpReference)
.status(status)
.purpose(rs.getString("purpose"))
.attempts(getNullableInt(rs, "attempts"))
.maxAttempts(maxAttempts)
.createdOn(toLocalDateTime(rs.getTimestamp("created_at")))
.expiresOn(toOffsetDateTime(rs.getTimestamp("expires_at")))
.verifiedOn(toOffsetDateTime(rs.getTimestamp("verified_at")))
.ipAddress(rs.getString("ip_address"))
.userAgent(rs.getString("user_agent"))
.message(message)
.provider(provider)
.requestPayload(includePayload ? rs.getString("request_payload") : null)
.responsePayload(includePayload ? rs.getString("response_payload") : null)
.otpHash(includePayload ? rs.getString("otp_hash") : null)
.build();
};
}
private static boolean notBlank(final String s) {
return s != null && !s.isBlank();
}
private static String mapChannelToProvider(final String channel) {
if (channel == null) {
return null;
}
if (CHANNEL_SMS.equalsIgnoreCase(channel)) {
return PROVIDER_LINCPAY;
}
if (CHANNEL_EMAIL.equalsIgnoreCase(channel)) {
return PROVIDER_EMAIL;
}
if (CHANNEL_TOTP.equalsIgnoreCase(channel)) {
return CHANNEL_TOTP;
}
return null;
}
private static String mapProviderToChannel(final String provider) {
if (provider == null) {
return null;
}
if (PROVIDER_LINCPAY.equalsIgnoreCase(provider)) {
return CHANNEL_SMS;
}
return provider.toUpperCase();
}
/**
* Mask the recipient for display. Mobile keeps the last 4 digits; email keeps first 2 chars
* of the local part and the full domain.
*/
private static String mask(final String channel, final String mobile, final String email) {
if (CHANNEL_SMS.equals(channel) && notBlank(mobile)) {
return maskMobile(mobile);
}
if (CHANNEL_EMAIL.equals(channel) && notBlank(email)) {
return maskEmail(email);
}
if (notBlank(mobile)) {
return maskMobile(mobile);
}
if (notBlank(email)) {
return maskEmail(email);
}
return null;
}
private static String maskMobile(final String mobile) {
if (mobile == null || mobile.length() <= 4) {
return mobile;
}
String prefix = "";
String digits = mobile;
if (mobile.startsWith("+") && mobile.length() > 3) {
// Keep "+91 " (or similar 2-digit country code) as a visible prefix
prefix = mobile.substring(0, 3) + " ";
digits = mobile.substring(3);
}
if (digits.length() <= 4) {
return prefix + digits;
}
final String last4 = digits.substring(digits.length() - 4);
final int hidden = digits.length() - 4;
return prefix + "X".repeat(hidden) + last4;
}
private static String maskEmail(final String email) {
if (email == null) {
return null;
}
final int at = email.indexOf('@');
if (at < 0) {
return email;
}
final String local = email.substring(0, at);
final String domain = email.substring(at);
if (local.length() <= 2) {
return local + "XXXX" + domain;
}
return local.substring(0, 2) + "XXXX" + domain;
}
private static String composeMessage(final String status, final String provider, final String otpReference, final String errorMessage) {
if (notBlank(errorMessage)) {
return errorMessage;
}
if (status == null) {
return null;
}
switch (status) {
case "VERIFIED":
return "OTP verified";
case "SENT":
final String providerLabel = providerLabel(provider);
if (notBlank(otpReference)) {
return "OTP delivered (" + providerLabel + " req#" + otpReference + ")";
}
return "OTP delivered" + (providerLabel != null ? " (" + providerLabel + ")" : "");
case "EXPIRED":
return "OTP expired";
case "FAILED":
return "OTP send failed";
case "PENDING":
return "OTP pending delivery";
case "LOCKED":
return "OTP locked — too many attempts";
default:
return status;
}
}
private static String providerLabel(final String provider) {
if (PROVIDER_LINCPAY.equalsIgnoreCase(provider)) {
return "LincPay";
}
if (PROVIDER_EMAIL.equalsIgnoreCase(provider)) {
return "Email";
}
return provider;
}
private static Long getNullableLong(final ResultSet rs, final String col) throws SQLException {
final long v = rs.getLong(col);
return rs.wasNull() ? null : v;
}
private static Integer getNullableInt(final ResultSet rs, final String col) throws SQLException {
final int v = rs.getInt(col);
return rs.wasNull() ? null : v;
}
private static LocalDateTime toLocalDateTime(final Timestamp ts) {
return ts == null ? null : ts.toLocalDateTime();
}
private static OffsetDateTime toOffsetDateTime(final Timestamp ts) {
return ts == null ? null : ts.toLocalDateTime().atOffset(ZoneOffset.UTC);
}
private static String composeFullName(final String firstname, final String lastname) {
if (firstname == null && lastname == null) {
return null;
}
if (firstname == null) {
return lastname;
}
if (lastname == null) {
return firstname;
}
return (firstname + " " + lastname).trim();
}
}
@@ -0,0 +1,247 @@
/**
* 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 java.util.ArrayList;
import java.util.List;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
import org.apache.fineract.infrastructure.security.data.OTPDeliveryMethod;
import org.apache.fineract.infrastructure.security.data.OTPMetadata;
import org.apache.fineract.infrastructure.security.data.OTPRequest;
import org.apache.fineract.infrastructure.security.domain.OTPRequestRepository;
import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
import org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository;
import org.apache.fineract.infrastructure.security.exception.AccessTokenInvalidIException;
import org.apache.fineract.infrastructure.security.exception.OTPDeliveryMethodInvalidException;
import org.apache.fineract.infrastructure.security.exception.OTPTokenInvalidException;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.service.EmailLoginOtpService;
import org.apache.fineract.infrastructure.security.otp.service.LoginOtpService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
/**
* Replaces {@link TwoFactorServiceImpl} when {@code fineract.security.2fa.enabled=true}. Drives the
* existing /twofactor/* endpoints and the {@code Fineract-Platform-TFA-Token} filter chain off the
* MFA factors that users register on the Access Control page (table {@code m_user_mfa_factor}),
* delivering OTPs via LincPay (SMS) or our branded SMTP template (email) — not via the legacy
* SMS-provider / email-template path.
*
* <p>OTP correlation between {@link #createNewOTPToken} and {@link #createAccessTokenFromOTP} uses
* the {@code OTPRequest.token} field as a sentinel for the {@code m_otp_log.id}. Verification then
* delegates back to {@link LoginOtpService#verify(Long, String)} or
* {@link EmailLoginOtpService#verify(Long, String)} which know how to actually validate the user-
* submitted OTP (LincPay round-trip for SMS, local hash compare for email).
*/
@Service
@Primary
@ConditionalOnProperty("fineract.security.2fa.enabled")
public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
private static final Logger LOG = LoggerFactory.getLogger(MfaFactorBackedTwoFactorServiceImpl.class);
private final UserMfaFactorRepository userMfaFactorRepository;
private final LoginOtpService smsLoginOtpService;
private final EmailLoginOtpService emailLoginOtpService;
private final OTPRequestRepository otpRequestRepository;
private final TFAccessTokenRepository tfAccessTokenRepository;
private final AccessTokenGenerationService accessTokenGenerationService;
private final TwoFactorConfigurationService configurationService;
@Autowired
public MfaFactorBackedTwoFactorServiceImpl(final UserMfaFactorRepository userMfaFactorRepository,
final LoginOtpService smsLoginOtpService, final EmailLoginOtpService emailLoginOtpService,
final OTPRequestRepository otpRequestRepository, final TFAccessTokenRepository tfAccessTokenRepository,
final AccessTokenGenerationService accessTokenGenerationService,
final TwoFactorConfigurationService configurationService) {
this.userMfaFactorRepository = userMfaFactorRepository;
this.smsLoginOtpService = smsLoginOtpService;
this.emailLoginOtpService = emailLoginOtpService;
this.otpRequestRepository = otpRequestRepository;
this.tfAccessTokenRepository = tfAccessTokenRepository;
this.accessTokenGenerationService = accessTokenGenerationService;
this.configurationService = configurationService;
}
// ── Delivery methods ─────────────────────────────────────────────────────
@Override
public List<OTPDeliveryMethod> getDeliveryMethodsForUser(final AppUser user) {
final List<OTPDeliveryMethod> methods = new ArrayList<>();
for (final UserMfaFactor factor : userMfaFactorRepository.findByAppUser_IdAndDisabledOnIsNull(user.getId())) {
if (!factor.isVerified()) {
continue;
}
final String name = mapFactorTypeToDeliveryName(factor.getFactorType());
if (name == null) {
continue; // TOTP / TOKEN not supported by this OTP path
}
final String target = factor.getDestination();
if (target == null || target.isBlank()) {
continue;
}
methods.add(new OTPDeliveryMethod().setName(name).setTarget(target));
}
return methods;
}
// ── Generate OTP ─────────────────────────────────────────────────────────
@Override
public OTPRequest createNewOTPToken(final AppUser user, final String deliveryMethodName, final boolean extendedAccessToken) {
if (deliveryMethodName == null) {
throw new OTPDeliveryMethodInvalidException();
}
final boolean isSms = TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
final boolean isEmail = TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
if (!isSms && !isEmail) {
throw new OTPDeliveryMethodInvalidException();
}
// The user must have a verified factor of the requested type.
final MfaFactorType type = isSms ? MfaFactorType.SMS : MfaFactorType.EMAIL;
final UserMfaFactor factor = userMfaFactorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
.filter(UserMfaFactor::isVerified)
.filter(f -> f.getDisabledOn() == null)
.orElseThrow(OTPDeliveryMethodInvalidException::new);
// Send OTP via the appropriate channel (LincPay / SMTP) and persist the audit row.
final OtpGenerateResponse result = isSms
? smsLoginOtpService.generate(user.getUsername(), null, null)
: emailLoginOtpService.generate(user.getUsername(), null, null);
if (!result.isSuccess() || result.getOtpId() == null) {
LOG.warn("MFA OTP delivery failed for user {} via {}: {}", user.getUsername(), deliveryMethodName, result.getMessage());
throw new OTPDeliveryMethodInvalidException();
}
// Mirror an OTPRequest into the legacy in-memory store so the existing /twofactor/validate
// path and frontend flow continue to work. The "token" field carries the otpId so we can
// route verification back to the right OTP service in createAccessTokenFromOTP.
final OTPDeliveryMethod method = new OTPDeliveryMethod().setName(deliveryMethodName).setTarget(factor.getDestination());
final int liveTime = configurationService.getOTPTokenLiveTime();
final OTPRequest request = OTPRequest.create(String.valueOf(result.getOtpId()), liveTime, extendedAccessToken, method);
otpRequestRepository.addOTPRequest(user, request);
return request;
}
// ── Verify OTP and issue TFA access token ────────────────────────────────
@Override
@CachePut(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#result.token + 'tok')")
public TFAccessToken createAccessTokenFromOTP(final AppUser user, final String otpToken) {
final OTPRequest otpRequest = otpRequestRepository.getOTPRequestForUser(user);
if (otpRequest == null || !otpRequest.isValid()) {
throw new OTPTokenInvalidException();
}
final Long otpId;
try {
otpId = Long.parseLong(otpRequest.getToken());
} catch (final NumberFormatException e) {
throw new OTPTokenInvalidException();
}
final OTPMetadata metadata = otpRequest.getMetadata();
final String channel = metadata != null && metadata.getDeliveryMethod() != null
? metadata.getDeliveryMethod().getName()
: null;
final OtpVerifyResponse verifyResult;
if (TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
verifyResult = smsLoginOtpService.verify(otpId, otpToken);
} else if (TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
verifyResult = emailLoginOtpService.verify(otpId, otpToken);
} else {
throw new OTPTokenInvalidException();
}
if (verifyResult == null || !verifyResult.isVerified()) {
throw new OTPTokenInvalidException();
}
otpRequestRepository.deleteOTPRequestForUser(user);
final int liveTime = metadata != null && metadata.isExtendedAccessToken()
? configurationService.getAccessTokenExtendedLiveTime()
: configurationService.getAccessTokenLiveTime();
final String token = accessTokenGenerationService.generateRandomToken();
final TFAccessToken accessToken = TFAccessToken.create(token, user, liveTime);
tfAccessTokenRepository.save(accessToken);
return accessToken;
}
// ── Token validation / lookup / invalidate (same shape as legacy) ────────
@Override
public void validateTwoFactorAccessToken(final AppUser user, final String token) {
final TFAccessToken accessToken = fetchAccessTokenForUser(user, token);
if (accessToken == null || !accessToken.isValid()) {
throw new AccessTokenInvalidIException();
}
}
@Override
@Cacheable(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#token + 'tok')")
public TFAccessToken fetchAccessTokenForUser(final AppUser user, final String token) {
return tfAccessTokenRepository.findByUserAndToken(user, token);
}
@Override
@CacheEvict(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#result.token + 'tok')")
public TFAccessToken invalidateAccessToken(final AppUser user, final JsonCommand command) {
final String token = command.stringValueOfParameterNamed("token");
final TFAccessToken accessToken = fetchAccessTokenForUser(user, token);
if (accessToken == null || !accessToken.isValid()) {
throw new AccessTokenInvalidIException();
}
accessToken.setEnabled(false);
tfAccessTokenRepository.save(accessToken);
return accessToken;
}
// ── Helpers ──────────────────────────────────────────────────────────────
private String mapFactorTypeToDeliveryName(final MfaFactorType type) {
if (type == null) {
return null;
}
return switch (type) {
case SMS -> TwoFactorConstants.SMS_DELIVERY_METHOD_NAME;
case EMAIL -> TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME;
default -> null; // TOTP and TOKEN aren't routed through this OTP path
};
}
}
@@ -107,6 +107,24 @@ public class Address extends AbstractPersistableCustom<Long> {
@Column(name = "updated_on") @Column(name = "updated_on")
private LocalDate updatedOn; private LocalDate updatedOn;
@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;
// Note: m_address already has manual audit columns (created_by VARCHAR, created_on DATE,
// updated_by VARCHAR, updated_on DATE) populated by service code — kept as-is.
private Address(final String street, final String addressLine1, final String addressLine2, final String addressLine3, private Address(final String street, final String addressLine1, final String addressLine2, final String addressLine3,
final String townVillage, final String city, final String countyDistrict, final CodeValue stateProvince, final String townVillage, final String city, final String countyDistrict, final CodeValue stateProvince,
final CodeValue country, final String postalCode, final BigDecimal latitude, final BigDecimal longitude, final String createdBy, final CodeValue country, final String postalCode, final BigDecimal latitude, final BigDecimal longitude, final String createdBy,
@@ -403,4 +421,40 @@ public class Address extends AbstractPersistableCustom<Long> {
this.addressType = addressType; this.addressType = addressType;
} }
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;
}
} }
@@ -175,7 +175,7 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic
this.context.authenticatedUser(); this.context.authenticatedUser();
final AddFieldsMapper rm = new AddFieldsMapper(); final AddFieldsMapper rm = new AddFieldsMapper();
final String sql = "select " + rm.schema() + " where client.id=?"; final String sql = "select " + rm.schema() + " where client.id=? and addr.is_deleted = false";
return this.jdbcTemplate.query(sql, rm, new Object[] { clientid }); // NOSONAR return this.jdbcTemplate.query(sql, rm, new Object[] { clientid }); // NOSONAR
} }
@@ -184,7 +184,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic
public List<AddressData> retrieveAllClientAddress(final long clientid) { public List<AddressData> retrieveAllClientAddress(final long clientid) {
this.context.authenticatedUser(); this.context.authenticatedUser();
final AddMapper rm = new AddMapper(); final AddMapper rm = new AddMapper();
final String sql = "select " + rm.schema() + " and ca.client_id=? and ca.is_active=true"; final String sql = "select " + rm.schema()
+ " and ca.client_id=? and ca.is_active=true and ca.is_deleted = false and addr.is_deleted = false";
return this.jdbcTemplate.query(sql, rm, new Object[] { clientid }); // NOSONAR return this.jdbcTemplate.query(sql, rm, new Object[] { clientid }); // NOSONAR
} }
@@ -193,7 +194,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic
this.context.authenticatedUser(); this.context.authenticatedUser();
final AddMapper rm = new AddMapper(); final AddMapper rm = new AddMapper();
final String sql = "select " + rm.schema() + " and ca.client_id=? and ca.address_type_id=? and ca.is_active=true"; final String sql = "select " + rm.schema()
+ " and ca.client_id=? and ca.address_type_id=? and ca.is_active=true and ca.is_deleted = false and addr.is_deleted = false";
return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, typeid }); // NOSONAR return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, typeid }); // NOSONAR
} }
@@ -204,7 +206,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic
boolean temp = Boolean.parseBoolean(status); boolean temp = Boolean.parseBoolean(status);
final AddMapper rm = new AddMapper(); final AddMapper rm = new AddMapper();
final String sql = "select " + rm.schema() + " and ca.client_id=? and ca.address_type_id=? and ca.is_active=?"; final String sql = "select " + rm.schema()
+ " and ca.client_id=? and ca.address_type_id=? and ca.is_active=? and ca.is_deleted = false and addr.is_deleted = false";
return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, typeid, temp }); // NOSONAR return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, typeid, temp }); // NOSONAR
} }
@@ -215,7 +218,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic
boolean temp = Boolean.parseBoolean(status); boolean temp = Boolean.parseBoolean(status);
final AddMapper rm = new AddMapper(); final AddMapper rm = new AddMapper();
final String sql = "select " + rm.schema() + " and ca.client_id=? and ca.is_active=?"; final String sql = "select " + rm.schema()
+ " and ca.client_id=? and ca.is_active=? and ca.is_deleted = false and addr.is_deleted = false";
return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, temp }); // NOSONAR return this.jdbcTemplate.query(sql, rm, new Object[] { clientid, temp }); // NOSONAR
} }
@@ -20,15 +20,23 @@ package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.address.domain.Address; import org.apache.fineract.portfolio.address.domain.Address;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity @Entity
@Table(name = "m_client_address") @Table(name = "m_client_address")
@EntityListeners(AuditingEntityListener.class)
public class ClientAddress extends AbstractPersistableCustom<Long> { public class ClientAddress extends AbstractPersistableCustom<Long> {
@ManyToOne @ManyToOne
@@ -44,11 +52,41 @@ public class ClientAddress extends AbstractPersistableCustom<Long> {
@Column(name = "is_active") @Column(name = "is_active")
private boolean isActive; private boolean isActive;
@Column(name = "is_deleted", nullable = false)
private boolean isDeleted = 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;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
private ClientAddress(final Client client, final Address address, final CodeValue addressType, final boolean isActive) { private ClientAddress(final Client client, final Address address, final CodeValue addressType, final boolean isActive) {
this.client = client; this.client = client;
this.address = address; this.address = address;
this.addressType = addressType; this.addressType = addressType;
this.isActive = isActive; this.isActive = isActive;
this.isDeleted = false;
} }
@@ -93,4 +131,32 @@ public class ClientAddress extends AbstractPersistableCustom<Long> {
this.isActive = isActive; this.isActive = isActive;
} }
public boolean isDeleted() {
return this.isDeleted;
}
public void setDeleted(final boolean isDeleted) {
this.isDeleted = isDeleted;
}
public Long getDeletedBy() {
return this.deletedBy;
}
public void setDeletedBy(final Long deletedBy) {
this.deletedBy = deletedBy;
}
public java.time.OffsetDateTime getDeletedAt() {
return this.deletedAt;
}
public void setDeletedAt(final java.time.OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Long getVersion() {
return this.version;
}
} }
@@ -20,12 +20,14 @@ package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Transient; import jakarta.persistence.Transient;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -35,9 +37,15 @@ import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType; import org.apache.fineract.portfolio.charge.domain.ChargeCalculationType;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.client.api.ClientApiConstants; import org.apache.fineract.portfolio.client.api.ClientApiConstants;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity @Entity
@Table(name = "m_client_charge") @Table(name = "m_client_charge")
@EntityListeners(AuditingEntityListener.class)
public class ClientCharge extends AbstractPersistableCustom<Long> { public class ClientCharge extends AbstractPersistableCustom<Long> {
@ManyToOne(optional = false) @ManyToOne(optional = false)
@@ -90,6 +98,22 @@ public class ClientCharge extends AbstractPersistableCustom<Long> {
@Transient @Transient
private OrganisationCurrency currency; private OrganisationCurrency currency;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
protected ClientCharge() { protected ClientCharge() {
// //
} }
@@ -20,14 +20,22 @@ package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity @Entity
@Table(name = "m_client_charge_paid_by") @Table(name = "m_client_charge_paid_by")
@EntityListeners(AuditingEntityListener.class)
public class ClientChargePaidBy extends AbstractPersistableCustom<Long> { public class ClientChargePaidBy extends AbstractPersistableCustom<Long> {
@ManyToOne @ManyToOne
@@ -41,6 +49,22 @@ public class ClientChargePaidBy extends AbstractPersistableCustom<Long> {
@Column(name = "amount", scale = 6, precision = 19, nullable = false) @Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount; private BigDecimal amount;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
protected ClientChargePaidBy() { protected ClientChargePaidBy() {
} }
@@ -21,16 +21,26 @@ package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity @Entity
@Table(name = "m_family_members") @Table(name = "m_family_members")
@EntityListeners(AuditingEntityListener.class)
public class ClientFamilyMembers extends AbstractPersistableCustom<Long> { public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@ManyToOne @ManyToOne
@@ -109,6 +119,38 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@Column(name = "marriage_authority", length = 50) @Column(name = "marriage_authority", length = 50)
private String marriageAuthority; private String marriageAuthority;
@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 OffsetDateTime deletedAt;
@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName, private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship, final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession, final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
@@ -131,6 +173,8 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
this.percentageShare = percentageShare; this.percentageShare = percentageShare;
this.remarks = remarks; this.remarks = remarks;
this.isAlive = true; this.isAlive = true;
this.isActive = Boolean.TRUE;
this.isDeleted = Boolean.FALSE;
} }
public ClientFamilyMembers() { public ClientFamilyMembers() {
@@ -293,4 +337,23 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
public String getMarriageAuthority() { return marriageAuthority; } public String getMarriageAuthority() { return marriageAuthority; }
public void setMarriageAuthority(String marriageAuthority) { this.marriageAuthority = marriageAuthority; } public void setMarriageAuthority(String marriageAuthority) { this.marriageAuthority = marriageAuthority; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
public Boolean getIsDeleted() { return isDeleted; }
public void setIsDeleted(Boolean isDeleted) { this.isDeleted = isDeleted; }
public Long getDeletedBy() { return deletedBy; }
public void setDeletedBy(Long deletedBy) { this.deletedBy = deletedBy; }
public OffsetDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; }
public Long getVersion() { return version; }
public Long getCreatedBy() { return createdBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
public Long getModifiedBy() { return modifiedBy; }
public LocalDateTime getModifiedOn() { return modifiedOn; }
} }
@@ -20,12 +20,14 @@ package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -38,9 +40,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.api.ClientApiConstants; import org.apache.fineract.portfolio.client.api.ClientApiConstants;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity @Entity
@Table(name = "m_client_non_person") @Table(name = "m_client_non_person")
@EntityListeners(AuditingEntityListener.class)
public class ClientNonPerson extends AbstractPersistableCustom<Long> { public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@OneToOne(optional = false) @OneToOne(optional = false)
@@ -73,6 +81,22 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@Column(name = "date_of_registration", nullable = true) @Column(name = "date_of_registration", nullable = true)
private LocalDate dateOfRegistration; private LocalDate dateOfRegistration;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(name = "modified_by")
private Long modifiedBy;
@LastModifiedDate
@Column(name = "modified_on")
private LocalDateTime modifiedOn;
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo, String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) { LocalDate dateOfRegistration) {
@@ -0,0 +1,95 @@
/**
* 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.portfolio.client.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.springframework.stereotype.Component;
/**
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
* <ul>
* <li><b>TT</b> — Customer type code:
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
* Priority order when multiple flags overlap: <em>Staff &gt; Minor &gt; Corporate &gt; Individual</em>.</li>
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
* </ul>
*
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
* The generator handles concurrent inserts by retrying on uniqueness collisions
* (the caller's outer save is what actually triggers the unique constraint).
*/
@Component
@RequiredArgsConstructor
public class CifNumberGenerator {
private static final String TYPE_INDIVIDUAL = "11";
private static final String TYPE_CORPORATE = "22";
private static final String TYPE_MINOR = "33";
private static final String TYPE_STAFF = "44";
private static final int SEQUENCE_WIDTH = 6;
private static final int MAX_COLLISION_RETRIES = 5;
private final ClientRepository clientRepository;
/**
* Generate the next CIF number for the given client. Caller is responsible for persisting.
*/
public String generate(final Client client) {
final String typeCode = resolveTypeCode(client);
final int year = resolveYear(client);
final String prefix = typeCode + year;
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
return candidate;
}
nextSequence++;
}
// Last resort: append client id to guarantee uniqueness if every attempt collided
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
}
private String resolveTypeCode(final Client client) {
if (client.isStaff()) {
return TYPE_STAFF;
}
if (Boolean.TRUE.equals(client.getIsMinor())) {
return TYPE_MINOR;
}
final Integer legalForm = client.getLegalForm();
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
return TYPE_CORPORATE;
}
return TYPE_INDIVIDUAL;
}
private int resolveYear(final Client client) {
final LocalDate submittedOn = client.getSubmittedOnDate();
return (submittedOn != null ? submittedOn : LocalDate.now()).getYear();
}
}
@@ -118,7 +118,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
this.context.authenticatedUser(); this.context.authenticatedUser();
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.client_id=?"; final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
} }
@@ -129,7 +129,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
this.context.authenticatedUser(); this.context.authenticatedUser();
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.id=? "; final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
} }
@@ -454,12 +454,17 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
@Override @Override
public CommandProcessingResult deleteFamilyMember(Long clientFamilyMemberId, JsonCommand command) { public CommandProcessingResult deleteFamilyMember(Long clientFamilyMemberId, JsonCommand command) {
this.context.authenticatedUser(); final org.apache.fineract.useradministration.domain.AppUser currentUser = this.context.authenticatedUser();
apiJsonDeserializer.validateForDelete(clientFamilyMemberId); apiJsonDeserializer.validateForDelete(clientFamilyMemberId);
ClientFamilyMembers clientFamilyMember = clientFamilyRepository.getReferenceById(clientFamilyMemberId); ClientFamilyMembers clientFamilyMember = clientFamilyRepository.getReferenceById(clientFamilyMemberId);
clientFamilyRepository.delete(clientFamilyMember); // Soft delete: flip is_active=false / is_deleted=true so historical references stay intact.
clientFamilyMember.setIsActive(Boolean.FALSE);
clientFamilyMember.setIsDeleted(Boolean.TRUE);
clientFamilyMember.setDeletedBy(currentUser.getId());
clientFamilyMember.setDeletedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
clientFamilyRepository.saveAndFlush(clientFamilyMember);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
@@ -204,11 +204,16 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
@Override @Override
@Transactional @Transactional
public CommandProcessingResult deleteNominee(final Long clientId, final Long nomineeId) { public CommandProcessingResult deleteNominee(final Long clientId, final Long nomineeId) {
this.context.authenticatedUser(); final org.apache.fineract.useradministration.domain.AppUser currentUser = this.context.authenticatedUser();
final ClientNominee nominee = nomineeRepository.findByClientIdAndId(clientId, nomineeId) final ClientNominee nominee = nomineeRepository.findByClientIdAndId(clientId, nomineeId)
.orElseThrow(() -> new ClientNotFoundException(nomineeId)); .orElseThrow(() -> new ClientNotFoundException(nomineeId));
nomineeRepository.delete(nominee); // Soft delete: flip is_active=false / is_deleted=true so account history stays intact.
nominee.setIsActive(Boolean.FALSE);
nominee.setIsDeleted(Boolean.TRUE);
nominee.setDeletedBy(currentUser.getId());
nominee.setDeletedAt(java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC));
nomineeRepository.saveAndFlush(nominee);
return new CommandProcessingResultBuilder() return new CommandProcessingResultBuilder()
.withClientId(clientId) .withClientId(clientId)
@@ -127,6 +127,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final ClientKycWritePlatformService clientKycWritePlatformService; private final ClientKycWritePlatformService clientKycWritePlatformService;
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository; private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository; private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final CifNumberGenerator cifNumberGenerator;
@Transactional @Transactional
@Override @Override
@@ -466,10 +467,11 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(newClient); this.clientRepository.saveAndFlush(newClient);
} }
// CIF Number generation - auto-generate with CIF prefix if not provided // CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
if (StringUtils.isBlank(cifNumber)) { if (StringUtils.isBlank(cifNumber)) {
final String generatedCif = "CIF" + StringUtils.leftPad(newClient.getId().toString(), 10, '0'); newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
newClient.updateCifNumber(generatedCif);
this.clientRepository.saveAndFlush(newClient); this.clientRepository.saveAndFlush(newClient);
} }
@@ -58,9 +58,10 @@ public class ClientSearchService {
Optional<ClientTextSearch> request = searchRequest.getRequest(); Optional<ClientTextSearch> request = searchRequest.getRequest();
String requestSearchText = request.map(ClientTextSearch::getText).orElse(null); String requestSearchText = request.map(ClientTextSearch::getText).orElse(null);
String searchText = Objects.toString(requestSearchText, ""); String searchText = Objects.toString(requestSearchText, "");
boolean activeOnly = request.map(ClientTextSearch::getActiveOnly).filter(Boolean::booleanValue).isPresent();
Pageable pageable = searchRequest.toPageable(); Pageable pageable = searchRequest.toPageable();
return clientRepository.searchByText(searchText, pageable, hierarchy).map(clientSearchDataMapper::map); return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly).map(clientSearchDataMapper::map);
} }
} }
@@ -24,4 +24,11 @@ import lombok.Data;
public class ClientTextSearch { public class ClientTextSearch {
private String text; private String text;
/**
* When true, only clients with status = ACTIVE (300) are returned. Used by linking flows
* (nominee, family-member linked client, guardian, signatory) where pending/closed/rejected
* clients should never appear. Defaults to false to preserve behavior of the general client list.
*/
private Boolean activeOnly;
} }
@@ -122,6 +122,8 @@ final class UsersApiResourceSwagger {
public String lastname; public String lastname;
@Schema(example = "whatever@mifos.org") @Schema(example = "whatever@mifos.org")
public String email; public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(example = "1") @Schema(example = "1")
public Long officeId; public Long officeId;
@Schema(example = "1") @Schema(example = "1")
@@ -201,6 +203,8 @@ final class UsersApiResourceSwagger {
public String lastname; public String lastname;
@Schema(example = "whatever@mifos.org") @Schema(example = "whatever@mifos.org")
public String email; public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(example = "1") @Schema(example = "1")
public Long officeId; public Long officeId;
@Schema(example = "1") @Schema(example = "1")
@@ -0,0 +1,124 @@
/**
* 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.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.service.SelfUserMfaFactorService;
import org.springframework.stereotype.Component;
/**
* The currently authenticated user manages their own MFA factors. Works for any authenticated
* user — admin or self-service alike — because the path is deliberately NOT under {@code /self/};
* Fineract's {@code SelfServiceUserAuthorizationManager} denies non-self-service users from
* anything under {@code /self/}, which would otherwise lock admins out of their own MFA settings.
*
* <p>Authentication is the platform's standard user filter; the principal is resolved via
* {@code PlatformSecurityContext.authenticatedUser()} inside the service.
*
* <ul>
* <li>GET /mfa/factors — list active factors for the current user</li>
* <li>POST /mfa/factors — register / upsert a verified factor</li>
* <li>DELETE /mfa/factors/{id} — soft-disable a factor (sets disabled_on)</li>
* </ul>
*/
@Path("/v1/mfa/factors")
@Component
@Tag(name = "MFA Factors", description = "Authenticated user's own MFA factors")
@RequiredArgsConstructor
public class MfaFactorApiResource {
private final SelfUserMfaFactorService selfUserMfaFactorService;
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List the current user's MFA factors. By default only active factors are "
+ "returned; pass includeDisabled=true to also include disabled rows so the UI can "
+ "show them with an 'Enable' (re-enroll via OTP) action.")
public List<UserMfaFactorData> list(@QueryParam("includeDisabled") @DefaultValue("false") final boolean includeDisabled) {
return includeDisabled
? selfUserMfaFactorService.listAllForCurrentUser()
: selfUserMfaFactorService.listForCurrentUser();
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Register or refresh an MFA factor for the current user (upserts by factor type)")
public UserMfaFactorData register(final String body) {
final JsonObject payload = parse(body);
final MfaFactorType type = MfaFactorType.fromString(stringOrNull(payload, "factorType"));
final String destination = stringOrNull(payload, "destination");
final String secret = stringOrNull(payload, "secret");
final String serial = stringOrNull(payload, "serial");
final Long otpId = longOrNull(payload, "otpId");
return selfUserMfaFactorService.registerForCurrentUser(type, destination, secret, serial, otpId);
}
@DELETE
@Path("{factorId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Soft-disable an MFA factor owned by the current user")
public Map<String, Object> disable(@PathParam("factorId") final Long factorId) {
final Long id = selfUserMfaFactorService.disableForCurrentUser(factorId);
return Map.of("resourceId", id);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final Exception e) {
return new JsonObject();
}
}
private String stringOrNull(final JsonObject obj, final String key) {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
private Long longOrNull(final JsonObject obj, final String key) {
try {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final Exception e) {
return null;
}
}
}
@@ -0,0 +1,64 @@
/**
* 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.data;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
@Getter
@Builder
@AllArgsConstructor
public class UserMfaFactorData {
private final Long id;
private final MfaFactorType factorType;
private final String destination;
/**
* TOTP base32 secret (only returned on enrollment endpoint, never on subsequent reads).
*/
private final String secret;
private final String serial;
private final boolean verified;
private final OffsetDateTime verifiedOn;
private final OffsetDateTime disabledOn;
private final Long disabledBy;
private final Long otpLogId;
/**
* Map an entity to a DTO. {@code includeSecret=false} hides the TOTP secret on list/read paths.
*/
public static UserMfaFactorData from(final UserMfaFactor f, final boolean includeSecret) {
return UserMfaFactorData.builder()
.id(f.getId())
.factorType(f.getFactorType())
.destination(f.getDestination())
.secret(includeSecret ? f.getSecret() : null)
.serial(f.getSerial())
.verified(f.isVerified())
.verifiedOn(f.getVerifiedOn())
.disabledOn(f.getDisabledOn())
.disabledBy(f.getDisabledBy())
.otpLogId(f.getOtpLogId())
.build();
}
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class UserMfaFactorNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserMfaFactorNotFoundException(final Long id) {
super("error.msg.user.mfa.factor.not.found", "MFA factor with id " + id + " not found", id);
}
}
@@ -0,0 +1,41 @@
/**
* 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.service;
import java.util.List;
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
public interface SelfUserMfaFactorService {
/** Active (non-disabled) factors for the currently authenticated user. */
List<UserMfaFactorData> listForCurrentUser();
/**
* Every factor for the user — active and disabled — newest first. Used by the manage-factors
* screen so users can see disabled factors and re-enroll them via OTP.
*/
List<UserMfaFactorData> listAllForCurrentUser();
/** Upsert a factor (verified=true, disabled_on cleared). Returns the persisted DTO. */
UserMfaFactorData registerForCurrentUser(MfaFactorType type, String destination, String secret, String serial, Long otpId);
/** Soft-disable the factor (sets disabled_on=NOW). Returns the disabled factor's id. */
Long disableForCurrentUser(Long factorId);
}
@@ -0,0 +1,108 @@
/**
* 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.service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.mfa.exception.UserMfaFactorNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SelfUserMfaFactorServiceImpl implements SelfUserMfaFactorService {
private final PlatformSecurityContext securityContext;
private final UserMfaFactorRepository factorRepository;
@Override
@Transactional(readOnly = true)
public List<UserMfaFactorData> listForCurrentUser() {
final AppUser user = securityContext.authenticatedUser();
return factorRepository.findByAppUser_IdAndDisabledOnIsNull(user.getId()).stream()
.map(f -> UserMfaFactorData.from(f, false))
.toList();
}
@Override
@Transactional(readOnly = true)
public List<UserMfaFactorData> listAllForCurrentUser() {
final AppUser user = securityContext.authenticatedUser();
return factorRepository.findByAppUser_IdOrderByIdDesc(user.getId()).stream()
.map(f -> UserMfaFactorData.from(f, false))
.toList();
}
@Override
@Transactional
public UserMfaFactorData registerForCurrentUser(final MfaFactorType type, final String destination, final String secret,
final String serial, final Long otpId) {
if (type == null) {
throw new PlatformDataIntegrityException("error.msg.user.mfa.factor.type.required",
"factorType is required (one of SMS, EMAIL, TOTP, TOKEN)");
}
final AppUser user = securityContext.authenticatedUser();
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
.orElseGet(() -> {
final UserMfaFactor f = new UserMfaFactor();
f.setAppUser(user);
f.setFactorType(type);
return f;
});
factor.setDestination(destination);
factor.setSecret(secret);
factor.setSerial(serial);
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
factor.setOtpLogId(otpId);
factor.setDisabledOn(null);
factor.setDisabledBy(null);
final UserMfaFactor saved = factorRepository.saveAndFlush(factor);
// TOTP secret is returned only on first enrollment so the UI can show the QR; never on subsequent reads.
final boolean includeSecret = type == MfaFactorType.TOTP;
return UserMfaFactorData.from(saved, includeSecret);
}
@Override
@Transactional
public Long disableForCurrentUser(final Long factorId) {
final AppUser user = securityContext.authenticatedUser();
final UserMfaFactor factor = factorRepository.findById(factorId)
.orElseThrow(() -> new UserMfaFactorNotFoundException(factorId));
if (!factor.getAppUser().getId().equals(user.getId())) {
// Don't reveal which other user owns it — pretend not found.
throw new UserMfaFactorNotFoundException(factorId);
}
factor.setDisabledOn(OffsetDateTime.now(ZoneOffset.UTC));
factor.setDisabledBy(user.getId());
factorRepository.saveAndFlush(factor);
return factor.getId();
}
}
@@ -124,9 +124,9 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic
linkedStaff = null; linkedStaff = null;
} }
AppUserData retUser = AppUserData.instance(user.getId(), user.getUsername(), user.getEmail(), user.getOffice().getId(), AppUserData retUser = AppUserData.instance(user.getId(), user.getUsername(), user.getEmail(), user.getMobileNo(),
user.getOffice().getName(), user.getFirstname(), user.getLastname(), availableRoles, null, selectedUserRoles, linkedStaff, user.getOffice().getId(), user.getOffice().getName(), user.getFirstname(), user.getLastname(), availableRoles, null,
user.getPasswordNeverExpires(), user.isSelfServiceUser()); selectedUserRoles, linkedStaff, user.getPasswordNeverExpires(), user.isSelfServiceUser());
if (retUser.isSelfServiceUser()) { if (retUser.isSelfServiceUser()) {
Set<ClientData> clients = new HashSet<>(); Set<ClientData> clients = new HashSet<>();
@@ -159,6 +159,7 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic
final String firstname = rs.getString("firstname"); final String firstname = rs.getString("firstname");
final String lastname = rs.getString("lastname"); final String lastname = rs.getString("lastname");
final String email = rs.getString("email"); final String email = rs.getString("email");
final String mobileNo = rs.getString("mobileNo");
final Long officeId = JdbcSupport.getLong(rs, "officeId"); final Long officeId = JdbcSupport.getLong(rs, "officeId");
final String officeName = rs.getString("officeName"); final String officeName = rs.getString("officeName");
final Long staffId = JdbcSupport.getLong(rs, "staffId"); final Long staffId = JdbcSupport.getLong(rs, "staffId");
@@ -172,12 +173,12 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic
} else { } else {
linkedStaff = null; linkedStaff = null;
} }
return AppUserData.instance(id, username, email, officeId, officeName, firstname, lastname, null, null, selectedRoles, return AppUserData.instance(id, username, email, mobileNo, officeId, officeName, firstname, lastname, null, null, selectedRoles,
linkedStaff, passwordNeverExpire, isSelfServiceUser); linkedStaff, passwordNeverExpire, isSelfServiceUser);
} }
public String schema() { public String schema() {
return " u.id as id, u.username as username, u.firstname as firstname, u.lastname as lastname, u.email as email, u.password_never_expires as passwordNeverExpires, " return " u.id as id, u.username as username, u.firstname as firstname, u.lastname as lastname, u.email as email, u.mobile_no as mobileNo, u.password_never_expires as passwordNeverExpires, "
+ " u.office_id as officeId, o.name as officeName, u.staff_id as staffId, u.is_self_service_user as isSelfServiceUser from m_appuser u " + " u.office_id as officeId, o.name as officeName, u.staff_id as staffId, u.is_self_service_user as isSelfServiceUser from m_appuser u "
+ " join m_office o on o.id = u.office_id where o.hierarchy like ? and u.is_deleted=false order by u.username"; + " join m_office o on o.id = u.office_id where o.hierarchy like ? and u.is_deleted=false order by u.username";
} }
@@ -52,6 +52,7 @@ public final class UserDataValidator {
public static final String FIRSTNAME = "firstname"; public static final String FIRSTNAME = "firstname";
public static final String LASTNAME = "lastname"; public static final String LASTNAME = "lastname";
public static final String EMAIL = "email"; public static final String EMAIL = "email";
public static final String MOBILE_NO = "mobileNo";
public static final String OFFICE_ID = "officeId"; public static final String OFFICE_ID = "officeId";
public static final String NOT_SELECTED_ROLES = "notSelectedRoles"; public static final String NOT_SELECTED_ROLES = "notSelectedRoles";
public static final String ROLES = "roles"; public static final String ROLES = "roles";
@@ -62,11 +63,11 @@ public final class UserDataValidator {
* The parameters supported for this command. * The parameters supported for this command.
*/ */
private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new HashSet<>( 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, Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
private static final Set<String> UPDATE_SUPPORTED_PARAMETERS = new HashSet<>( 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, Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire"; public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
@@ -664,3 +664,11 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# ── DIP / MNRL Mobile Number Lookup ────────────────────────────────────────── # ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080} fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
# ── Email Configuration ──────────────────────────────────────────────────────
spring.mail.host=${FINERACT_EMAIL_SMTP_HOST:smtp.gmail.com}
spring.mail.port=${FINERACT_EMAIL_SMTP_PORT:587}
spring.mail.username=${FINERACT_EMAIL_SMTP_USERNAME:}
spring.mail.password=${FINERACT_EMAIL_SMTP_PASSWORD:}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.debug=false
@@ -294,4 +294,18 @@
<include file="parts/0280_add_fixed_deposit_product_codes.xml" relativeToChangelogFile="true"/> <include file="parts/0280_add_fixed_deposit_product_codes.xml" relativeToChangelogFile="true"/>
<include file="parts/0284_remove_product_code_unique_constraints.xml" relativeToChangelogFile="true"/> <include file="parts/0284_remove_product_code_unique_constraints.xml" relativeToChangelogFile="true"/>
<include file="parts/0281_update_fd_base_interest_rate_seed_data.xml" relativeToChangelogFile="true"/> <include file="parts/0281_update_fd_base_interest_rate_seed_data.xml" relativeToChangelogFile="true"/>
<include file="parts/0278_add_soft_delete_to_family_members.xml" relativeToChangelogFile="true"/>
<include file="parts/0279_add_soft_delete_columns_client_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0280_add_deletion_audit_and_version_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0281_normalize_audit_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0282_create_m_otp_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0283_add_mobile_to_m_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0284_otp_log_support_email.xml" relativeToChangelogFile="true"/>
<include file="parts/0285_create_m_user_mfa_factor.xml" relativeToChangelogFile="true"/>
<include file="parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0287_mfa_factor_enforcement_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0288_add_disabled_by_to_m_user_mfa_factor.xml" relativeToChangelogFile="true"/>
<include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/>
<include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,43 @@
<?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
-->
<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.1.xsd">
<!-- Soft-delete flags for family members. is_active=true / is_deleted=false by default. -->
<changeSet id="0278_add_is_active_is_deleted_to_family_members" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_family_members" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_family_members">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0278_index_family_members_active" author="fineract">
<preConditions onFail="MARK_RAN">
<not><indexExists tableName="m_family_members" indexName="idx_m_family_members_client_active"/></not>
</preConditions>
<createIndex tableName="m_family_members" indexName="idx_m_family_members_client_active">
<column name="client_id"/>
<column name="is_deleted"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,237 @@
<?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
-->
<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.1.xsd">
<!--
Standardise soft-delete flags across all user-editable client detail tables.
Default state for a row: is_active=TRUE, is_deleted=FALSE.
On removal/unlink: is_active=FALSE, is_deleted=TRUE.
Read services must filter `WHERE is_deleted = false`.
Tables that already have `is_active` only get `is_deleted`.
Tables that have neither get both.
Skipped intentionally:
- m_client (uses status_enum / sub_status state machine)
- m_client_transaction (financial; reverse, don't delete)
- m_client_kyc_verification_log, m_client_transfer_details, m_client_attendance (immutable audit logs)
- m_ckyc_batch (uses its own `status` field)
-->
<!-- 1. Tables that already have is_active — add is_deleted only -->
<changeSet id="0279a_is_deleted_client_address" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_address" columnName="is_deleted"/></not>
</preConditions>
<addColumn tableName="m_client_address">
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279b_is_deleted_client_nominee" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_nominee" columnName="is_deleted"/></not>
</preConditions>
<addColumn tableName="m_client_nominee">
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279c_is_deleted_client_charge" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_charge" columnName="is_deleted"/></not>
</preConditions>
<addColumn tableName="m_client_charge">
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- 2. Tables that have neither — add both -->
<changeSet id="0279d_flags_client_non_person" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_non_person" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_non_person">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279e_flags_client_identifier" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_identifier" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_identifier">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279f_flags_client_kyc" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_kyc" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279g_flags_address" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_address" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_address">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279h_flags_client_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_priority_sector" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_priority_sector">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279i_flags_client_non_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_non_priority_sector" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_non_priority_sector">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279j_flags_client_msme" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_msme" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_msme">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279k_flags_client_mudra_borrower" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_mudra_borrower" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_mudra_borrower">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279l_flags_client_startup_india" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_startup_india" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_startup_india">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279m_flags_client_eou" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_eou" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_eou">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279n_flags_client_charge_paid_by" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_charge_paid_by" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_charge_paid_by">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0279o_flags_client_collateral_management" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_collateral_management" columnName="is_active"/></not>
</preConditions>
<addColumn tableName="m_client_collateral_management">
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,174 @@
<?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
-->
<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.1.xsd">
<!--
Adds deletion-audit columns and an optimistic-locking version column to the
16 client-related tables already covered by 0278/0279 soft-delete columns.
- deleted_by BIGINT — FK-style m_appuser.id (matching existing created_by/last_modified_by style)
- deleted_at TIMESTAMP — when the soft-delete happened
- version BIGINT NOT NULL DEFAULT 0 — used by JPA @Version for optimistic locking
Maker-checker is intentionally NOT modeled per-row. Use the existing
m_portfolio_command_source framework + m_permission.can_maker_checker.
-->
<changeSet id="0280a_deletion_audit_family_members" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_family_members" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_family_members">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280b_deletion_audit_client_address" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_address" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_address">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280c_deletion_audit_client_nominee" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_nominee" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_nominee">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280d_deletion_audit_client_charge" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_charge">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280e_deletion_audit_client_non_person" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_person" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_non_person">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280f_deletion_audit_client_identifier" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_identifier" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_identifier">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280g_deletion_audit_client_kyc" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_kyc" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_kyc">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280h_deletion_audit_address" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_address" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_address">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280i_deletion_audit_client_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_priority_sector" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_priority_sector">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280j_deletion_audit_client_non_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_priority_sector" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_non_priority_sector">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280k_deletion_audit_client_msme" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_msme" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_msme">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280l_deletion_audit_client_mudra_borrower" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_mudra_borrower" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_mudra_borrower">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280m_deletion_audit_client_startup_india" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_startup_india" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_startup_india">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280n_deletion_audit_client_eou" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_eou" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_eou">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280o_deletion_audit_client_charge_paid_by" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge_paid_by" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_charge_paid_by">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet id="0280p_deletion_audit_client_collateral_management" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_collateral_management" columnName="deleted_at"/></not></preConditions>
<addColumn tableName="m_client_collateral_management">
<column name="deleted_by" type="bigint"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,168 @@
<?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
-->
<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.1.xsd">
<!--
Normalises audit column names across the 16 client-related tables to:
created_by BIGINT (FK-style → m_appuser.id; matches existing platform pattern)
created_at TIMESTAMP
modified_by BIGINT (FK-style)
modified_on TIMESTAMP
Group A — already had columns under different names (renamed):
last_modified_by → modified_by
created_on_utc → created_at
last_modified_on_utc → modified_on
created_by → unchanged (already correctly named)
Group B — entities extend AbstractPersistableCustom and had no audit columns;
these are added fresh and populated via Spring Data JPA auditing.
Note: types are BIGINT (user IDs) to stay consistent with the rest of the
platform's existing audit. JOIN m_appuser to fetch usernames in reports.
-->
<!-- =========================== Group A: rename =========================== -->
<changeSet id="0281a_rename_audit_client_nominee" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_nominee" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_nominee" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_nominee" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_nominee" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281b_rename_audit_client_identifier" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_identifier" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_identifier" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_identifier" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_identifier" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281c_rename_audit_client_kyc" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_kyc" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_kyc" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_kyc" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_kyc" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281d_rename_audit_client_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_priority_sector" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_priority_sector" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_priority_sector" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_priority_sector" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281e_rename_audit_client_non_priority_sector" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_non_priority_sector" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281f_rename_audit_client_msme" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_msme" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_msme" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_msme" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_msme" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281g_rename_audit_client_mudra_borrower" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_mudra_borrower" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281h_rename_audit_client_startup_india" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_startup_india" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_startup_india" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_startup_india" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_startup_india" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<changeSet id="0281i_rename_audit_client_eou" author="fineract">
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_eou" columnName="last_modified_by"/></preConditions>
<renameColumn tableName="m_client_eou" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
<renameColumn tableName="m_client_eou" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
<renameColumn tableName="m_client_eou" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
</changeSet>
<!-- =========================== Group B: add ============================ -->
<changeSet id="0281j_add_audit_family_members" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_family_members" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_family_members">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
<changeSet id="0281k_add_audit_client_address" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_address" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_client_address">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
<!-- m_address intentionally skipped: it already has manual audit columns (created_by VARCHAR,
created_on DATE, updated_by VARCHAR, updated_on DATE) populated by service code. -->
<changeSet id="0281m_add_audit_client_charge" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_client_charge">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
<changeSet id="0281n_add_audit_client_non_person" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_person" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_client_non_person">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
<changeSet id="0281o_add_audit_client_charge_paid_by" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge_paid_by" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_client_charge_paid_by">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
<changeSet id="0281p_add_audit_client_collateral_management" author="fineract">
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_collateral_management" columnName="created_at"/></not></preConditions>
<addColumn tableName="m_client_collateral_management">
<column name="created_by" type="bigint"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="bigint"/>
<column name="modified_on" type="datetime"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,93 @@
<?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
-->
<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.1.xsd">
<!--
Audit log for OTP generation/verification. One row per OTP issued.
Used by login 2FA (LincPay provider) and extensible to other purposes.
-->
<changeSet id="0282_create_m_otp_log" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_otp_log"/></not></preConditions>
<createTable tableName="m_otp_log">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="purpose" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="provider" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="user_id" type="BIGINT"/>
<column name="username" type="VARCHAR(100)"/>
<column name="mobile_number" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="otp_reference" type="VARCHAR(100)"/>
<column name="otp_hash" type="VARCHAR(255)"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="attempts" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(45)"/>
<column name="user_agent" type="VARCHAR(500)"/>
<column name="request_payload" type="LONGTEXT"/>
<column name="response_payload" type="LONGTEXT"/>
<column name="error_message" type="VARCHAR(1000)"/>
<column name="expires_at" type="datetime"/>
<column name="verified_at" type="datetime"/>
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="version" type="bigint" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_at" type="datetime"/>
<column name="modified_by" type="BIGINT"/>
<column name="modified_on" type="datetime"/>
</createTable>
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_user_status">
<column name="user_id"/>
<column name="status"/>
</createIndex>
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_otp_reference">
<column name="otp_reference"/>
</createIndex>
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_mobile_purpose">
<column name="mobile_number"/>
<column name="purpose"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?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
-->
<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.1.xsd">
<!-- Mobile number on m_appuser — used by login OTP (2FA) lookup. Nullable; populated at user create time. -->
<changeSet id="0283_add_mobile_no_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_appuser" columnName="mobile_no"/></not>
</preConditions>
<addColumn tableName="m_appuser">
<column name="mobile_no" type="VARCHAR(16)"/>
</addColumn>
<createIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_no">
<column name="mobile_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?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
-->
<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.1.xsd">
<!-- Make m_otp_log work for both SMS-OTP and email-OTP. -->
<changeSet id="0284_otp_log_mobile_number_nullable" author="fineract">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_otp_log" columnName="mobile_number"/>
</preConditions>
<dropNotNullConstraint tableName="m_otp_log" columnName="mobile_number" columnDataType="VARCHAR(20)"/>
</changeSet>
<changeSet id="0284_otp_log_add_email_address" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_otp_log" columnName="email_address"/></not>
</preConditions>
<addColumn tableName="m_otp_log">
<column name="email_address" type="VARCHAR(255)"/>
</addColumn>
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_email_purpose">
<column name="email_address"/>
<column name="purpose"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,111 @@
<?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
-->
<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.1.xsd">
<!--
Persists which MFA factors a given AppUser has enrolled and verified.
One row per (appuser_id, factor_type). Soft-disable via disabled_on.
-->
<changeSet id="0285_create_m_user_mfa_factor" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_mfa_factor"/></not></preConditions>
<createTable tableName="m_user_mfa_factor">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="factor_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="destination" type="VARCHAR(255)"/>
<column name="secret" type="VARCHAR(64)"/>
<column name="serial" type="VARCHAR(64)"/>
<column name="verified" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="verified_on" type="datetime"/>
<column name="otp_log_id" type="BIGINT"/>
<column name="created_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="disabled_on" type="datetime"/>
</createTable>
<addUniqueConstraint tableName="m_user_mfa_factor" columnNames="appuser_id, factor_type"
constraintName="uq_user_factor"/>
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="appuser_id"
constraintName="fk_mfa_factor_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="otp_log_id"
constraintName="fk_mfa_factor_otp"
referencedTableName="m_otp_log" referencedColumnNames="id"/>
<createIndex tableName="m_user_mfa_factor" indexName="idx_mfa_factor_user_active">
<column name="appuser_id"/>
<column name="disabled_on"/>
</createIndex>
</changeSet>
<!-- Permissions: list, register, disable factor (Self Service flow) -->
<changeSet id="0285_seed_user_mfa_permissions" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_USER_MFA_FACTORS'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="READ_USER_MFA_FACTORS"/>
<column name="entity_name" value="USER_MFA_FACTOR"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="CREATE_USER_MFA_FACTOR"/>
<column name="entity_name" value="USER_MFA_FACTOR"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="DELETE_USER_MFA_FACTOR"/>
<column name="entity_name" value="USER_MFA_FACTOR"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Grant the new permissions to the Self Service User role (id resolved by name to keep portable). -->
<changeSet id="0285_grant_mfa_permissions_to_self_service_role" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Self Service User'
AND p.code IN ('READ_USER_MFA_FACTORS', 'CREATE_USER_MFA_FACTOR', 'DELETE_USER_MFA_FACTOR')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,57 @@
<?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
-->
<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.1.xsd">
<!--
m_appuser ended up with both `mobile_number` (legacy from an earlier version of 0283) and
`mobile_no` (current). The Java code uses `mobile_no` exclusively. This migration:
1. Backfills `mobile_no` from `mobile_number` for any row where `mobile_no` is NULL
but `mobile_number` has a value, so we don't lose data.
2. Drops the now-unused `idx_m_appuser_mobile_number` index.
3. Drops the `mobile_number` column.
-->
<changeSet id="0286_backfill_mobile_no_from_mobile_number" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<columnExists tableName="m_appuser" columnName="mobile_number"/>
<columnExists tableName="m_appuser" columnName="mobile_no"/>
</and>
</preConditions>
<sql>
UPDATE m_appuser
SET mobile_no = mobile_number
WHERE mobile_no IS NULL
AND mobile_number IS NOT NULL
AND mobile_number &lt;&gt; ''
</sql>
</changeSet>
<changeSet id="0286_drop_index_idx_m_appuser_mobile_number" author="fineract">
<preConditions onFail="MARK_RAN">
<indexExists tableName="m_appuser" indexName="idx_m_appuser_mobile_number"/>
</preConditions>
<dropIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_number"/>
</changeSet>
<changeSet id="0286_drop_mobile_number_from_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="mobile_number"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="mobile_number"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,61 @@
<?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
-->
<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.1.xsd">
<!--
Global kill-switch for MFA-factor-backed login enforcement.
- DEFAULT: disabled. Existing users are unaffected on first deploy.
- When enabled, AuthenticationApiResource will challenge any user that
has at least one verified, non-disabled row in m_user_mfa_factor.
- Flip enabled=true in c_configuration to roll out; flip back to false
to roll back instantly without a redeploy.
-->
<changeSet id="0287_seed_mfa_factor_enforcement_config" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM c_configuration WHERE name = 'mfa-factor-enforcement'</sqlCheck>
</preConditions>
<insert tableName="c_configuration">
<column name="name" value="mfa-factor-enforcement"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description"
value="When enabled, users with at least one verified MFA factor in m_user_mfa_factor are required to complete a 2FA challenge after password authentication."/>
</insert>
</changeSet>
<!--
Pre-flight: grant BYPASS_TWO_FACTOR to the Super user role so an admin
can always log in even if MFA delivery is broken (LincPay outage, SMTP
misconfig, etc.). Idempotent — only inserts if missing.
-->
<changeSet id="0287_grant_bypass_twofactor_to_super_user" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Super user'
AND p.code = 'BYPASS_TWO_FACTOR'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,35 @@
<?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
-->
<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.1.xsd">
<!--
Audit symmetry with our other soft-delete/disable patterns: capture WHO disabled the
factor, not just WHEN. Useful when admins disable a user's factor (future support) or
for compliance trails — paired with the existing disabled_on column.
-->
<changeSet id="0288_add_disabled_by_to_m_user_mfa_factor" author="fineract">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_user_mfa_factor" columnName="disabled_by"/></not>
</preConditions>
<addColumn tableName="m_user_mfa_factor">
<column name="disabled_by" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="disabled_by"
constraintName="fk_mfa_factor_disabled_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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
-->
<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.1.xsd">
<!-- Admin-only read access to the m_otp_log audit trail. -->
<changeSet id="0289_seed_read_otp_log_permission" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="READ_OTP_LOG"/>
<column name="entity_name" value="OTP_LOG"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Grant to Super user so admins can immediately use the new endpoint. -->
<changeSet id="0289_grant_read_otp_log_to_super_user" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Super user'
AND p.code = 'READ_OTP_LOG'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?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
-->
<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.1.xsd">
<!--
Dedicated single-purpose role that grants READ access to the m_otp_log audit trail.
Sized small on purpose so admins can attach it to specific users (compliance auditors,
bank admins, support staff) without giving them the full Super user privileges.
The bankadmin assignment is environment-specific data — it's wrapped in a precondition so
the migration is a no-op on installs where that user doesn't exist.
-->
<!-- 1) Create the role -->
<changeSet id="0290_create_otp_log_auditor_role" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
</preConditions>
<insert tableName="m_role">
<column name="name" value="OTP Log Auditor"/>
<column name="description" value="Read-only access to the OTP audit trail (m_otp_log). Pair with users who need to inspect OTP send/verify history without having Super user rights."/>
<column name="is_disabled" valueBoolean="false"/>
</insert>
</changeSet>
<!-- 2) Grant READ_OTP_LOG to the role -->
<changeSet id="0290_grant_read_otp_log_to_otp_log_auditor" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
</and>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'OTP Log Auditor'
AND p.code = 'READ_OTP_LOG'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
<!-- 3) Assign role to 'bankadmin' if that user exists in this environment.
This block is intentionally environment-aware — fresh installs without a bankadmin
user will skip silently (preCondition onFail=MARK_RAN). -->
<changeSet id="0290_assign_otp_log_auditor_to_bankadmin" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin'</sqlCheck>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
</and>
</preConditions>
<sql>
INSERT INTO m_appuser_role (appuser_id, role_id)
SELECT u.id, r.id
FROM m_appuser u, m_role r
WHERE u.username = 'bankadmin'
AND r.name = 'OTP Log Auditor'
AND NOT EXISTS (
SELECT 1 FROM m_appuser_role ur
WHERE ur.appuser_id = u.id AND ur.role_id = r.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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
-->
<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.1.xsd">
<!--
Direct grant: attach READ_OTP_LOG to every role the 'bankadmin' user already holds.
Migration 0290 created a dedicated 'OTP Log Auditor' role and linked it to bankadmin,
which is the clean approach. This migration is a parallel, belt-and-braces grant that
also adds the permission to bankadmin's *existing* roles (Bank Admin, etc.) so the
OTP Audit Logs screen works without anyone toggling checkboxes in Manage Roles UI.
Idempotent via NOT EXISTS — safe to re-run. No-op on installs without a 'bankadmin' user.
-->
<changeSet id="0291_grant_read_otp_log_to_bankadmin_roles" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin'</sqlCheck>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
</and>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code = 'READ_OTP_LOG'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -39,6 +39,7 @@ import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.Set; import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.data.EnumOptionData; 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;
@@ -49,6 +50,7 @@ import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.useradministration.data.RoleData; import org.apache.fineract.useradministration.data.RoleData;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.Role; import org.apache.fineract.useradministration.domain.Role;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -79,6 +81,8 @@ public class AuthenticationApiResource {
private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializerService; private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializerService;
private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext; private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext;
private final ClientReadPlatformService clientReadPlatformService; private final ClientReadPlatformService clientReadPlatformService;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@POST @POST
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -132,7 +136,18 @@ public class AuthenticationApiResource {
final EnumOptionData organisationalRole = principal.organisationalRoleData(); final EnumOptionData organisationalRole = principal.organisationalRoleData();
boolean isTwoFactorRequired = this.twoFactorEnabled // 2FA is required when ALL of the following hold:
// 1) the framework is enabled at all (env: fineract.security.2fa.enabled=true)
// 2) the global kill-switch is on (c_configuration: mfa-factor-enforcement=true)
// 3) this specific user has at least one verified, non-disabled factor enrolled
// 4) the user does NOT have the BYPASS_TWO_FACTOR permission (break-glass for admins)
// If any of these is false, the user is allowed through without a challenge — preserves
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant
// rollback (flip the c_configuration row off and everyone is unchallenged).
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
final boolean userHasFactors = enforcementEnabled
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION); && !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
Long userId = principal.getId(); Long userId = principal.getId();
if (this.springSecurityPlatformSecurityContext.doesPasswordHasToBeRenewed(principal)) { if (this.springSecurityPlatformSecurityContext.doesPasswordHasToBeRenewed(principal)) {
@@ -28,11 +28,13 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants; import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken; import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken;
import org.apache.fineract.infrastructure.security.domain.TFAccessToken; import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
import org.apache.fineract.infrastructure.security.service.TwoFactorService; import org.apache.fineract.infrastructure.security.service.TwoFactorService;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
@@ -57,6 +59,8 @@ import org.springframework.web.filter.GenericFilterBean;
public class TwoFactorAuthenticationFilter extends GenericFilterBean { public class TwoFactorAuthenticationFilter extends GenericFilterBean {
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@Override @Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
@@ -78,24 +82,37 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
return; return;
} }
if (!user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION)) { // Mirror the same gating that AuthenticationApiResource uses to decide
// User can't bypass two-factor auth, check two-factor access // isTwoFactorAuthenticationRequired. If this user shouldn't be challenged, grant the
// token // TWOFACTOR_AUTHENTICATED authority silently so the SecurityConfig's downstream
// hasAuthority("TWOFACTOR_AUTHENTICATED") check passes. Without this the filter chain
// returns 403 on every protected endpoint when 2fa.enabled=true is on but the user
// has no enrolled factors (or the global kill-switch is off).
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
final boolean userHasFactors = enforcementOn
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
if (shouldChallenge) {
// User must present a valid TFA token
String token = request.getHeader("Fineract-Platform-TFA-Token"); String token = request.getHeader("Fineract-Platform-TFA-Token");
if (token != null) { if (token == null) {
TFAccessToken accessToken = twoFactorService.fetchAccessTokenForUser(user, token); // No token — let the request continue without TWOFACTOR_AUTHENTICATED.
// Token is non-existent or invalid // Downstream SecurityConfig will reject with 403, which signals the UI to
if (accessToken == null || !accessToken.isValid()) { // run the 2FA flow.
response.addHeader("WWW-Authenticate", "Basic realm=\"Fineract Platform API Two Factor\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid two-factor access token provided");
return;
}
} else {
// No token provided
chain.doFilter(req, res); chain.doFilter(req, res);
return; return;
} }
TFAccessToken accessToken = twoFactorService.fetchAccessTokenForUser(user, token);
if (accessToken == null || !accessToken.isValid()) {
response.addHeader("WWW-Authenticate", "Basic realm=\"Fineract Platform API Two Factor\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid two-factor access token provided");
return;
}
// Valid token → fall through and grant the authority
} }
// If !shouldChallenge OR a valid token was supplied: grant TWOFACTOR_AUTHENTICATED.
List<GrantedAuthority> updatedAuthorities = new ArrayList<>(authentication.getAuthorities()); List<GrantedAuthority> updatedAuthorities = new ArrayList<>(authentication.getAuthorities());
updatedAuthorities.add(new SimpleGrantedAuthority("TWOFACTOR_AUTHENTICATED")); updatedAuthorities.add(new SimpleGrantedAuthority("TWOFACTOR_AUTHENTICATED"));
@@ -0,0 +1,200 @@
{
"info": {
"name": "CBSnexora — Login OTP (Mobile + Email)",
"description": "Send and verify login OTPs via SMS (LincPay) and email. The `Send OTP` requests automatically capture the returned `otpId` into a collection variable so the matching `Verify OTP` request works in a single click.\n\n## Setup\n\n1. Open the collection's **Variables** tab and set:\n - `baseUrl` — e.g. `https://localhost:8443/fineract-provider/api/v1`\n - `tenantId` — e.g. `default`\n - `username` — the user whose registered mobile/email should receive the OTP\n2. Run **Send Mobile OTP** (or **Send Email OTP**), check the SMS/email for the 6-digit code, paste it into the matching **Verify** request's body, and run.\n\n## Endpoints\n\n- `POST /v1/login/otp/generate` — sends OTP via LincPay SMS\n- `POST /v1/login/otp/verify` — verifies the SMS OTP\n- `POST /v1/login/otp/email/generate` — sends OTP via SMTP email (CBSnexora-branded)\n- `POST /v1/login/otp/email/verify` — verifies the email OTP\n\nAll four endpoints are public (no auth header needed). Tenant header is still required.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Mobile OTP (LincPay SMS)",
"item": [
{
"name": "Send Mobile OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"// Capture otpId so the matching Verify request works without copy/paste",
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"if (json && json.otpId) {",
" pm.collectionVariables.set('mobileOtpId', json.otpId);",
" console.log('Saved mobileOtpId =', json.otpId);",
"}",
"pm.test('success flag is true', function () {",
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{username}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/generate",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "generate"]
},
"description": "Sends a 6-digit OTP via LincPay SMS to the user's registered mobile.\n\nMobile is fetched server-side from `m_appuser.mobile_no` based on the username — the UI does not need to pass it.\n\nResponse stores `otpId` and `otpReference` in `m_otp_log`. The `otpId` is auto-saved to the `mobileOtpId` collection variable for the verify step."
}
},
{
"name": "Verify Mobile OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"pm.test('verified flag is true', function () {",
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"otpId\": {{mobileOtpId}},\n \"otp\": \"123456\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/verify",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "verify"]
},
"description": "Verifies the SMS OTP delivered by LincPay. Replace `123456` with the OTP you received on your phone before sending.\n\nThe `otpId` is auto-populated from the previous `Send Mobile OTP` request via the `mobileOtpId` collection variable.\n\nVerification is delegated to LincPay's `/verify-mobile-otp` endpoint; the audit row in `m_otp_log` is updated with the result."
}
}
]
},
{
"name": "Email OTP (SMTP)",
"item": [
{
"name": "Send Email OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"if (json && json.otpId) {",
" pm.collectionVariables.set('emailOtpId', json.otpId);",
" console.log('Saved emailOtpId =', json.otpId);",
"}",
"pm.test('success flag is true', function () {",
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{username}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/email/generate",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "email", "generate"]
},
"description": "Generates a 6-digit OTP, stores its SHA-256 hash in `m_otp_log`, and emails the plaintext OTP to the user's registered email (`m_appuser.email`) using the CBSnexora-branded HTML template.\n\nThe `otpId` is auto-saved to the `emailOtpId` collection variable for the verify step.\n\n**Pre-flight**: ensure `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` env vars are populated. If SMTP is not configured, the response will be `{\"success\": false, \"message\": \"OTP send failed\"}` and the audit row marked `FAILED`."
}
},
{
"name": "Verify Email OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"pm.test('verified flag is true', function () {",
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"otpId\": {{emailOtpId}},\n \"otp\": \"123456\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/email/verify",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "email", "verify"]
},
"description": "Verifies the email OTP. Replace `123456` with the code from the email before sending.\n\nVerification is local: the submitted OTP is SHA-256 hashed and compared against the stored hash. On success the audit row is marked `VERIFIED`; failures increment `attempts` and lock at `max-attempts` (default 5)."
}
}
]
}
],
"variable": [
{
"key": "baseUrl",
"value": "https://localhost:8443/fineract-provider/api/v1",
"type": "string",
"description": "Fineract API base URL including the version segment (e.g. /api/v1)."
},
{
"key": "tenantId",
"value": "default",
"type": "string",
"description": "Tenant identifier sent in the `Fineract-Platform-TenantId` header."
},
{
"key": "username",
"value": "alice",
"type": "string",
"description": "Username of the AppUser whose registered mobile / email will receive the OTP."
},
{
"key": "mobileOtpId",
"value": "",
"type": "string",
"description": "Auto-populated from `Send Mobile OTP` response — used by the verify request."
},
{
"key": "emailOtpId",
"value": "",
"type": "string",
"description": "Auto-populated from `Send Email OTP` response — used by the verify request."
}
]
}