From 5e3212dc54373b4bfd3473d609cadcfad8397edb Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 10 May 2026 13:19:10 +0530 Subject: [PATCH] Add MFA backend: SMS/email OTP, factor management, OTP audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login 2FA via @Primary MfaFactorBackedTwoFactorServiceImpl (replaces legacy) - LincPay SMS OTP and SMTP email OTP with auto-promote to m_user_mfa_factor - Reusable EmailSenderService + EmailTemplateService (CBSnexora branding) - Admin OTP audit viewer (GET /v1/otp-logs) with paging, search, masking - Self-service MFA factor API at /v1/mfa/factors - READ_OTP_LOG permission + OTP Log Auditor role + bankadmin direct grant - JsonAuthenticationEntryPoint suppresses Basic-auth popup for SPA paths - TwoFactorAuthenticationFilter grants TWOFACTOR_AUTHENTICATED when no challenge - Soft-delete + audit columns + deletion audit + version on client tables - CIF number generator (TTYYYYNNNNNN format) - DocumentNotFoundExceptionMapper to silence noisy WARN logs on missing images - Liquibase migrations 0278-0291 (MFA, OTP log, audit/soft-delete, role wiring) Note: application.properties intentionally not committed — contains local DB and Gmail App Password fallbacks. Set SPRING_MAIL_PASSWORD, FINERACT_DEFAULT_TENANTDB_PWD, FINERACT_HIKARI_PASSWORD as env vars in prod. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/GlobalConfigurationConstants.java | 1 + .../domain/ConfigurationDomainService.java | 7 + .../client/domain/ClientIdentifier.java | 59 +++ .../portfolio/client/domain/ClientKyc.java | 59 +++ .../client/domain/ClientNominee.java | 24 +- .../domain/ClientNomineeRepository.java | 12 +- .../client/domain/ClientRepository.java | 7 + .../search/SearchingClientRepository.java | 2 +- .../search/SearchingClientRepositoryImpl.java | 8 +- .../domain/ClientEou.java | 7 + .../domain/ClientMsme.java | 7 + .../domain/ClientMudraBorrower.java | 7 + .../domain/ClientNonPrioritySector.java | 7 + .../domain/ClientPrioritySector.java | 7 + .../domain/ClientStartupIndia.java | 7 + .../useradministration/data/AppUserData.java | 26 +- .../useradministration/domain/AppUser.java | 25 +- .../mfa/domain/MfaFactorType.java | 38 ++ .../mfa/domain/UserMfaFactor.java | 91 +++++ .../mfa/domain/UserMfaFactorRepository.java | 39 ++ .../DocumentNotFoundExceptionMapper.java | 53 +++ .../service/ImageReadPlatformServiceImpl.java | 3 + .../domain/ConfigurationDomainServiceJpa.java | 5 + .../config/JsonAuthenticationEntryPoint.java | 49 +++ .../core/config/SecurityConfig.java | 36 +- .../email/service/EmailSenderService.java | 39 ++ .../email/service/EmailSenderServiceImpl.java | 89 ++++ .../email/service/EmailTemplateService.java | 39 ++ .../service/EmailTemplateServiceImpl.java | 155 +++++++ .../config/AuthorizationServerConfig.java | 6 +- .../otp/api/AdminOtpLogApiResource.java | 162 ++++++++ .../otp/api/EmailLoginOtpApiResource.java | 114 ++++++ .../security/otp/api/LoginOtpApiResource.java | 114 ++++++ .../otp/data/OtpGenerateResponse.java | 36 ++ .../security/otp/data/OtpLogData.java | 69 ++++ .../security/otp/data/OtpLogPage.java | 32 ++ .../otp/data/OtpLogSearchCriteria.java | 48 +++ .../security/otp/data/OtpVerifyResponse.java | 33 ++ .../security/otp/domain/OtpLog.java | 129 ++++++ .../security/otp/domain/OtpLogRepository.java | 30 ++ .../otp/service/EmailLoginOtpService.java | 37 ++ .../otp/service/EmailLoginOtpServiceImpl.java | 255 ++++++++++++ .../security/otp/service/LoginOtpService.java | 36 ++ .../otp/service/LoginOtpServiceImpl.java | 380 ++++++++++++++++++ .../service/OtpLogReadPlatformService.java | 32 ++ .../OtpLogReadPlatformServiceImpl.java | 359 +++++++++++++++++ .../MfaFactorBackedTwoFactorServiceImpl.java | 247 ++++++++++++ .../portfolio/address/domain/Address.java | 54 +++ .../AddressReadPlatformServiceImpl.java | 14 +- .../client/domain/ClientAddress.java | 66 +++ .../portfolio/client/domain/ClientCharge.java | 24 ++ .../client/domain/ClientChargePaidBy.java | 24 ++ .../client/domain/ClientFamilyMembers.java | 63 +++ .../client/domain/ClientNonPerson.java | 24 ++ .../client/service/CifNumberGenerator.java | 95 +++++ ...tFamilyMembersReadPlatformServiceImpl.java | 4 +- ...FamilyMembersWritePlatformServiceImpl.java | 9 +- ...ClientNomineeWritePlatformServiceImpl.java | 9 +- ...WritePlatformServiceJpaRepositoryImpl.java | 8 +- .../service/search/ClientSearchService.java | 3 +- .../search/domain/ClientTextSearch.java | 7 + .../api/UsersApiResourceSwagger.java | 4 + .../mfa/api/MfaFactorApiResource.java | 124 ++++++ .../mfa/data/UserMfaFactorData.java | 64 +++ .../UserMfaFactorNotFoundException.java | 28 ++ .../mfa/service/SelfUserMfaFactorService.java | 41 ++ .../service/SelfUserMfaFactorServiceImpl.java | 108 +++++ .../AppUserReadPlatformServiceImpl.java | 11 +- .../service/UserDataValidator.java | 9 +- .../db/changelog/tenant/changelog-tenant.xml | 14 + ...0278_add_soft_delete_to_family_members.xml | 43 ++ ..._add_soft_delete_columns_client_tables.xml | 237 +++++++++++ ...add_deletion_audit_and_version_columns.xml | 174 ++++++++ .../parts/0281_normalize_audit_columns.xml | 168 ++++++++ .../tenant/parts/0282_create_m_otp_log.xml | 93 +++++ .../parts/0283_add_mobile_to_m_appuser.xml | 31 ++ .../parts/0284_otp_log_support_email.xml | 40 ++ .../parts/0285_create_m_user_mfa_factor.xml | 111 +++++ ..._obsolete_mobile_number_from_m_appuser.xml | 57 +++ .../0287_mfa_factor_enforcement_config.xml | 61 +++ ...8_add_disabled_by_to_m_user_mfa_factor.xml | 35 ++ .../0289_seed_read_otp_log_permission.xml | 50 +++ .../0290_create_otp_log_auditor_role.xml | 83 ++++ ..._grant_read_otp_log_to_bankadmin_roles.xml | 50 +++ .../api/AuthenticationApiResource.java | 17 +- .../filter/TwoFactorAuthenticationFilter.java | 43 +- ...BSnexora-Login-OTP.postman_collection.json | 200 +++++++++ 87 files changed, 5262 insertions(+), 65 deletions(-) create mode 100644 fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactor.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactorRepository.java create mode 100644 fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/exceptionmapper/DocumentNotFoundExceptionMapper.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/JsonAuthenticationEntryPoint.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/AdminOtpLogApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpGenerateResponse.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogPage.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogSearchCriteria.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpVerifyResponse.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLog.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLogRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/MfaFactorBackedTwoFactorServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/api/MfaFactorApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/data/UserMfaFactorData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/exception/UserMfaFactorNotFoundException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0278_add_soft_delete_to_family_members.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0279_add_soft_delete_columns_client_tables.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0280_add_deletion_audit_and_version_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0281_normalize_audit_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0282_create_m_otp_log.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0283_add_mobile_to_m_appuser.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0284_otp_log_support_email.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0285_create_m_user_mfa_factor.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0287_mfa_factor_enforcement_config.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0288_add_disabled_by_to_m_user_mfa_factor.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0289_seed_read_otp_log_permission.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0290_create_otp_log_auditor_role.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0291_grant_read_otp_log_to_bankadmin_roles.xml create mode 100644 postman/CBSnexora-Login-OTP.postman_collection.json diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java index a9336214d..d62a646e1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/api/GlobalConfigurationConstants.java @@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.configuration.api; public final class GlobalConfigurationConstants { public static final String MAKER_CHECKER = "maker-checker"; + public static final String MFA_FACTOR_ENFORCEMENT = "mfa-factor-enforcement"; public static final String AMAZON_S3 = "amazon-s3"; public static final String RESCHEDULE_FUTURE_REPAYMENTS = "reschedule-future-repayments"; public static final String RESCHEDULE_REPAYMENTS_ON_HOLIDAYS = "reschedule-repayments-on-holidays"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java index b2a897b46..18a910266 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainService.java @@ -26,6 +26,13 @@ public interface ConfigurationDomainService { boolean isMakerCheckerEnabledForTask(String taskPermissionCode); + /** + * Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least + * one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge + * after password authentication. Default: false (off, safe rollback). + */ + boolean isMfaFactorEnforcementEnabled(); + List getAllowedLoanStatusesForExternalAssetTransfer(); List getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientIdentifier.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientIdentifier.java index a42810a8b..73d51c143 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientIdentifier.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientIdentifier.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; @@ -35,6 +37,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa @Table(name = "m_client_identifier", uniqueConstraints = { @UniqueConstraint(columnNames = { "document_type_id", "document_key" }, name = "unique_identifier_key"), @UniqueConstraint(columnNames = { "client_id", "document_key", "active" }, name = "unique_active_client_identifier") }) +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom { @ManyToOne @@ -57,6 +64,22 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom { @ManyToOne @@ -777,4 +784,56 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public String getForm15Fy() { return form15Fy; } + + @Column(name = "is_active", nullable = false) + private Boolean isActive = Boolean.TRUE; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = Boolean.FALSE; + + @Column(name = "deleted_by") + private Long deletedBy; + + @Column(name = "deleted_at") + private java.time.OffsetDateTime deletedAt; + + @jakarta.persistence.Version + @Column(name = "version", nullable = false) + private Long version = 0L; + + public Boolean getIsActive() { + return this.isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public Boolean getIsDeleted() { + return this.isDeleted; + } + + public void setIsDeleted(Boolean isDeleted) { + this.isDeleted = isDeleted; + } + + public Long getDeletedBy() { + return this.deletedBy; + } + + public void setDeletedBy(Long deletedBy) { + this.deletedBy = deletedBy; + } + + public java.time.OffsetDateTime getDeletedAt() { + return this.deletedAt; + } + + public void setDeletedAt(java.time.OffsetDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public Long getVersion() { + return this.version; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNominee.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNominee.java index 6d6b99ef4..efe9ddddc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNominee.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNominee.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -39,6 +41,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa */ @Entity @Table(name = "m_client_nominee") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor @@ -60,8 +67,21 @@ public class ClientNominee extends AbstractAuditableWithUTCDateTimeCustom @Column(name = "percentage_share", precision = 5, scale = 2) private BigDecimal percentageShare; - @Column(name = "is_active") - private Boolean isActive; + @Column(name = "is_active", nullable = false) + private Boolean isActive = Boolean.TRUE; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = Boolean.FALSE; + + @Column(name = "deleted_by") + private Long deletedBy; + + @Column(name = "deleted_at") + private java.time.OffsetDateTime deletedAt; + + @jakarta.persistence.Version + @Column(name = "version", nullable = false) + private Long version = 0L; /** If nominee is a minor, guardian details */ @Column(name = "nominee_is_minor") diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNomineeRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNomineeRepository.java index 91b9181b5..c5825f5d3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNomineeRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNomineeRepository.java @@ -26,21 +26,21 @@ import org.springframework.data.repository.query.Param; public interface ClientNomineeRepository extends JpaRepository { - @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 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 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 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 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 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); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java index 055789653..ae6ee9af7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java @@ -54,4 +54,11 @@ public interface ClientRepository extends JpaRepository, JpaSpecif @Modifying @Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId") void removeImageId(@Param("clientId") Long clientId); + + /** + * Count clients whose cif_number begins with the given prefix. Used by the CIF number generator + * (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket. + */ + @Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')") + long countByCifNumberPrefix(@Param("prefix") String prefix); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java index e1827da1b..88f338e7b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepository.java @@ -23,5 +23,5 @@ import org.springframework.data.domain.Pageable; public interface SearchingClientRepository { - Page searchByText(String searchText, Pageable pageable, String officeHierarchy); + Page searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java index 056bacc25..7db83891c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/search/SearchingClientRepositoryImpl.java @@ -36,6 +36,7 @@ import org.apache.fineract.infrastructure.core.jpa.CriteriaQueryFactory; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.ClientIdentifier; +import org.apache.fineract.portfolio.client.domain.ClientStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -51,7 +52,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository private final CriteriaQueryFactory criteriaQueryFactory; @Override - public Page searchByText(String searchText, Pageable pageable, String officeHierarchy) { + public Page searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly) { /* * this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at * this point the upgrade is too costly @@ -79,6 +80,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository cb.like(r.get("displayName"), searchLikeValue), cb.like(r.get("externalId"), searchLikeValue), cb.like(r.get("mobileNo"), searchLikeValue), cb.like(identity.get("documentKey"), searchLikeValue))); + // Linking flows (nominee, family-member, guardian, signatory) only see ACTIVE clients + if (activeOnly) { + predicates.add(cb.equal(r.get("status"), ClientStatus.ACTIVE.getValue())); + } + return cb.and(predicates.toArray(new Predicate[0])); }; criteriaQueryFactory.applySpecificationToCriteria(root, spec, query); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientEou.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientEou.java index f002ae4fb..ff2ec98e6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientEou.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientEou.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client; */ @Entity @Table(name = "m_client_eou") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMsme.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMsme.java index a5b4fbeee..54290f9a3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMsme.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMsme.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -34,6 +36,11 @@ import org.apache.fineract.portfolio.client.domain.Client; @Entity @Table(name = "m_client_msme") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMudraBorrower.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMudraBorrower.java index a9044d041..12c25f0cd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMudraBorrower.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientMudraBorrower.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client; */ @Entity @Table(name = "m_client_mudra_borrower") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientNonPrioritySector.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientNonPrioritySector.java index ed3b86044..e3de0c3b0 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientNonPrioritySector.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientNonPrioritySector.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -36,6 +38,11 @@ import org.apache.fineract.portfolio.client.domain.Client; */ @Entity @Table(name = "m_client_non_priority_sector") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientPrioritySector.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientPrioritySector.java index cff744a78..1a4911736 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientPrioritySector.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientPrioritySector.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client; */ @Entity @Table(name = "m_client_priority_sector") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientStartupIndia.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientStartupIndia.java index f28b7cc7a..8c466241d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientStartupIndia.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/entityclassification/domain/ClientStartupIndia.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.client.entityclassification.domain; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -38,6 +40,11 @@ import org.apache.fineract.portfolio.client.domain.Client; */ @Entity @Table(name = "m_client_startup_india") +@AttributeOverrides({ + @AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)), + @AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)), + @AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false)) +}) @Getter @Setter @NoArgsConstructor diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/data/AppUserData.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/data/AppUserData.java index ed5d6cec1..ca9d0f7fe 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/data/AppUserData.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/data/AppUserData.java @@ -40,6 +40,7 @@ public final class AppUserData { private final String firstname; private final String lastname; private final String email; + private final String mobileNo; private final Boolean passwordNeverExpires; // import fields @@ -76,6 +77,7 @@ public final class AppUserData { this.firstname = firstname; this.lastname = lastname; this.email = email; + this.mobileNo = null; this.passwordNeverExpires = passwordNeverExpires; this.roles = roleIds; this.sendPasswordToEmail = sendPasswordToEmail; @@ -91,30 +93,31 @@ public final class AppUserData { } public static AppUserData template(final AppUserData user, final Collection officesForDropdown) { - return new AppUserData(user.id, user.username, user.email, user.officeId, user.officeName, user.firstname, user.lastname, - user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff, user.passwordNeverExpires, - user.isSelfServiceUser); + return new AppUserData(user.id, user.username, user.email, user.mobileNo, user.officeId, user.officeName, user.firstname, + user.lastname, user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff, + user.passwordNeverExpires, user.isSelfServiceUser); } public static AppUserData template(final Collection offices, final Collection availableRoles, final Collection selfServiceRoles) { - return new AppUserData(null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null, null); + return new AppUserData(null, null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null, + null); } public static AppUserData dropdown(final Long id, final String username) { - return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null); + return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null, null); } - public static AppUserData instance(final Long id, final String username, final String email, final Long officeId, + public static AppUserData instance(final Long id, final String username, final String email, final String mobileNo, final Long officeId, final String officeName, final String firstname, final String lastname, final Collection availableRoles, final Collection selfServiceRoles, final Collection selectedRoles, final StaffData staff, final Boolean passwordNeverExpire, final Boolean isSelfServiceUser) { - return new AppUserData(id, username, email, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles, + return new AppUserData(id, username, email, mobileNo, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles, selectedRoles, null, staff, passwordNeverExpire, isSelfServiceUser); } - private AppUserData(final Long id, final String username, final String email, final Long officeId, final String officeName, - final String firstname, final String lastname, final Collection availableRoles, + private AppUserData(final Long id, final String username, final String email, final String mobileNo, final Long officeId, + final String officeName, final String firstname, final String lastname, final Collection availableRoles, final Collection selfServiceRoles, final Collection selectedRoles, final Collection allowedOffices, final StaffData staff, final Boolean passwordNeverExpire, final Boolean isSelfServiceUser) { @@ -125,6 +128,7 @@ public final class AppUserData { this.firstname = firstname; this.lastname = lastname; this.email = email; + this.mobileNo = mobileNo; this.allowedOffices = allowedOffices; this.availableRoles = availableRoles; this.selfServiceRoles = selfServiceRoles; @@ -134,6 +138,10 @@ public final class AppUserData { this.isSelfServiceUser = isSelfServiceUser; } + public String getMobileNo() { + return this.mobileNo; + } + public boolean hasIdentifyOf(final Long createdById) { return this.id.equals(createdById); } diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java index 5d7ab2305..70ab26537 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/domain/AppUser.java @@ -134,6 +134,17 @@ public class AppUser extends AbstractPersistableCustom implements Platform @Column(name = "password_reset_required", nullable = false) private boolean passwordResetRequired; + @Column(name = "mobile_no", length = 16) + private String mobileNo; + + public String getMobileNo() { + return this.mobileNo; + } + + public void setMobileNo(final String mobileNo) { + this.mobileNo = mobileNo; + } + public boolean isPasswordResetRequired() { return this.passwordResetRequired; } @@ -174,11 +185,14 @@ public class AppUser extends AbstractPersistableCustom implements Platform final String email = command.stringValueOfParameterNamed("email"); final String firstname = command.stringValueOfParameterNamed("firstname"); final String lastname = command.stringValueOfParameterNamed("lastname"); + final String mobileNo = command.stringValueOfParameterNamed("mobileNo"); final boolean isSelfServiceUser = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_SELF_SERVICE_USER); - return new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, isSelfServiceUser, - clients, cannotChangePassword); + final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, + isSelfServiceUser, clients, cannotChangePassword); + appUser.setMobileNo(mobileNo); + return appUser; } protected AppUser() { @@ -321,6 +335,13 @@ public class AppUser extends AbstractPersistableCustom implements Platform this.email = newValue; } + final String mobileNoParamName = "mobileNo"; + if (command.isChangeInStringParameterNamed(mobileNoParamName, this.mobileNo)) { + final String newValue = command.stringValueOfParameterNamed(mobileNoParamName); + actualChanges.put(mobileNoParamName, newValue); + this.mobileNo = newValue; + } + final String passwordNeverExpire = "passwordNeverExpires"; if (command.hasParameter(passwordNeverExpire) diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java new file mode 100644 index 000000000..d41dfa665 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java @@ -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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactor.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactor.java new file mode 100644 index 000000000..e149512c4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactor.java @@ -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 { + + @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); + } + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactorRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactorRepository.java new file mode 100644 index 000000000..3629a70c9 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/UserMfaFactorRepository.java @@ -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 { + + List findByAppUser_IdAndDisabledOnIsNull(Long userId); + + /** Returns every factor for the user — active and disabled — newest first. */ + List findByAppUser_IdOrderByIdDesc(Long userId); + + Optional 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); +} diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/exceptionmapper/DocumentNotFoundExceptionMapper.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/exceptionmapper/DocumentNotFoundExceptionMapper.java new file mode 100644 index 000000000..07b88cc64 --- /dev/null +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/exceptionmapper/DocumentNotFoundExceptionMapper.java @@ -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 { + + @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; + } +} diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java index 056a11122..6e28b5f3f 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java @@ -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.ContentDetectorManager; 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.data.DocumentContent; import org.apache.fineract.infrastructure.documentmanagement.domain.ImageRepository; @@ -62,6 +63,8 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService { .getMimeType()) .stream(storeService.download(image.getLocation())).build())) .orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, -1L)); + } catch (final AbstractPlatformDomainRuleException e) { + throw e; } catch (final Exception e) { throw new DocumentInvalidRequestException(e); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java index e43ee3396..9056094c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/configuration/domain/ConfigurationDomainServiceJpa.java @@ -80,6 +80,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.ENABLE_SAME_MAKER_CHECKER).isEnabled(); } + @Override + public boolean isMfaFactorEnforcementEnabled() { + return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MFA_FACTOR_ENFORCEMENT).isEnabled(); + } + @Override public boolean isAmazonS3Enabled() { return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.AMAZON_S3).isEnabled(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/JsonAuthenticationEntryPoint.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/JsonAuthenticationEntryPoint.java new file mode 100644 index 000000000..c461872bf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/JsonAuthenticationEntryPoint.java @@ -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 + "\"}"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 2aa14cb57..8381962a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -25,6 +25,7 @@ import static org.springframework.security.authorization.AuthorityAuthorizationM import static org.springframework.security.authorization.AuthorizationManagers.allOf; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService; 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.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; 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.util.matcher.RequestMatcher; import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.web.cors.CorsConfiguration; @@ -142,6 +146,12 @@ public class SecurityConfig { 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/*/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() // businessdate .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*")) @@ -388,7 +398,11 @@ public class SecurityConfig { public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() { 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() { @@ -417,10 +431,22 @@ public class SecurityConfig { } @Bean - public BasicAuthenticationEntryPoint basicAuthenticationEntryPoint() { - BasicAuthenticationEntryPoint basicAuthenticationEntryPoint = new BasicAuthenticationEntryPoint(); - basicAuthenticationEntryPoint.setRealmName("Fineract Platform API"); - return basicAuthenticationEntryPoint; + public AuthenticationEntryPoint basicAuthenticationEntryPoint() { + // The default Basic-auth entry point is kept for the rest of the API — it emits + // `WWW-Authenticate: Basic ...` which is useful for tooling and programmatic clients. + 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 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") diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderService.java new file mode 100644 index 000000000..8e4298c6e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderServiceImpl.java new file mode 100644 index 000000000..90a4f0362 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailSenderServiceImpl.java @@ -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; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateService.java new file mode 100644 index 000000000..13fe5f479 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java new file mode 100644 index 000000000..cacf4b0ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/email/service/EmailTemplateServiceImpl.java @@ -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 = "" + + "

" + greeting + "

" + + "

Use the one-time password (OTP) below to complete your sign-in. " + + "It is valid for " + validityMinutes + " minutes.

" + + "
" + + " " + + escape(otp) + "" + + "
" + + "

If you did not request this code, please ignore this email or " + + "contact support immediately.

"; + 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 = "" + + "

" + greeting(recipientName) + "

" + + "

Welcome to " + escape(brandName) + ". Your account has been created.

" + + "

Username: " + escape(username) + "

" + + "" + + "

If you did not expect this email, " + + "contact support.

"; + 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 = "" + + "

" + greeting(recipientName) + "

" + + "

We received a request to reset your password. The link below is valid for " + + "" + validityMinutes + " minutes.

" + + "" + + "

If the button does not work, paste this URL into your browser:
" + + "" + escape(safeLink) + "

" + + "

If you did not request a password reset, you can safely ignore this email.

"; + return wrap("Reset your " + brandName + " password", body); + } + + @Override + public String renderAlert(final String recipientName, final String alertTitle, final String alertMessage) { + final String body = "" + + "

" + greeting(recipientName) + "

" + + "
" + + "

" + escape(alertTitle) + "

" + + "

" + escape(alertMessage) + "

" + + "
" + + "

If this does not look right, please " + + "contact support.

"; + return wrap(alertTitle + " — " + brandName, body); + } + + // ── Shared shell ───────────────────────────────────────────────────────── + + private String wrap(final String previewTitle, final String innerHtml) { + return "" + + "" + + "" + + "" + + "" + + "" + escape(previewTitle) + "" + + "" + + "" + + "
" + escape(previewTitle) + "
" + + " " + + " " + + "
" + + " " + // Header / brand + + " " + // Body + + " " + // Footer + + " " + + "
" + + " " + + " " + + " " + + "
" + + escape(brandName) + "Core Banking
" + + "
" + + innerHtml + + "
" + + "

" + + " Need help? Reach our team at " + + " " + + escape(supportEmail) + "" + + "

" + + "

" + + " © " + Year.now().getValue() + " " + escape(brandName) + ". All rights reserved." + + "

" + + "
" + + "
" + + ""; + } + + 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java index ce70a02cd..dd0b93d29 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java @@ -287,7 +287,11 @@ public class AuthorizationServerConfig { public TwoFactorAuthenticationFilter twoFactorAuthenticationFilter() { 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() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/AdminOtpLogApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/AdminOtpLogApiResource.java new file mode 100644 index 000000000..814981aff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/AdminOtpLogApiResource.java @@ -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. + * + *
    + *
  • GET /v1/otp-logs — paged search across OTP rows joined with m_appuser; destination is + * masked server-side, channel is normalised (SMS/EMAIL/TOTP)
  • + *
  • GET /v1/otp-logs/{id} — single row, optionally with raw request/response payloads
  • + *
+ * + *

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; + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java new file mode 100644 index 000000000..a98b1f057 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/EmailLoginOtpApiResource.java @@ -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. + * + *

POST /v1/login/otp/email/generate body: { "username": "alice" } — email looked up from m_appuser + *

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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java new file mode 100644 index 000000000..a7f1e78d6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/api/LoginOtpApiResource.java @@ -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). + * + *

POST /v1/login/otp/generate body: { "username": "alice" } ← mobile is looked up from m_appuser + *

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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpGenerateResponse.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpGenerateResponse.java new file mode 100644 index 000000000..ae6f4ed0d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpGenerateResponse.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogData.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogData.java new file mode 100644 index 000000000..bc999bf55 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogData.java @@ -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. + * + *

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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogPage.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogPage.java new file mode 100644 index 000000000..0c4c32224 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogPage.java @@ -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 pageItems; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogSearchCriteria.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogSearchCriteria.java new file mode 100644 index 000000000..a469792ec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpLogSearchCriteria.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpVerifyResponse.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpVerifyResponse.java new file mode 100644 index 000000000..4c61d86f2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/data/OtpVerifyResponse.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLog.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLog.java new file mode 100644 index 000000000..19caa3d5d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLog.java @@ -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. + * + *

Status transitions: PENDING → SENT (provider accepted) → VERIFIED / EXPIRED / FAILED. + * + *

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 { + + @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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLogRepository.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLogRepository.java new file mode 100644 index 000000000..e30c6ada6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/domain/OtpLogRepository.java @@ -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 { + + Optional findFirstByIdAndIsDeletedFalseOrderByIdDesc(Long id); + + List findByMobileNumberAndPurposeAndIsDeletedFalseOrderByIdDesc(String mobileNumber, String purpose); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpService.java new file mode 100644 index 000000000..e2edd36b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java new file mode 100644 index 000000000..f9b3eee1d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpService.java new file mode 100644 index 000000000..036fa7b02 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java new file mode 100644 index 000000000..754cee034 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java @@ -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 entity = new HttpEntity<>(requestBodyJson, headers); + final ResponseEntity 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformService.java new file mode 100644 index 000000000..a78978a7a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformServiceImpl.java new file mode 100644 index 000000000..7dfbd70ee --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/OtpLogReadPlatformServiceImpl.java @@ -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 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 dataParams = new ArrayList<>(params); + dataParams.add(criteria.getLimit() <= 0 ? 25 : criteria.getLimit()); + dataParams.add(Math.max(0, criteria.getOffset())); + + final List 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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/MfaFactorBackedTwoFactorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/MfaFactorBackedTwoFactorServiceImpl.java new file mode 100644 index 000000000..ad9f49b0d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/MfaFactorBackedTwoFactorServiceImpl.java @@ -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. + * + *

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 getDeliveryMethodsForUser(final AppUser user) { + final List 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 + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/domain/Address.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/domain/Address.java index 3d325cea7..f5e5a5149 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/domain/Address.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/domain/Address.java @@ -107,6 +107,24 @@ public class Address extends AbstractPersistableCustom { @Column(name = "updated_on") 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, 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, @@ -403,4 +421,40 @@ public class Address extends AbstractPersistableCustom { 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; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressReadPlatformServiceImpl.java index 61829f500..eb655c574 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/address/service/AddressReadPlatformServiceImpl.java @@ -175,7 +175,7 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic this.context.authenticatedUser(); 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 } @@ -184,7 +184,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic public List retrieveAllClientAddress(final long clientid) { this.context.authenticatedUser(); 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 } @@ -193,7 +194,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic this.context.authenticatedUser(); 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 } @@ -204,7 +206,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic boolean temp = Boolean.parseBoolean(status); 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 } @@ -215,7 +218,8 @@ public class AddressReadPlatformServiceImpl implements AddressReadPlatformServic boolean temp = Boolean.parseBoolean(status); 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 } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientAddress.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientAddress.java index ceb905150..7577386ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientAddress.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientAddress.java @@ -20,15 +20,23 @@ package org.apache.fineract.portfolio.client.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.time.LocalDateTime; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; 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 @Table(name = "m_client_address") +@EntityListeners(AuditingEntityListener.class) public class ClientAddress extends AbstractPersistableCustom { @ManyToOne @@ -44,11 +52,41 @@ public class ClientAddress extends AbstractPersistableCustom { @Column(name = "is_active") 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) { this.client = client; this.address = address; this.addressType = addressType; this.isActive = isActive; + this.isDeleted = false; } @@ -93,4 +131,32 @@ public class ClientAddress extends AbstractPersistableCustom { 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; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java index 237e094fc..0cf7e10d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientCharge.java @@ -20,12 +20,14 @@ package org.apache.fineract.portfolio.client.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Transient; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; 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.ChargeTimeType; 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 @Table(name = "m_client_charge") +@EntityListeners(AuditingEntityListener.class) public class ClientCharge extends AbstractPersistableCustom { @ManyToOne(optional = false) @@ -90,6 +98,22 @@ public class ClientCharge extends AbstractPersistableCustom { @Transient 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() { // } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientChargePaidBy.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientChargePaidBy.java index 435d37305..1ec9625e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientChargePaidBy.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientChargePaidBy.java @@ -20,14 +20,22 @@ package org.apache.fineract.portfolio.client.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.math.BigDecimal; +import java.time.LocalDateTime; 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 @Table(name = "m_client_charge_paid_by") +@EntityListeners(AuditingEntityListener.class) public class ClientChargePaidBy extends AbstractPersistableCustom { @ManyToOne @@ -41,6 +49,22 @@ public class ClientChargePaidBy extends AbstractPersistableCustom { @Column(name = "amount", scale = 6, precision = 19, nullable = false) 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() { } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java index 2f779cbae..a363d627b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java @@ -21,16 +21,26 @@ package org.apache.fineract.portfolio.client.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.math.BigDecimal; 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.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 @Table(name = "m_family_members") +@EntityListeners(AuditingEntityListener.class) public class ClientFamilyMembers extends AbstractPersistableCustom { @ManyToOne @@ -109,6 +119,38 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { @Column(name = "marriage_authority", length = 50) 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, 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, @@ -131,6 +173,8 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { this.percentageShare = percentageShare; this.remarks = remarks; this.isAlive = true; + this.isActive = Boolean.TRUE; + this.isDeleted = Boolean.FALSE; } public ClientFamilyMembers() { @@ -293,4 +337,23 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { public String getMarriageAuthority() { return 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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java index e2f293277..f55518622 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java @@ -20,12 +20,14 @@ package org.apache.fineract.portfolio.client.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; 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.service.DateUtils; 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 @Table(name = "m_client_non_person") +@EntityListeners(AuditingEntityListener.class) public class ClientNonPerson extends AbstractPersistableCustom { @OneToOne(optional = false) @@ -73,6 +81,22 @@ public class ClientNonPerson extends AbstractPersistableCustom { @Column(name = "date_of_registration", nullable = true) 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, String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo, LocalDate dateOfRegistration) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java new file mode 100644 index 000000000..b54290143 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java @@ -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 TTYYYYNNNNNN: + *

    + *
  • TT — Customer type code: + * 11 Individual, 22 Corporate, 33 Minor, 44 Staff. + * Priority order when multiple flags overlap: Staff > Minor > Corporate > Individual.
  • + *
  • YYYY — Year the client was submitted (4 digits).
  • + *
  • NNNNNN — Zero-padded 6-digit sequence within the (type, year) bucket.
  • + *
+ * + * Example: an Individual client created in 2026 → 1120260000001. + * 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java index b236231f8..7c0c4ef96 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java @@ -118,7 +118,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM this.context.authenticatedUser(); 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 } @@ -129,7 +129,7 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM this.context.authenticatedUser(); 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 } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index 44e34f86a..08d3e3819 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -454,12 +454,17 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily @Override public CommandProcessingResult deleteFamilyMember(Long clientFamilyMemberId, JsonCommand command) { - this.context.authenticatedUser(); + final org.apache.fineract.useradministration.domain.AppUser currentUser = this.context.authenticatedUser(); apiJsonDeserializer.validateForDelete(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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java index 44a43110d..6ea490f34 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientNomineeWritePlatformServiceImpl.java @@ -204,11 +204,16 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite @Override @Transactional 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) .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() .withClientId(clientId) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index e19ddc229..77be02962 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -127,6 +127,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP private final ClientKycWritePlatformService clientKycWritePlatformService; private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository; private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository; + private final CifNumberGenerator cifNumberGenerator; @Transactional @Override @@ -466,10 +467,11 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP 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)) { - final String generatedCif = "CIF" + StringUtils.leftPad(newClient.getId().toString(), 10, '0'); - newClient.updateCifNumber(generatedCif); + newClient.updateCifNumber(cifNumberGenerator.generate(newClient)); this.clientRepository.saveAndFlush(newClient); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java index 2535bb3c5..d9fb75494 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/ClientSearchService.java @@ -58,9 +58,10 @@ public class ClientSearchService { Optional request = searchRequest.getRequest(); String requestSearchText = request.map(ClientTextSearch::getText).orElse(null); String searchText = Objects.toString(requestSearchText, ""); + boolean activeOnly = request.map(ClientTextSearch::getActiveOnly).filter(Boolean::booleanValue).isPresent(); Pageable pageable = searchRequest.toPageable(); - return clientRepository.searchByText(searchText, pageable, hierarchy).map(clientSearchDataMapper::map); + return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly).map(clientSearchDataMapper::map); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java index f6523aaff..905652323 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/search/domain/ClientTextSearch.java @@ -24,4 +24,11 @@ import lombok.Data; public class ClientTextSearch { 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java index c3181a86d..03f7428d4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java @@ -122,6 +122,8 @@ final class UsersApiResourceSwagger { public String lastname; @Schema(example = "whatever@mifos.org") public String email; + @Schema(example = "9999999999") + public String mobileNo; @Schema(example = "1") public Long officeId; @Schema(example = "1") @@ -201,6 +203,8 @@ final class UsersApiResourceSwagger { public String lastname; @Schema(example = "whatever@mifos.org") public String email; + @Schema(example = "9999999999") + public String mobileNo; @Schema(example = "1") public Long officeId; @Schema(example = "1") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/api/MfaFactorApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/api/MfaFactorApiResource.java new file mode 100644 index 000000000..12df56ee0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/api/MfaFactorApiResource.java @@ -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. + * + *

Authentication is the platform's standard user filter; the principal is resolved via + * {@code PlatformSecurityContext.authenticatedUser()} inside the service. + * + *

    + *
  • GET /mfa/factors — list active factors for the current user
  • + *
  • POST /mfa/factors — register / upsert a verified factor
  • + *
  • DELETE /mfa/factors/{id} — soft-disable a factor (sets disabled_on)
  • + *
+ */ +@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 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 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; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/data/UserMfaFactorData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/data/UserMfaFactorData.java new file mode 100644 index 000000000..979f646e2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/data/UserMfaFactorData.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/exception/UserMfaFactorNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/exception/UserMfaFactorNotFoundException.java new file mode 100644 index 000000000..3ec1a8d21 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/exception/UserMfaFactorNotFoundException.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorService.java new file mode 100644 index 000000000..3471549b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorService.java @@ -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 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 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java new file mode 100644 index 000000000..194a63f6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java @@ -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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserReadPlatformServiceImpl.java index b621d8f32..57ee33b45 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserReadPlatformServiceImpl.java @@ -124,9 +124,9 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic linkedStaff = null; } - AppUserData retUser = AppUserData.instance(user.getId(), user.getUsername(), user.getEmail(), user.getOffice().getId(), - user.getOffice().getName(), user.getFirstname(), user.getLastname(), availableRoles, null, selectedUserRoles, linkedStaff, - user.getPasswordNeverExpires(), user.isSelfServiceUser()); + AppUserData retUser = AppUserData.instance(user.getId(), user.getUsername(), user.getEmail(), user.getMobileNo(), + user.getOffice().getId(), user.getOffice().getName(), user.getFirstname(), user.getLastname(), availableRoles, null, + selectedUserRoles, linkedStaff, user.getPasswordNeverExpires(), user.isSelfServiceUser()); if (retUser.isSelfServiceUser()) { Set clients = new HashSet<>(); @@ -159,6 +159,7 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic final String firstname = rs.getString("firstname"); final String lastname = rs.getString("lastname"); final String email = rs.getString("email"); + final String mobileNo = rs.getString("mobileNo"); final Long officeId = JdbcSupport.getLong(rs, "officeId"); final String officeName = rs.getString("officeName"); final Long staffId = JdbcSupport.getLong(rs, "staffId"); @@ -172,12 +173,12 @@ public class AppUserReadPlatformServiceImpl implements AppUserReadPlatformServic } else { 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); } 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 " + " join m_office o on o.id = u.office_id where o.hierarchy like ? and u.is_deleted=false order by u.username"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java index 932341fd0..2f87652de 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java @@ -52,6 +52,7 @@ public final class UserDataValidator { public static final String FIRSTNAME = "firstname"; public static final String LASTNAME = "lastname"; public static final String EMAIL = "email"; + public static final String MOBILE_NO = "mobileNo"; public static final String OFFICE_ID = "officeId"; public static final String NOT_SELECTED_ROLES = "notSelectedRoles"; public static final String ROLES = "roles"; @@ -62,11 +63,11 @@ public final class UserDataValidator { * The parameters supported for this command. */ private static final Set CREATE_SUPPORTED_PARAMETERS = new HashSet<>( - Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, - SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES, + ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); private static final Set UPDATE_SUPPORTED_PARAMETERS = new HashSet<>( - Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES, - SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); + Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES, + ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS)); private static final Set CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD)); public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire"; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 900a5f42b..a64c193ec 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -289,4 +289,18 @@ + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0278_add_soft_delete_to_family_members.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0278_add_soft_delete_to_family_members.xml new file mode 100644 index 000000000..d45cdc61d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0278_add_soft_delete_to_family_members.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0279_add_soft_delete_columns_client_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0279_add_soft_delete_columns_client_tables.xml new file mode 100644 index 000000000..923c4fd5c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0279_add_soft_delete_columns_client_tables.xml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0280_add_deletion_audit_and_version_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0280_add_deletion_audit_and_version_columns.xml new file mode 100644 index 000000000..c9dacfd10 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0280_add_deletion_audit_and_version_columns.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0281_normalize_audit_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0281_normalize_audit_columns.xml new file mode 100644 index 000000000..c85f29941 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0281_normalize_audit_columns.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0282_create_m_otp_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0282_create_m_otp_log.xml new file mode 100644 index 000000000..20df70ff2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0282_create_m_otp_log.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0283_add_mobile_to_m_appuser.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0283_add_mobile_to_m_appuser.xml new file mode 100644 index 000000000..42ea3b7eb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0283_add_mobile_to_m_appuser.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0284_otp_log_support_email.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0284_otp_log_support_email.xml new file mode 100644 index 000000000..9c760dee6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0284_otp_log_support_email.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0285_create_m_user_mfa_factor.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0285_create_m_user_mfa_factor.xml new file mode 100644 index 000000000..65798769e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0285_create_m_user_mfa_factor.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_USER_MFA_FACTORS' + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User' + + + 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 + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml new file mode 100644 index 000000000..6f759da48 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + UPDATE m_appuser + SET mobile_no = mobile_number + WHERE mobile_no IS NULL + AND mobile_number IS NOT NULL + AND mobile_number <> '' + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0287_mfa_factor_enforcement_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0287_mfa_factor_enforcement_config.xml new file mode 100644 index 000000000..997ce895f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0287_mfa_factor_enforcement_config.xml @@ -0,0 +1,61 @@ + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'mfa-factor-enforcement' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Super user' + + + 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 + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0288_add_disabled_by_to_m_user_mfa_factor.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0288_add_disabled_by_to_m_user_mfa_factor.xml new file mode 100644 index 000000000..58e1705c7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0288_add_disabled_by_to_m_user_mfa_factor.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0289_seed_read_otp_log_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0289_seed_read_otp_log_permission.xml new file mode 100644 index 000000000..53657baec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0289_seed_read_otp_log_permission.xml @@ -0,0 +1,50 @@ + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG' + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'Super user' + + + 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 + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0290_create_otp_log_auditor_role.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0290_create_otp_log_auditor_role.xml new file mode 100644 index 000000000..9f927899b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0290_create_otp_log_auditor_role.xml @@ -0,0 +1,83 @@ + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor' + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor' + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG' + + + + 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 + ) + + + + + + + + SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin' + SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor' + + + + 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 + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0291_grant_read_otp_log_to_bankadmin_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0291_grant_read_otp_log_to_bankadmin_roles.xml new file mode 100644 index 000000000..1d2e66469 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0291_grant_read_otp_log_to_bankadmin_roles.xml @@ -0,0 +1,50 @@ + + + + + + + + + SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin' + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG' + + + + 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 + ) + + + + diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java index 81d8c3117..020beb125 100644 --- a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java @@ -39,6 +39,7 @@ import java.util.Base64; import java.util.Collection; import java.util.Set; 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.serialization.ToApiJsonSerializer; 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.domain.AppUser; 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.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -79,6 +81,8 @@ public class AuthenticationApiResource { private final ToApiJsonSerializer apiJsonSerializerService; private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext; private final ClientReadPlatformService clientReadPlatformService; + private final ConfigurationDomainService configurationDomainService; + private final UserMfaFactorRepository userMfaFactorRepository; @POST @Consumes({ MediaType.APPLICATION_JSON }) @@ -132,7 +136,18 @@ public class AuthenticationApiResource { 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); Long userId = principal.getId(); if (this.springSecurityPlatformSecurityContext.doesPasswordHasToBeRenewed(principal)) { diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java index 5c63c3c84..c1c8fea4e 100644 --- a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java @@ -28,11 +28,13 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; 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.data.FineractJwtAuthenticationToken; import org.apache.fineract.infrastructure.security.domain.TFAccessToken; import org.apache.fineract.infrastructure.security.service.TwoFactorService; 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.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -57,6 +59,8 @@ import org.springframework.web.filter.GenericFilterBean; public class TwoFactorAuthenticationFilter extends GenericFilterBean { private final TwoFactorService twoFactorService; + private final ConfigurationDomainService configurationDomainService; + private final UserMfaFactorRepository userMfaFactorRepository; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { @@ -78,24 +82,37 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean { return; } - if (!user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION)) { - // User can't bypass two-factor auth, check two-factor access - // token + // Mirror the same gating that AuthenticationApiResource uses to decide + // isTwoFactorAuthenticationRequired. If this user shouldn't be challenged, grant the + // 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"); - if (token != null) { - TFAccessToken accessToken = twoFactorService.fetchAccessTokenForUser(user, token); - // Token is non-existent or invalid - 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; - } - } else { - // No token provided + if (token == null) { + // No token — let the request continue without TWOFACTOR_AUTHENTICATED. + // Downstream SecurityConfig will reject with 403, which signals the UI to + // run the 2FA flow. chain.doFilter(req, res); 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 updatedAuthorities = new ArrayList<>(authentication.getAuthorities()); updatedAuthorities.add(new SimpleGrantedAuthority("TWOFACTOR_AUTHENTICATED")); diff --git a/postman/CBSnexora-Login-OTP.postman_collection.json b/postman/CBSnexora-Login-OTP.postman_collection.json new file mode 100644 index 000000000..4895a48eb --- /dev/null +++ b/postman/CBSnexora-Login-OTP.postman_collection.json @@ -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." + } + ] +}