Add MFA backend: SMS/email OTP, factor management, OTP audit log
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c51a36b4e9
commit
5e3212dc54
+1
@@ -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";
|
||||
|
||||
+7
@@ -26,6 +26,13 @@ public interface ConfigurationDomainService {
|
||||
|
||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||
|
||||
/**
|
||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||
* after password authentication. Default: false (off, safe rollback).
|
||||
*/
|
||||
boolean isMfaFactorEnforcementEnabled();
|
||||
|
||||
List<String> getAllowedLoanStatusesForExternalAssetTransfer();
|
||||
|
||||
List<String> getAllowedLoanStatusesOfDelayedSettlementForExternalAssetTransfer();
|
||||
|
||||
+59
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
@@ -35,6 +37,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
|
||||
@Table(name = "m_client_identifier", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "document_type_id", "document_key" }, name = "unique_identifier_key"),
|
||||
@UniqueConstraint(columnNames = { "client_id", "document_key", "active" }, name = "unique_active_client_identifier") })
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
|
||||
})
|
||||
public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@@ -57,6 +64,22 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
||||
@Column(name = "active")
|
||||
private Integer active;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Boolean isDeleted = Boolean.FALSE;
|
||||
|
||||
@Column(name = "deleted_by")
|
||||
private Long deletedBy;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private java.time.OffsetDateTime deletedAt;
|
||||
|
||||
@jakarta.persistence.Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
|
||||
public static ClientIdentifier fromJson(final Client client, final CodeValue documentType, final JsonCommand command) {
|
||||
final String documentKey = command.stringValueOfParameterNamed("documentKey");
|
||||
final String description = command.stringValueOfParameterNamed("description");
|
||||
@@ -127,4 +150,40 @@ public class ClientIdentifier extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
||||
public Long documentTypeId() {
|
||||
return this.documentType.getId();
|
||||
}
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public void setIsActive(Boolean isActive) {
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
public Boolean getIsDeleted() {
|
||||
return this.isDeleted;
|
||||
}
|
||||
|
||||
public void setIsDeleted(Boolean isDeleted) {
|
||||
this.isDeleted = isDeleted;
|
||||
}
|
||||
|
||||
public Long getDeletedBy() {
|
||||
return this.deletedBy;
|
||||
}
|
||||
|
||||
public void setDeletedBy(Long deletedBy) {
|
||||
this.deletedBy = deletedBy;
|
||||
}
|
||||
|
||||
public java.time.OffsetDateTime getDeletedAt() {
|
||||
return this.deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(java.time.OffsetDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
@@ -46,6 +48,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "client_id" }, name = "uq_m_client_kyc_client_id") })
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
|
||||
})
|
||||
public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@@ -777,4 +784,56 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public String getForm15Fy() {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Boolean isDeleted = Boolean.FALSE;
|
||||
|
||||
@Column(name = "deleted_by")
|
||||
private Long deletedBy;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private java.time.OffsetDateTime deletedAt;
|
||||
|
||||
@jakarta.persistence.Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public void setIsActive(Boolean isActive) {
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
public Boolean getIsDeleted() {
|
||||
return this.isDeleted;
|
||||
}
|
||||
|
||||
public void setIsDeleted(Boolean isDeleted) {
|
||||
this.isDeleted = isDeleted;
|
||||
}
|
||||
|
||||
public Long getDeletedBy() {
|
||||
return this.deletedBy;
|
||||
}
|
||||
|
||||
public void setDeletedBy(Long deletedBy) {
|
||||
this.deletedBy = deletedBy;
|
||||
}
|
||||
|
||||
public java.time.OffsetDateTime getDeletedAt() {
|
||||
return this.deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(java.time.OffsetDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
@@ -39,6 +41,11 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_nominee")
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "createdDate", column = @Column(name = "created_at", updatable = false, nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedBy", column = @Column(name = "modified_by", nullable = false)),
|
||||
@AttributeOverride(name = "lastModifiedDate", column = @Column(name = "modified_on", nullable = false))
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@@ -60,8 +67,21 @@ public class ClientNominee extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
@Column(name = "percentage_share", precision = 5, scale = 2)
|
||||
private BigDecimal percentageShare;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive;
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Boolean isDeleted = Boolean.FALSE;
|
||||
|
||||
@Column(name = "deleted_by")
|
||||
private Long deletedBy;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private java.time.OffsetDateTime deletedAt;
|
||||
|
||||
@jakarta.persistence.Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
|
||||
/** If nominee is a minor, guardian details */
|
||||
@Column(name = "nominee_is_minor")
|
||||
|
||||
+6
-6
@@ -26,21 +26,21 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientNomineeRepository extends JpaRepository<ClientNominee, Long> {
|
||||
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId ORDER BY n.id")
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isDeleted = false ORDER BY n.id")
|
||||
List<ClientNominee> findAllByClientId(@Param("clientId") Long clientId);
|
||||
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.id = :nomineeId")
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.id = :nomineeId AND n.isDeleted = false")
|
||||
Optional<ClientNominee> findByClientIdAndId(@Param("clientId") Long clientId, @Param("nomineeId") Long nomineeId);
|
||||
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isActive = true")
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.isActive = true AND n.isDeleted = false")
|
||||
List<ClientNominee> findActiveByClientId(@Param("clientId") Long clientId);
|
||||
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isActive = true")
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isActive = true AND n.isDeleted = false")
|
||||
List<ClientNominee> findActiveByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
|
||||
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.accountNo = :accountNo AND n.accountType = :accountType ORDER BY n.id")
|
||||
@Query("SELECT n FROM ClientNominee n WHERE n.client.id = :clientId AND n.accountNo = :accountNo AND n.accountType = :accountType AND n.isDeleted = false ORDER BY n.id")
|
||||
List<ClientNominee> findByClientIdAndAccountNoAndAccountType(@Param("clientId") Long clientId, @Param("accountNo") String accountNo, @Param("accountType") String accountType);
|
||||
|
||||
@Query("SELECT COUNT(n) FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType")
|
||||
@Query("SELECT COUNT(n) FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isDeleted = false")
|
||||
long countByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
|
||||
}
|
||||
|
||||
+7
@@ -54,4 +54,11 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
||||
@Modifying
|
||||
@Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId")
|
||||
void removeImageId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,5 +23,5 @@ import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface SearchingClientRepository {
|
||||
|
||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy);
|
||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly);
|
||||
}
|
||||
|
||||
+7
-1
@@ -36,6 +36,7 @@ import org.apache.fineract.infrastructure.core.jpa.CriteriaQueryFactory;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientIdentifier;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientStatus;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -51,7 +52,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
private final CriteriaQueryFactory criteriaQueryFactory;
|
||||
|
||||
@Override
|
||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy) {
|
||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly) {
|
||||
/*
|
||||
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
|
||||
* this point the upgrade is too costly
|
||||
@@ -79,6 +80,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
cb.like(r.get("displayName"), searchLikeValue), cb.like(r.get("externalId"), searchLikeValue),
|
||||
cb.like(r.get("mobileNo"), searchLikeValue), cb.like(identity.get("documentKey"), searchLikeValue)));
|
||||
|
||||
// Linking flows (nominee, family-member, guardian, signatory) only see ACTIVE clients
|
||||
if (activeOnly) {
|
||||
predicates.add(cb.equal(r.get("status"), ClientStatus.ACTIVE.getValue()));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+17
-9
@@ -40,6 +40,7 @@ public final class AppUserData {
|
||||
private final String firstname;
|
||||
private final String lastname;
|
||||
private final String email;
|
||||
private final String mobileNo;
|
||||
private final Boolean passwordNeverExpires;
|
||||
|
||||
// import fields
|
||||
@@ -76,6 +77,7 @@ public final class AppUserData {
|
||||
this.firstname = firstname;
|
||||
this.lastname = lastname;
|
||||
this.email = email;
|
||||
this.mobileNo = null;
|
||||
this.passwordNeverExpires = passwordNeverExpires;
|
||||
this.roles = roleIds;
|
||||
this.sendPasswordToEmail = sendPasswordToEmail;
|
||||
@@ -91,30 +93,31 @@ public final class AppUserData {
|
||||
}
|
||||
|
||||
public static AppUserData template(final AppUserData user, final Collection<OfficeData> officesForDropdown) {
|
||||
return new AppUserData(user.id, user.username, user.email, user.officeId, user.officeName, user.firstname, user.lastname,
|
||||
user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff, user.passwordNeverExpires,
|
||||
user.isSelfServiceUser);
|
||||
return new AppUserData(user.id, user.username, user.email, user.mobileNo, user.officeId, user.officeName, user.firstname,
|
||||
user.lastname, user.availableRoles, user.selfServiceRoles, user.selectedRoles, officesForDropdown, user.staff,
|
||||
user.passwordNeverExpires, user.isSelfServiceUser);
|
||||
}
|
||||
|
||||
public static AppUserData template(final Collection<OfficeData> offices, final Collection<RoleData> availableRoles,
|
||||
final Collection<RoleData> selfServiceRoles) {
|
||||
return new AppUserData(null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null, null);
|
||||
return new AppUserData(null, null, null, null, null, null, null, null, availableRoles, selfServiceRoles, null, offices, null, null,
|
||||
null);
|
||||
}
|
||||
|
||||
public static AppUserData dropdown(final Long id, final String username) {
|
||||
return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
return new AppUserData(id, username, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static AppUserData instance(final Long id, final String username, final String email, final Long officeId,
|
||||
public static AppUserData instance(final Long id, final String username, final String email, final String mobileNo, final Long officeId,
|
||||
final String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
|
||||
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles, final StaffData staff,
|
||||
final Boolean passwordNeverExpire, final Boolean isSelfServiceUser) {
|
||||
return new AppUserData(id, username, email, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles,
|
||||
return new AppUserData(id, username, email, mobileNo, officeId, officeName, firstname, lastname, availableRoles, selfServiceRoles,
|
||||
selectedRoles, null, staff, passwordNeverExpire, isSelfServiceUser);
|
||||
}
|
||||
|
||||
private AppUserData(final Long id, final String username, final String email, final Long officeId, final String officeName,
|
||||
final String firstname, final String lastname, final Collection<RoleData> availableRoles,
|
||||
private AppUserData(final Long id, final String username, final String email, final String mobileNo, final Long officeId,
|
||||
final String officeName, final String firstname, final String lastname, final Collection<RoleData> availableRoles,
|
||||
final Collection<RoleData> selfServiceRoles, final Collection<RoleData> selectedRoles,
|
||||
final Collection<OfficeData> allowedOffices, final StaffData staff, final Boolean passwordNeverExpire,
|
||||
final Boolean isSelfServiceUser) {
|
||||
@@ -125,6 +128,7 @@ public final class AppUserData {
|
||||
this.firstname = firstname;
|
||||
this.lastname = lastname;
|
||||
this.email = email;
|
||||
this.mobileNo = mobileNo;
|
||||
this.allowedOffices = allowedOffices;
|
||||
this.availableRoles = availableRoles;
|
||||
this.selfServiceRoles = selfServiceRoles;
|
||||
@@ -134,6 +138,10 @@ public final class AppUserData {
|
||||
this.isSelfServiceUser = isSelfServiceUser;
|
||||
}
|
||||
|
||||
public String getMobileNo() {
|
||||
return this.mobileNo;
|
||||
}
|
||||
|
||||
public boolean hasIdentifyOf(final Long createdById) {
|
||||
return this.id.equals(createdById);
|
||||
}
|
||||
|
||||
+23
-2
@@ -134,6 +134,17 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
|
||||
@Column(name = "password_reset_required", nullable = false)
|
||||
private boolean passwordResetRequired;
|
||||
|
||||
@Column(name = "mobile_no", length = 16)
|
||||
private String mobileNo;
|
||||
|
||||
public String getMobileNo() {
|
||||
return this.mobileNo;
|
||||
}
|
||||
|
||||
public void setMobileNo(final String mobileNo) {
|
||||
this.mobileNo = mobileNo;
|
||||
}
|
||||
|
||||
public boolean isPasswordResetRequired() {
|
||||
return this.passwordResetRequired;
|
||||
}
|
||||
@@ -174,11 +185,14 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
|
||||
final String email = command.stringValueOfParameterNamed("email");
|
||||
final String firstname = command.stringValueOfParameterNamed("firstname");
|
||||
final String lastname = command.stringValueOfParameterNamed("lastname");
|
||||
final String mobileNo = command.stringValueOfParameterNamed("mobileNo");
|
||||
|
||||
final boolean isSelfServiceUser = command.booleanPrimitiveValueOfParameterNamed(AppUserConstants.IS_SELF_SERVICE_USER);
|
||||
|
||||
return new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire, isSelfServiceUser,
|
||||
clients, cannotChangePassword);
|
||||
final AppUser appUser = new AppUser(userOffice, user, allRoles, email, firstname, lastname, linkedStaff, passwordNeverExpire,
|
||||
isSelfServiceUser, clients, cannotChangePassword);
|
||||
appUser.setMobileNo(mobileNo);
|
||||
return appUser;
|
||||
}
|
||||
|
||||
protected AppUser() {
|
||||
@@ -321,6 +335,13 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
|
||||
this.email = newValue;
|
||||
}
|
||||
|
||||
final String mobileNoParamName = "mobileNo";
|
||||
if (command.isChangeInStringParameterNamed(mobileNoParamName, this.mobileNo)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(mobileNoParamName);
|
||||
actualChanges.put(mobileNoParamName, newValue);
|
||||
this.mobileNo = newValue;
|
||||
}
|
||||
|
||||
final String passwordNeverExpire = "passwordNeverExpires";
|
||||
|
||||
if (command.hasParameter(passwordNeverExpire)
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/**
|
||||
* One row per (AppUser, factor type). Soft-disable via {@link #disabledOn}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_user_mfa_factor", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "appuser_id", "factor_type" }, name = "uq_user_factor") })
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class UserMfaFactor extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "appuser_id", nullable = false)
|
||||
private AppUser appUser;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "factor_type", length = 20, nullable = false)
|
||||
private MfaFactorType factorType;
|
||||
|
||||
@Column(name = "destination", length = 255)
|
||||
private String destination;
|
||||
|
||||
@Column(name = "secret", length = 64)
|
||||
private String secret;
|
||||
|
||||
@Column(name = "serial", length = 64)
|
||||
private String serial;
|
||||
|
||||
@Column(name = "verified", nullable = false)
|
||||
private boolean verified;
|
||||
|
||||
@Column(name = "verified_on")
|
||||
private OffsetDateTime verifiedOn;
|
||||
|
||||
@Column(name = "otp_log_id")
|
||||
private Long otpLogId;
|
||||
|
||||
@Column(name = "created_on", nullable = false, updatable = false)
|
||||
private OffsetDateTime createdOn;
|
||||
|
||||
@Column(name = "disabled_on")
|
||||
private OffsetDateTime disabledOn;
|
||||
|
||||
@Column(name = "disabled_by")
|
||||
private Long disabledBy;
|
||||
|
||||
@PrePersist
|
||||
private void prePersist() {
|
||||
if (createdOn == null) {
|
||||
createdOn = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserMfaFactorRepository extends JpaRepository<UserMfaFactor, Long> {
|
||||
|
||||
List<UserMfaFactor> findByAppUser_IdAndDisabledOnIsNull(Long userId);
|
||||
|
||||
/** Returns every factor for the user — active and disabled — newest first. */
|
||||
List<UserMfaFactor> findByAppUser_IdOrderByIdDesc(Long userId);
|
||||
|
||||
Optional<UserMfaFactor> findByAppUser_IdAndFactorType(Long userId, MfaFactorType type);
|
||||
|
||||
/**
|
||||
* Returns true if the user has at least one verified, non-disabled MFA factor enrolled.
|
||||
* Used by the auth flow to decide whether to gate the login with a 2FA challenge.
|
||||
*/
|
||||
boolean existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(Long userId);
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.exceptionmapper;
|
||||
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiGlobalErrorResponse;
|
||||
import org.apache.fineract.infrastructure.core.exceptionmapper.FineractExceptionMapper;
|
||||
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Provider
|
||||
@Component
|
||||
@Scope("singleton")
|
||||
@Slf4j
|
||||
public class DocumentNotFoundExceptionMapper implements FineractExceptionMapper, ExceptionMapper<DocumentNotFoundException> {
|
||||
|
||||
@Override
|
||||
public Response toResponse(final DocumentNotFoundException exception) {
|
||||
// Missing entity images are an expected normal case (e.g. client without a profile picture);
|
||||
// log at DEBUG to avoid flooding the WARN log with stack traces.
|
||||
log.debug("Document not found: {}", exception.getDefaultUserMessage());
|
||||
final ApiGlobalErrorResponse notFoundErrorResponse = ApiGlobalErrorResponse.notFound(exception.getGlobalisationMessageCode(),
|
||||
exception.getDefaultUserMessage(), exception.getDefaultUserMessageArgs());
|
||||
return Response.status(Status.NOT_FOUND).entity(notFoundErrorResponse).type(MediaType.APPLICATION_JSON).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int errorCode() {
|
||||
return 1002;
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+5
@@ -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();
|
||||
|
||||
+49
@@ -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 + "\"}");
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -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<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
|
||||
entryPoints.put(API_MATCHER.matcher("/api/*/mfa/**"), silentJsonEntryPoint);
|
||||
|
||||
final DelegatingAuthenticationEntryPoint delegating = new DelegatingAuthenticationEntryPoint(entryPoints);
|
||||
delegating.setDefaultEntryPoint(basicEntryPoint);
|
||||
return delegating;
|
||||
}
|
||||
|
||||
@Bean(name = "customAuthenticationProvider")
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.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);
|
||||
}
|
||||
+89
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.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);
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.email.service;
|
||||
|
||||
import java.time.Year;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmailTemplateServiceImpl implements EmailTemplateService {
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String brandName;
|
||||
|
||||
@Value("${fineract.email.support-address:support@cbsnexora.com}")
|
||||
private String supportEmail;
|
||||
|
||||
@Override
|
||||
public String renderOtp(final String recipientName, final String otp, final int validityMinutes) {
|
||||
final String greeting = greeting(recipientName);
|
||||
final String body = ""
|
||||
+ "<p style=\"margin:0 0 16px;\">" + greeting + "</p>"
|
||||
+ "<p style=\"margin:0 0 16px;\">Use the one-time password (OTP) below to complete your sign-in. "
|
||||
+ "It is valid for <strong>" + validityMinutes + " minutes</strong>.</p>"
|
||||
+ "<div style=\"margin:24px 0;text-align:center;\">"
|
||||
+ " <span style=\"display:inline-block;font-family:'Courier New',monospace;font-size:32px;letter-spacing:8px;"
|
||||
+ "background:#f4f6fb;border:1px solid #d8dde8;border-radius:6px;padding:14px 28px;color:#0f172a;font-weight:700;\">"
|
||||
+ escape(otp) + "</span>"
|
||||
+ "</div>"
|
||||
+ "<p style=\"margin:0 0 16px;\">If you did not request this code, please ignore this email or "
|
||||
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a> immediately.</p>";
|
||||
return wrap("Your " + brandName + " Sign-in OTP", body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String renderWelcome(final String recipientName, final String username, final String loginUrl) {
|
||||
final String safeUrl = loginUrl == null ? "#" : loginUrl;
|
||||
final String body = ""
|
||||
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
|
||||
+ "<p style=\"margin:0 0 16px;\">Welcome to <strong>" + escape(brandName) + "</strong>. Your account has been created.</p>"
|
||||
+ "<p style=\"margin:0 0 8px;\">Username: <strong>" + escape(username) + "</strong></p>"
|
||||
+ "<div style=\"margin:24px 0;\"><a href=\"" + escape(safeUrl) + "\" "
|
||||
+ "style=\"display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 24px;"
|
||||
+ "border-radius:6px;font-weight:600;\">Sign in to your account</a></div>"
|
||||
+ "<p style=\"margin:0 0 16px;\">If you did not expect this email, "
|
||||
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a>.</p>";
|
||||
return wrap("Welcome to " + brandName, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String renderPasswordReset(final String recipientName, final String resetLink, final int validityMinutes) {
|
||||
final String safeLink = resetLink == null ? "#" : resetLink;
|
||||
final String body = ""
|
||||
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
|
||||
+ "<p style=\"margin:0 0 16px;\">We received a request to reset your password. The link below is valid for "
|
||||
+ "<strong>" + validityMinutes + " minutes</strong>.</p>"
|
||||
+ "<div style=\"margin:24px 0;\"><a href=\"" + escape(safeLink) + "\" "
|
||||
+ "style=\"display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 24px;"
|
||||
+ "border-radius:6px;font-weight:600;\">Reset password</a></div>"
|
||||
+ "<p style=\"margin:0 0 16px;color:#475569;font-size:13px;\">If the button does not work, paste this URL into your browser:<br/>"
|
||||
+ "<span style=\"word-break:break-all;\">" + escape(safeLink) + "</span></p>"
|
||||
+ "<p style=\"margin:0 0 16px;\">If you did not request a password reset, you can safely ignore this email.</p>";
|
||||
return wrap("Reset your " + brandName + " password", body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String renderAlert(final String recipientName, final String alertTitle, final String alertMessage) {
|
||||
final String body = ""
|
||||
+ "<p style=\"margin:0 0 16px;\">" + greeting(recipientName) + "</p>"
|
||||
+ "<div style=\"margin:16px 0;padding:16px;background:#fff7ed;border-left:4px solid #f97316;border-radius:4px;\">"
|
||||
+ " <p style=\"margin:0 0 6px;font-weight:700;color:#9a3412;\">" + escape(alertTitle) + "</p>"
|
||||
+ " <p style=\"margin:0;color:#7c2d12;\">" + escape(alertMessage) + "</p>"
|
||||
+ "</div>"
|
||||
+ "<p style=\"margin:0 0 16px;\">If this does not look right, please "
|
||||
+ "<a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">contact support</a>.</p>";
|
||||
return wrap(alertTitle + " — " + brandName, body);
|
||||
}
|
||||
|
||||
// ── Shared shell ─────────────────────────────────────────────────────────
|
||||
|
||||
private String wrap(final String previewTitle, final String innerHtml) {
|
||||
return ""
|
||||
+ "<!DOCTYPE html>"
|
||||
+ "<html><head>"
|
||||
+ "<meta charset=\"utf-8\"/>"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>"
|
||||
+ "<title>" + escape(previewTitle) + "</title>"
|
||||
+ "</head>"
|
||||
+ "<body style=\"margin:0;padding:0;background:#f1f5f9;font-family:'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#0f172a;\">"
|
||||
+ " <div style=\"display:none;visibility:hidden;opacity:0;height:0;width:0;overflow:hidden;\">" + escape(previewTitle) + "</div>"
|
||||
+ " <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#f1f5f9;\">"
|
||||
+ " <tr><td align=\"center\" style=\"padding:32px 16px;\">"
|
||||
+ " <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" "
|
||||
+ " style=\"max-width:600px;background:#ffffff;border-radius:8px;overflow:hidden;"
|
||||
+ " box-shadow:0 1px 3px rgba(15,23,42,0.08);\">"
|
||||
// Header / brand
|
||||
+ " <tr><td style=\"background:#0f172a;padding:24px 28px;\">"
|
||||
+ " <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>"
|
||||
+ " <td style=\"font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;\">"
|
||||
+ escape(brandName) + "</td>"
|
||||
+ " <td style=\"text-align:right;font-size:12px;color:#94a3b8;\">Core Banking</td>"
|
||||
+ " </tr></table>"
|
||||
+ " </td></tr>"
|
||||
// Body
|
||||
+ " <tr><td style=\"padding:32px 28px;font-size:15px;line-height:1.6;\">"
|
||||
+ innerHtml
|
||||
+ " </td></tr>"
|
||||
// Footer
|
||||
+ " <tr><td style=\"padding:20px 28px;background:#f8fafc;border-top:1px solid #e2e8f0;\">"
|
||||
+ " <p style=\"margin:0 0 6px;font-size:12px;color:#64748b;\">"
|
||||
+ " Need help? Reach our team at "
|
||||
+ " <a href=\"mailto:" + escape(supportEmail) + "\" style=\"color:#1d4ed8;\">"
|
||||
+ escape(supportEmail) + "</a>"
|
||||
+ " </p>"
|
||||
+ " <p style=\"margin:0;font-size:12px;color:#94a3b8;\">"
|
||||
+ " © " + Year.now().getValue() + " " + escape(brandName) + ". All rights reserved."
|
||||
+ " </p>"
|
||||
+ " </td></tr>"
|
||||
+ " </table>"
|
||||
+ " </td></tr>"
|
||||
+ " </table>"
|
||||
+ "</body></html>";
|
||||
}
|
||||
|
||||
private String greeting(final String recipientName) {
|
||||
return "Hi " + (recipientName == null || recipientName.isBlank() ? "there" : escape(recipientName)) + ",";
|
||||
}
|
||||
|
||||
private String escape(final String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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() {
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogData;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogPage;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogSearchCriteria;
|
||||
import org.apache.fineract.infrastructure.security.otp.service.OtpLogReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin-only viewer for the {@code m_otp_log} audit trail.
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /v1/otp-logs — paged search across OTP rows joined with m_appuser; destination is
|
||||
* masked server-side, channel is normalised (SMS/EMAIL/TOTP)</li>
|
||||
* <li>GET /v1/otp-logs/{id} — single row, optionally with raw request/response payloads</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Permission: {@code READ_OTP_LOG} (granted to {@code Super user} by migration 0289).
|
||||
*/
|
||||
@Path("/v1/otp-logs")
|
||||
@Component
|
||||
@Tag(name = "OTP Logs (Admin)", description = "Read access to the m_otp_log audit trail")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminOtpLogApiResource {
|
||||
|
||||
private static final String RESOURCE = "OTP_LOG";
|
||||
private static final int DEFAULT_PAGE_SIZE = 25;
|
||||
private static final int MAX_PAGE_SIZE = 500;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final OtpLogReadPlatformService readService;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Paged list of OTP audit rows. Filters: search, status, channel, "
|
||||
+ "dateFrom, dateTo. Destination is masked server-side; payloads are excluded by default "
|
||||
+ "— use the /{id} endpoint with includePayload=true for the full record.")
|
||||
public OtpLogPage list(
|
||||
@QueryParam("page") @DefaultValue("0") final int page,
|
||||
@QueryParam("pageSize") @DefaultValue("25") final int pageSize,
|
||||
@QueryParam("search") final String search,
|
||||
@QueryParam("status") final String status,
|
||||
@QueryParam("channel") final String channel,
|
||||
@QueryParam("purpose") final String purpose,
|
||||
@QueryParam("userId") final Long userId,
|
||||
@QueryParam("username") final String username,
|
||||
@QueryParam("dateFrom") final String dateFrom,
|
||||
@QueryParam("dateTo") final String dateTo) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
|
||||
final int sanitizedPageSize = (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) ? DEFAULT_PAGE_SIZE : pageSize;
|
||||
final int sanitizedPage = Math.max(0, page);
|
||||
final int offset = sanitizedPage * sanitizedPageSize;
|
||||
|
||||
final OtpLogSearchCriteria criteria = OtpLogSearchCriteria.builder()
|
||||
.userId(userId)
|
||||
.username(blankToNull(username))
|
||||
.purpose(blankToNull(purpose))
|
||||
.channel(blankToNull(channel))
|
||||
.status(blankToNull(status))
|
||||
.search(blankToNull(search))
|
||||
.fromCreatedAt(parseFromDate(dateFrom))
|
||||
.toCreatedAt(parseToDate(dateTo))
|
||||
.includePayload(false)
|
||||
.offset(offset)
|
||||
.limit(sanitizedPageSize)
|
||||
.build();
|
||||
|
||||
return readService.retrieveAll(criteria);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Single OTP audit row by id. includePayload=true (default) also returns "
|
||||
+ "raw request/response payloads and the OTP hash for forensics.")
|
||||
public OtpLogData getById(@PathParam("id") final Long id,
|
||||
@QueryParam("includePayload") @DefaultValue("true") final boolean includePayload) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final OtpLogData row = readService.retrieveOne(id, includePayload);
|
||||
if (row == null) {
|
||||
throw new NotFoundException("OTP log " + id + " not found");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static String blankToNull(final String s) {
|
||||
return s == null || s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a yyyy-MM-dd date as the start of that day (00:00:00). Lenient — also accepts a full
|
||||
* ISO datetime if the caller sends one.
|
||||
*/
|
||||
private static LocalDateTime parseFromDate(final String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s).atStartOfDay();
|
||||
} catch (final Exception ignored) {
|
||||
try {
|
||||
return LocalDateTime.parse(s);
|
||||
} catch (final Exception ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a yyyy-MM-dd date as the end of that day (23:59:59.999) so dateTo is inclusive.
|
||||
*/
|
||||
private static LocalDateTime parseToDate(final String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s).atTime(23, 59, 59, 999_000_000);
|
||||
} catch (final Exception ignored) {
|
||||
try {
|
||||
return LocalDateTime.parse(s);
|
||||
} catch (final Exception ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.api;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.service.EmailLoginOtpService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Public endpoints for sending and verifying email-based login OTPs.
|
||||
*
|
||||
* <p>POST /v1/login/otp/email/generate body: { "username": "alice" } — email looked up from m_appuser
|
||||
* <p>POST /v1/login/otp/email/verify body: { "otpId": 1234, "otp": "123456" }
|
||||
*/
|
||||
@Path("/v1/login/otp/email")
|
||||
@Component
|
||||
@Tag(name = "Login Email OTP", description = "Email-based OTP send/verify for 2FA login")
|
||||
@RequiredArgsConstructor
|
||||
public class EmailLoginOtpApiResource {
|
||||
|
||||
private final EmailLoginOtpService emailLoginOtpService;
|
||||
|
||||
@POST
|
||||
@Path("generate")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Send OTP to the user's registered email")
|
||||
public OtpGenerateResponse generate(final String body, @Context final HttpServletRequest httpRequest) {
|
||||
final JsonObject payload = parse(body);
|
||||
final String username = stringOrNull(payload, "username");
|
||||
|
||||
final String ipAddress = clientIp(httpRequest);
|
||||
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
|
||||
|
||||
return emailLoginOtpService.generate(username, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("verify")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Verify the email OTP")
|
||||
public OtpVerifyResponse verify(final String body) {
|
||||
final JsonObject payload = parse(body);
|
||||
final Long otpId = longOrNull(payload, "otpId");
|
||||
final String otp = stringOrNull(payload, "otp");
|
||||
return emailLoginOtpService.verify(otpId, otp);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final Exception e) {
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject obj, final String key) {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long longOrNull(final JsonObject obj, final String key) {
|
||||
try {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String clientIp(final HttpServletRequest req) {
|
||||
if (req == null) {
|
||||
return null;
|
||||
}
|
||||
final String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) {
|
||||
return fwd.split(",")[0].trim();
|
||||
}
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.api;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.service.LoginOtpService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Public endpoints for sending and verifying login OTPs (no auth required, similar to PAN/GSTIN flows).
|
||||
*
|
||||
* <p>POST /v1/login/otp/generate body: { "username": "alice" } ← mobile is looked up from m_appuser
|
||||
* <p>POST /v1/login/otp/verify body: { "otpId": 1234, "otp": "123456" }
|
||||
*/
|
||||
@Path("/v1/login/otp")
|
||||
@Component
|
||||
@Tag(name = "Login OTP", description = "OTP send/verify for 2FA login (LincPay-backed)")
|
||||
@RequiredArgsConstructor
|
||||
public class LoginOtpApiResource {
|
||||
|
||||
private final LoginOtpService loginOtpService;
|
||||
|
||||
@POST
|
||||
@Path("generate")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Send OTP to the user's registered mobile (looked up from m_appuser) via LincPay")
|
||||
public OtpGenerateResponse generate(final String body, @Context final HttpServletRequest httpRequest) {
|
||||
final JsonObject payload = parse(body);
|
||||
final String username = stringOrNull(payload, "username");
|
||||
|
||||
final String ipAddress = clientIp(httpRequest);
|
||||
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
|
||||
|
||||
return loginOtpService.generate(username, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("verify")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Verify the OTP that was sent")
|
||||
public OtpVerifyResponse verify(final String body) {
|
||||
final JsonObject payload = parse(body);
|
||||
final Long otpId = longOrNull(payload, "otpId");
|
||||
final String otp = stringOrNull(payload, "otp");
|
||||
return loginOtpService.verify(otpId, otp);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final Exception e) {
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject obj, final String key) {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long longOrNull(final JsonObject obj, final String key) {
|
||||
try {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String clientIp(final HttpServletRequest req) {
|
||||
if (req == null) {
|
||||
return null;
|
||||
}
|
||||
final String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) {
|
||||
return fwd.split(",")[0].trim();
|
||||
}
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Admin-facing audit row for an OTP send/verify cycle. Designed for the OTP-logs grid:
|
||||
* destination is masked, channel is normalised (SMS/EMAIL/TOTP), message is human-readable.
|
||||
*
|
||||
* <p>Heavy raw fields (request/response payloads, OTP hash) live on the detail endpoint only.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
public class OtpLogData {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
/** Full name from m_appuser (firstname + lastname). Useful in detail view; can be hidden in grid. */
|
||||
private final String userFullName;
|
||||
/** SMS / EMAIL / TOTP — UI-facing channel name (provider mapped: LINCPAY → SMS). */
|
||||
private final String channel;
|
||||
/** Masked recipient (e.g. {@code +91 XXXXXX2076} for SMS, {@code adXXXX@gmail.com} for email). */
|
||||
private final String destination;
|
||||
private final String otpReference;
|
||||
/** PENDING / SENT / VERIFIED / FAILED / EXPIRED / LOCKED. */
|
||||
private final String status;
|
||||
private final String purpose;
|
||||
private final Integer attempts;
|
||||
private final Integer maxAttempts;
|
||||
private final LocalDateTime createdOn;
|
||||
private final OffsetDateTime expiresOn;
|
||||
private final OffsetDateTime verifiedOn;
|
||||
private final String ipAddress;
|
||||
private final String userAgent;
|
||||
/** Human-readable summary — error_message on failure, synthesised text on success. */
|
||||
private final String message;
|
||||
|
||||
// Heavy / privacy-sensitive fields — only on detail endpoint with includePayload=true.
|
||||
private final String requestPayload;
|
||||
private final String responsePayload;
|
||||
private final String otpHash;
|
||||
|
||||
// Underlying provider value (LINCPAY, EMAIL, ...) — kept for forensics, optional in grid.
|
||||
private final String provider;
|
||||
/** Raw user id from m_otp_log.user_id — useful for filter "show all by user". */
|
||||
private final Long userId;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.data;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class OtpLogPage {
|
||||
|
||||
/** Total rows matching the filter (across all pages). Frontend uses this to size pagination. */
|
||||
private final long totalElements;
|
||||
private final List<OtpLogData> pageItems;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EntityListeners;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
import org.springframework.data.annotation.CreatedBy;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedBy;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
/**
|
||||
* Audit log of every OTP issued (login 2FA, KYC, future flows). One row per OTP request.
|
||||
*
|
||||
* <p>Status transitions: PENDING → SENT (provider accepted) → VERIFIED / EXPIRED / FAILED.
|
||||
*
|
||||
* <p>Secrets are NEVER stored — {@link #requestPayload} has them redacted before save.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_otp_log")
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class OtpLog extends AbstractPersistableCustom<Long> {
|
||||
|
||||
@Column(name = "purpose", length = 30, nullable = false)
|
||||
private String purpose;
|
||||
|
||||
@Column(name = "provider", length = 30, nullable = false)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "username", length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(name = "mobile_number", length = 20)
|
||||
private String mobileNumber;
|
||||
|
||||
@Column(name = "email_address", length = 255)
|
||||
private String emailAddress;
|
||||
|
||||
@Column(name = "otp_reference", length = 100)
|
||||
private String otpReference;
|
||||
|
||||
@Column(name = "otp_hash", length = 255)
|
||||
private String otpHash;
|
||||
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private String status = "PENDING";
|
||||
|
||||
@Column(name = "attempts", nullable = false)
|
||||
private Integer attempts = 0;
|
||||
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "user_agent", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "request_payload", columnDefinition = "LONGTEXT")
|
||||
private String requestPayload;
|
||||
|
||||
@Column(name = "response_payload", columnDefinition = "LONGTEXT")
|
||||
private String responsePayload;
|
||||
|
||||
@Column(name = "error_message", length = 1000)
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "expires_at")
|
||||
private OffsetDateTime expiresAt;
|
||||
|
||||
@Column(name = "verified_at")
|
||||
private OffsetDateTime verifiedAt;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Boolean isDeleted = Boolean.FALSE;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by", updatable = false)
|
||||
private Long createdBy;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "modified_by")
|
||||
private Long modifiedBy;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "modified_on")
|
||||
private LocalDateTime modifiedOn;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface OtpLogRepository extends JpaRepository<OtpLog, Long> {
|
||||
|
||||
Optional<OtpLog> findFirstByIdAndIsDeletedFalseOrderByIdDesc(Long id);
|
||||
|
||||
List<OtpLog> findByMobileNumberAndPurposeAndIsDeletedFalseOrderByIdDesc(String mobileNumber, String purpose);
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+255
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
|
||||
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Service
|
||||
public class LoginOtpServiceImpl implements LoginOtpService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(LoginOtpServiceImpl.class);
|
||||
|
||||
private static final String PURPOSE_LOGIN_2FA = "LOGIN_2FA";
|
||||
private static final String PROVIDER_LINCPAY = "LINCPAY";
|
||||
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_SENT = "SENT";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String STATUS_VERIFIED = "VERIFIED";
|
||||
private static final String STATUS_EXPIRED = "EXPIRED";
|
||||
|
||||
@Value("${fineract.kyc.lincpay.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${fineract.kyc.lincpay.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${fineract.kyc.lincpay.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${fineract.security.otp.lincpay.generate-path:/mobile/generate-otp}")
|
||||
private String generateOtpPath;
|
||||
|
||||
@Value("${fineract.security.otp.lincpay.verify-path:/verify-mobile-otp}")
|
||||
private String verifyOtpPath;
|
||||
|
||||
@Value("${fineract.security.otp.ttl-seconds:300}")
|
||||
private int ttlSeconds;
|
||||
|
||||
@Value("${fineract.security.otp.max-attempts:5}")
|
||||
private int maxAttempts;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final OtpLogRepository otpLogRepository;
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
|
||||
@Autowired
|
||||
public LoginOtpServiceImpl(final OtpLogRepository otpLogRepository, final AppUserRepository appUserRepository,
|
||||
final UserMfaFactorRepository userMfaFactorRepository) {
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.otpLogRepository = otpLogRepository;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.userMfaFactorRepository = userMfaFactorRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OtpGenerateResponse generate(final String username, final String ipAddress, final String userAgent) {
|
||||
if (username == null || username.isBlank()) {
|
||||
return OtpGenerateResponse.builder().success(false).message("username is required").build();
|
||||
}
|
||||
|
||||
// Look up the user and their registered mobile number — single source of truth is m_appuser.
|
||||
final AppUser appUser = appUserRepository.findAppUserByName(username);
|
||||
if (appUser == null) {
|
||||
// Don't leak whether the user exists. Generic message + no DB row written.
|
||||
LOG.info("Login OTP requested for unknown username: {}", username);
|
||||
return OtpGenerateResponse.builder().success(false).message("Unable to send OTP").build();
|
||||
}
|
||||
final String mobileNumber = appUser.getMobileNo();
|
||||
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||
LOG.warn("Login OTP requested for user without registered mobile: {}", username);
|
||||
return OtpGenerateResponse.builder().success(false)
|
||||
.message("No mobile number registered for this user — contact admin").build();
|
||||
}
|
||||
|
||||
// LincPay generates the OTP on their side and SMS-delivers it. We track the request via reference;
|
||||
// verification is delegated to LincPay's verify-mobile-otp endpoint.
|
||||
final OtpLog log = new OtpLog();
|
||||
log.setUserId(appUser.getId());
|
||||
log.setPurpose(PURPOSE_LOGIN_2FA);
|
||||
log.setProvider(PROVIDER_LINCPAY);
|
||||
log.setUsername(username);
|
||||
log.setMobileNumber(mobileNumber);
|
||||
log.setStatus(STATUS_PENDING);
|
||||
log.setIpAddress(ipAddress);
|
||||
log.setUserAgent(userAgent);
|
||||
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
|
||||
|
||||
final JsonObject requestBody = new JsonObject();
|
||||
requestBody.addProperty("apiKey", apiKey);
|
||||
requestBody.addProperty("mobileNumber", mobileNumber);
|
||||
requestBody.addProperty("secretKey", secret);
|
||||
|
||||
// Stored copy has secrets redacted to avoid leaking credentials in audit logs
|
||||
final JsonObject redactedRequest = new JsonObject();
|
||||
redactedRequest.addProperty("apiKey", "***REDACTED***");
|
||||
redactedRequest.addProperty("mobileNumber", mobileNumber);
|
||||
redactedRequest.addProperty("secretKey", "***REDACTED***");
|
||||
log.setRequestPayload(redactedRequest.toString());
|
||||
|
||||
try {
|
||||
final String url = baseUrl + generateOtpPath;
|
||||
final String rawResponse = post(url, requestBody.toString());
|
||||
log.setResponsePayload(truncate(rawResponse, 65000));
|
||||
|
||||
final JsonObject response = parseJson(rawResponse);
|
||||
if (isSuccess(response)) {
|
||||
log.setStatus(STATUS_SENT);
|
||||
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number",
|
||||
"transactionId", "txnId", "requestId"));
|
||||
final OtpLog saved = otpLogRepository.saveAndFlush(log);
|
||||
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), mask(mobileNumber));
|
||||
return OtpGenerateResponse.builder()
|
||||
.success(true)
|
||||
.otpId(saved.getId())
|
||||
.otpReference(saved.getOtpReference())
|
||||
.maskedMobile(mask(mobileNumber))
|
||||
.expiresInSeconds(ttlSeconds)
|
||||
.message("OTP sent")
|
||||
.build();
|
||||
}
|
||||
|
||||
final String errorMsg = extractField(response, "message", "errorMessage", "error", "description");
|
||||
log.setStatus(STATUS_FAILED);
|
||||
log.setErrorMessage(errorMsg);
|
||||
otpLogRepository.saveAndFlush(log);
|
||||
LOG.warn("LincPay login OTP failed: {}", errorMsg);
|
||||
return OtpGenerateResponse.builder().success(false).message(errorMsg != null ? errorMsg : "OTP send failed").build();
|
||||
|
||||
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
||||
log.setStatus(STATUS_FAILED);
|
||||
log.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
|
||||
log.setResponsePayload(truncate(e.getResponseBodyAsString(), 65000));
|
||||
otpLogRepository.saveAndFlush(log);
|
||||
LOG.error("LincPay login OTP HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return OtpGenerateResponse.builder().success(false).message("OTP send failed (HTTP " + e.getStatusCode() + ")").build();
|
||||
} catch (final Exception e) {
|
||||
log.setStatus(STATUS_FAILED);
|
||||
log.setErrorMessage(truncate(e.getMessage(), 900));
|
||||
otpLogRepository.saveAndFlush(log);
|
||||
LOG.error("LincPay login OTP unexpected error", e);
|
||||
return OtpGenerateResponse.builder().success(false).message("OTP send failed: " + e.getMessage()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OtpVerifyResponse verify(final Long otpId, final String otp) {
|
||||
if (otpId == null || otp == null || otp.isBlank()) {
|
||||
return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build();
|
||||
}
|
||||
|
||||
final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
|
||||
if (row == null) {
|
||||
return OtpVerifyResponse.builder().verified(false).message("OTP not found").build();
|
||||
}
|
||||
if (STATUS_VERIFIED.equals(row.getStatus())) {
|
||||
return OtpVerifyResponse.builder().verified(false).message("OTP already used").build();
|
||||
}
|
||||
if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) {
|
||||
row.setStatus(STATUS_EXPIRED);
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
return OtpVerifyResponse.builder().verified(false).message("OTP expired").build();
|
||||
}
|
||||
if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) {
|
||||
row.setStatus(STATUS_EXPIRED);
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build();
|
||||
}
|
||||
|
||||
row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1);
|
||||
// Hash of the submitted OTP — useful for audit (we never store the plaintext)
|
||||
row.setOtpHash(sha256(otp));
|
||||
|
||||
// Delegate verification to LincPay
|
||||
final JsonObject requestBody = new JsonObject();
|
||||
requestBody.addProperty("apiKey", apiKey);
|
||||
requestBody.addProperty("mobileNumber", row.getMobileNumber());
|
||||
requestBody.addProperty("otp", otp);
|
||||
requestBody.addProperty("secretKey", secret);
|
||||
|
||||
try {
|
||||
final String url = baseUrl + verifyOtpPath;
|
||||
final String rawResponse = post(url, requestBody.toString());
|
||||
// Append latest verify response to existing payload for audit
|
||||
final String existing = row.getResponsePayload() == null ? "" : row.getResponsePayload();
|
||||
row.setResponsePayload(truncate(existing + "\n--verify--\n" + rawResponse, 65000));
|
||||
|
||||
final JsonObject response = parseJson(rawResponse);
|
||||
if (isSuccess(response)) {
|
||||
row.setStatus(STATUS_VERIFIED);
|
||||
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
autoPromoteFactor(row);
|
||||
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), mask(row.getMobileNumber()));
|
||||
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
|
||||
}
|
||||
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
final String providerMsg = extractField(response, "message", "errorMessage", "error", "description");
|
||||
final int remaining = Math.max(0, maxAttempts - row.getAttempts());
|
||||
return OtpVerifyResponse.builder().verified(false)
|
||||
.message(providerMsg != null ? providerMsg : "Invalid OTP")
|
||||
.attemptsRemaining(remaining).build();
|
||||
|
||||
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
||||
row.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
LOG.error("LincPay verify-mobile-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return OtpVerifyResponse.builder().verified(false)
|
||||
.message("OTP verification failed (HTTP " + e.getStatusCode() + ")").build();
|
||||
} catch (final Exception e) {
|
||||
row.setErrorMessage(truncate(e.getMessage(), 900));
|
||||
otpLogRepository.saveAndFlush(row);
|
||||
LOG.error("LincPay verify-mobile-otp unexpected error", e);
|
||||
return OtpVerifyResponse.builder().verified(false)
|
||||
.message("OTP verification failed: " + e.getMessage()).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the user's SMS MFA factor on a successful OTP verify. Creates the row if it's the
|
||||
* user's first verification (acts as enrollment); otherwise refreshes verified_on + otp_log_id.
|
||||
* The user proved possession of the destination by completing the OTP cycle, so the factor row
|
||||
* is the natural side-effect of that proof.
|
||||
*/
|
||||
private void autoPromoteFactor(final OtpLog row) {
|
||||
if (row.getUserId() == null) {
|
||||
return;
|
||||
}
|
||||
final AppUser user = appUserRepository.findById(row.getUserId()).orElse(null);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
final UserMfaFactor factor = userMfaFactorRepository
|
||||
.findByAppUser_IdAndFactorType(row.getUserId(), MfaFactorType.SMS)
|
||||
.orElseGet(() -> {
|
||||
final UserMfaFactor f = new UserMfaFactor();
|
||||
f.setAppUser(user);
|
||||
f.setFactorType(MfaFactorType.SMS);
|
||||
return f;
|
||||
});
|
||||
factor.setVerified(true);
|
||||
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
factor.setOtpLogId(row.getId());
|
||||
factor.setDisabledOn(null);
|
||||
factor.setDisabledBy(null);
|
||||
factor.setDestination(row.getMobileNumber());
|
||||
userMfaFactorRepository.saveAndFlush(factor);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String post(final String url, final String requestBodyJson) {
|
||||
final HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("accept", "*/*");
|
||||
final HttpEntity<String> entity = new HttpEntity<>(requestBodyJson, headers);
|
||||
final ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
||||
return response.getBody();
|
||||
}
|
||||
|
||||
private JsonObject parseJson(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(raw);
|
||||
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private boolean isSuccess(final JsonObject response) {
|
||||
for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) {
|
||||
if (response.has(key)) {
|
||||
final JsonElement v = response.get(key);
|
||||
if (v.isJsonPrimitive()) {
|
||||
final String s = v.getAsString();
|
||||
if ("200".equals(s) || "0".equals(s) || "true".equalsIgnoreCase(s)
|
||||
|| "success".equalsIgnoreCase(s) || "SUCCESS".equalsIgnoreCase(s)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.has("success") && response.get("success").isJsonPrimitive()) {
|
||||
return response.get("success").getAsBoolean();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractField(final JsonObject obj, final String... keys) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
for (final String key : keys) {
|
||||
if (obj.has(key) && obj.get(key).isJsonPrimitive()) {
|
||||
return obj.get(key).getAsString();
|
||||
}
|
||||
}
|
||||
// Also look one level deep in nested "data" object
|
||||
if (obj.has("data") && obj.get("data").isJsonObject()) {
|
||||
final JsonObject data = obj.getAsJsonObject("data");
|
||||
for (final String key : keys) {
|
||||
if (data.has(key) && data.get(key).isJsonPrimitive()) {
|
||||
return data.get(key).getAsString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String sha256(final String input) {
|
||||
try {
|
||||
final MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(final String mobile) {
|
||||
if (mobile == null || mobile.length() < 4) {
|
||||
return "****";
|
||||
}
|
||||
return "******" + mobile.substring(mobile.length() - 4);
|
||||
}
|
||||
|
||||
private String truncate(final String s, final int max) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
return s.length() <= max ? s : s.substring(0, max);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.otp.service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogData;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogPage;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpLogSearchCriteria;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class OtpLogReadPlatformServiceImpl implements OtpLogReadPlatformService {
|
||||
|
||||
private static final String CHANNEL_SMS = "SMS";
|
||||
private static final String CHANNEL_EMAIL = "EMAIL";
|
||||
private static final String CHANNEL_TOTP = "TOTP";
|
||||
|
||||
private static final String PROVIDER_LINCPAY = "LINCPAY";
|
||||
private static final String PROVIDER_EMAIL = "EMAIL";
|
||||
|
||||
@Value("${fineract.security.otp.max-attempts:5}")
|
||||
private int maxAttempts;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public OtpLogPage retrieveAll(final OtpLogSearchCriteria criteria) {
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final StringBuilder where = new StringBuilder(" WHERE 1=1 ");
|
||||
|
||||
if (criteria.getUserId() != null) {
|
||||
where.append(" AND o.user_id = ? ");
|
||||
params.add(criteria.getUserId());
|
||||
}
|
||||
if (notBlank(criteria.getUsername())) {
|
||||
where.append(" AND o.username = ? ");
|
||||
params.add(criteria.getUsername());
|
||||
}
|
||||
if (notBlank(criteria.getPurpose())) {
|
||||
where.append(" AND o.purpose = ? ");
|
||||
params.add(criteria.getPurpose());
|
||||
}
|
||||
// channel (UI-facing) takes precedence over provider (raw)
|
||||
if (notBlank(criteria.getChannel())) {
|
||||
final String mapped = mapChannelToProvider(criteria.getChannel());
|
||||
if (mapped != null) {
|
||||
where.append(" AND o.provider = ? ");
|
||||
params.add(mapped);
|
||||
}
|
||||
} else if (notBlank(criteria.getProvider())) {
|
||||
where.append(" AND o.provider = ? ");
|
||||
params.add(criteria.getProvider());
|
||||
}
|
||||
if (notBlank(criteria.getStatus())) {
|
||||
where.append(" AND o.status = ? ");
|
||||
params.add(criteria.getStatus());
|
||||
}
|
||||
if (notBlank(criteria.getMobileNumber())) {
|
||||
where.append(" AND o.mobile_number = ? ");
|
||||
params.add(criteria.getMobileNumber());
|
||||
}
|
||||
if (notBlank(criteria.getEmailAddress())) {
|
||||
where.append(" AND o.email_address = ? ");
|
||||
params.add(criteria.getEmailAddress());
|
||||
}
|
||||
if (notBlank(criteria.getSearch())) {
|
||||
// LIKE-match across username, mobile, email, otp_reference
|
||||
where.append(" AND (o.username LIKE ? OR o.mobile_number LIKE ? OR o.email_address LIKE ? OR o.otp_reference LIKE ?) ");
|
||||
final String like = "%" + criteria.getSearch() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
if (criteria.getFromCreatedAt() != null) {
|
||||
where.append(" AND o.created_at >= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getFromCreatedAt()));
|
||||
}
|
||||
if (criteria.getToCreatedAt() != null) {
|
||||
where.append(" AND o.created_at <= ? ");
|
||||
params.add(Timestamp.valueOf(criteria.getToCreatedAt()));
|
||||
}
|
||||
|
||||
final String countSql = "SELECT COUNT(*) FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
|
||||
+ where.toString();
|
||||
final Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); // NOSONAR
|
||||
|
||||
if (total == null || total == 0L) {
|
||||
return new OtpLogPage(0L, Collections.emptyList());
|
||||
}
|
||||
|
||||
final String dataSql = "SELECT " + selectList(criteria.isIncludePayload())
|
||||
+ " FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
|
||||
+ where.toString()
|
||||
+ " ORDER BY o.id DESC LIMIT ? OFFSET ? ";
|
||||
final List<Object> dataParams = new ArrayList<>(params);
|
||||
dataParams.add(criteria.getLimit() <= 0 ? 25 : criteria.getLimit());
|
||||
dataParams.add(Math.max(0, criteria.getOffset()));
|
||||
|
||||
final List<OtpLogData> rows = jdbcTemplate.query(dataSql, rowMapper(criteria.isIncludePayload()), dataParams.toArray()); // NOSONAR
|
||||
return new OtpLogPage(total, rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OtpLogData retrieveOne(final Long id, final boolean includePayload) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
final String sql = "SELECT " + selectList(includePayload)
|
||||
+ " FROM m_otp_log o LEFT JOIN m_appuser u ON u.id = o.user_id "
|
||||
+ " WHERE o.id = ? ";
|
||||
final List<OtpLogData> rows = jdbcTemplate.query(sql, rowMapper(includePayload), id); // NOSONAR
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String selectList(final boolean includePayload) {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
sb.append(" o.id, o.purpose, o.provider, o.user_id, o.username, ");
|
||||
sb.append(" u.firstname AS u_firstname, u.lastname AS u_lastname, ");
|
||||
sb.append(" o.mobile_number, o.email_address, o.otp_reference, o.status, o.attempts, ");
|
||||
sb.append(" o.ip_address, o.user_agent, o.error_message, o.expires_at, o.verified_at, ");
|
||||
sb.append(" o.created_at ");
|
||||
if (includePayload) {
|
||||
sb.append(" , o.request_payload, o.response_payload, o.otp_hash ");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private RowMapper<OtpLogData> rowMapper(final boolean includePayload) {
|
||||
return (final ResultSet rs, final int rowNum) -> {
|
||||
final String firstname = rs.getString("u_firstname");
|
||||
final String lastname = rs.getString("u_lastname");
|
||||
final String fullName = composeFullName(firstname, lastname);
|
||||
|
||||
final String provider = rs.getString("provider");
|
||||
final String channel = mapProviderToChannel(provider);
|
||||
final String mobile = rs.getString("mobile_number");
|
||||
final String email = rs.getString("email_address");
|
||||
final String destination = mask(channel, mobile, email);
|
||||
|
||||
final String status = rs.getString("status");
|
||||
final String errorMessage = rs.getString("error_message");
|
||||
final String otpReference = rs.getString("otp_reference");
|
||||
final String message = composeMessage(status, provider, otpReference, errorMessage);
|
||||
|
||||
return OtpLogData.builder()
|
||||
.id(rs.getLong("id"))
|
||||
.userId(getNullableLong(rs, "user_id"))
|
||||
.username(rs.getString("username"))
|
||||
.userFullName(fullName)
|
||||
.channel(channel)
|
||||
.destination(destination)
|
||||
.otpReference(otpReference)
|
||||
.status(status)
|
||||
.purpose(rs.getString("purpose"))
|
||||
.attempts(getNullableInt(rs, "attempts"))
|
||||
.maxAttempts(maxAttempts)
|
||||
.createdOn(toLocalDateTime(rs.getTimestamp("created_at")))
|
||||
.expiresOn(toOffsetDateTime(rs.getTimestamp("expires_at")))
|
||||
.verifiedOn(toOffsetDateTime(rs.getTimestamp("verified_at")))
|
||||
.ipAddress(rs.getString("ip_address"))
|
||||
.userAgent(rs.getString("user_agent"))
|
||||
.message(message)
|
||||
.provider(provider)
|
||||
.requestPayload(includePayload ? rs.getString("request_payload") : null)
|
||||
.responsePayload(includePayload ? rs.getString("response_payload") : null)
|
||||
.otpHash(includePayload ? rs.getString("otp_hash") : null)
|
||||
.build();
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean notBlank(final String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static String mapChannelToProvider(final String channel) {
|
||||
if (channel == null) {
|
||||
return null;
|
||||
}
|
||||
if (CHANNEL_SMS.equalsIgnoreCase(channel)) {
|
||||
return PROVIDER_LINCPAY;
|
||||
}
|
||||
if (CHANNEL_EMAIL.equalsIgnoreCase(channel)) {
|
||||
return PROVIDER_EMAIL;
|
||||
}
|
||||
if (CHANNEL_TOTP.equalsIgnoreCase(channel)) {
|
||||
return CHANNEL_TOTP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String mapProviderToChannel(final String provider) {
|
||||
if (provider == null) {
|
||||
return null;
|
||||
}
|
||||
if (PROVIDER_LINCPAY.equalsIgnoreCase(provider)) {
|
||||
return CHANNEL_SMS;
|
||||
}
|
||||
return provider.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask the recipient for display. Mobile keeps the last 4 digits; email keeps first 2 chars
|
||||
* of the local part and the full domain.
|
||||
*/
|
||||
private static String mask(final String channel, final String mobile, final String email) {
|
||||
if (CHANNEL_SMS.equals(channel) && notBlank(mobile)) {
|
||||
return maskMobile(mobile);
|
||||
}
|
||||
if (CHANNEL_EMAIL.equals(channel) && notBlank(email)) {
|
||||
return maskEmail(email);
|
||||
}
|
||||
if (notBlank(mobile)) {
|
||||
return maskMobile(mobile);
|
||||
}
|
||||
if (notBlank(email)) {
|
||||
return maskEmail(email);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String maskMobile(final String mobile) {
|
||||
if (mobile == null || mobile.length() <= 4) {
|
||||
return mobile;
|
||||
}
|
||||
String prefix = "";
|
||||
String digits = mobile;
|
||||
if (mobile.startsWith("+") && mobile.length() > 3) {
|
||||
// Keep "+91 " (or similar 2-digit country code) as a visible prefix
|
||||
prefix = mobile.substring(0, 3) + " ";
|
||||
digits = mobile.substring(3);
|
||||
}
|
||||
if (digits.length() <= 4) {
|
||||
return prefix + digits;
|
||||
}
|
||||
final String last4 = digits.substring(digits.length() - 4);
|
||||
final int hidden = digits.length() - 4;
|
||||
return prefix + "X".repeat(hidden) + last4;
|
||||
}
|
||||
|
||||
private static String maskEmail(final String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
final int at = email.indexOf('@');
|
||||
if (at < 0) {
|
||||
return email;
|
||||
}
|
||||
final String local = email.substring(0, at);
|
||||
final String domain = email.substring(at);
|
||||
if (local.length() <= 2) {
|
||||
return local + "XXXX" + domain;
|
||||
}
|
||||
return local.substring(0, 2) + "XXXX" + domain;
|
||||
}
|
||||
|
||||
private static String composeMessage(final String status, final String provider, final String otpReference, final String errorMessage) {
|
||||
if (notBlank(errorMessage)) {
|
||||
return errorMessage;
|
||||
}
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
switch (status) {
|
||||
case "VERIFIED":
|
||||
return "OTP verified";
|
||||
case "SENT":
|
||||
final String providerLabel = providerLabel(provider);
|
||||
if (notBlank(otpReference)) {
|
||||
return "OTP delivered (" + providerLabel + " req#" + otpReference + ")";
|
||||
}
|
||||
return "OTP delivered" + (providerLabel != null ? " (" + providerLabel + ")" : "");
|
||||
case "EXPIRED":
|
||||
return "OTP expired";
|
||||
case "FAILED":
|
||||
return "OTP send failed";
|
||||
case "PENDING":
|
||||
return "OTP pending delivery";
|
||||
case "LOCKED":
|
||||
return "OTP locked — too many attempts";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
private static String providerLabel(final String provider) {
|
||||
if (PROVIDER_LINCPAY.equalsIgnoreCase(provider)) {
|
||||
return "LincPay";
|
||||
}
|
||||
if (PROVIDER_EMAIL.equalsIgnoreCase(provider)) {
|
||||
return "Email";
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static Long getNullableLong(final ResultSet rs, final String col) throws SQLException {
|
||||
final long v = rs.getLong(col);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static Integer getNullableInt(final ResultSet rs, final String col) throws SQLException {
|
||||
final int v = rs.getInt(col);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static LocalDateTime toLocalDateTime(final Timestamp ts) {
|
||||
return ts == null ? null : ts.toLocalDateTime();
|
||||
}
|
||||
|
||||
private static OffsetDateTime toOffsetDateTime(final Timestamp ts) {
|
||||
return ts == null ? null : ts.toLocalDateTime().atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private static String composeFullName(final String firstname, final String lastname) {
|
||||
if (firstname == null && lastname == null) {
|
||||
return null;
|
||||
}
|
||||
if (firstname == null) {
|
||||
return lastname;
|
||||
}
|
||||
if (lastname == null) {
|
||||
return firstname;
|
||||
}
|
||||
return (firstname + " " + lastname).trim();
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.security.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
|
||||
import org.apache.fineract.infrastructure.security.data.OTPDeliveryMethod;
|
||||
import org.apache.fineract.infrastructure.security.data.OTPMetadata;
|
||||
import org.apache.fineract.infrastructure.security.data.OTPRequest;
|
||||
import org.apache.fineract.infrastructure.security.domain.OTPRequestRepository;
|
||||
import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
|
||||
import org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository;
|
||||
import org.apache.fineract.infrastructure.security.exception.AccessTokenInvalidIException;
|
||||
import org.apache.fineract.infrastructure.security.exception.OTPDeliveryMethodInvalidException;
|
||||
import org.apache.fineract.infrastructure.security.exception.OTPTokenInvalidException;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
|
||||
import org.apache.fineract.infrastructure.security.otp.service.EmailLoginOtpService;
|
||||
import org.apache.fineract.infrastructure.security.otp.service.LoginOtpService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Replaces {@link TwoFactorServiceImpl} when {@code fineract.security.2fa.enabled=true}. Drives the
|
||||
* existing /twofactor/* endpoints and the {@code Fineract-Platform-TFA-Token} filter chain off the
|
||||
* MFA factors that users register on the Access Control page (table {@code m_user_mfa_factor}),
|
||||
* delivering OTPs via LincPay (SMS) or our branded SMTP template (email) — not via the legacy
|
||||
* SMS-provider / email-template path.
|
||||
*
|
||||
* <p>OTP correlation between {@link #createNewOTPToken} and {@link #createAccessTokenFromOTP} uses
|
||||
* the {@code OTPRequest.token} field as a sentinel for the {@code m_otp_log.id}. Verification then
|
||||
* delegates back to {@link LoginOtpService#verify(Long, String)} or
|
||||
* {@link EmailLoginOtpService#verify(Long, String)} which know how to actually validate the user-
|
||||
* submitted OTP (LincPay round-trip for SMS, local hash compare for email).
|
||||
*/
|
||||
@Service
|
||||
@Primary
|
||||
@ConditionalOnProperty("fineract.security.2fa.enabled")
|
||||
public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(MfaFactorBackedTwoFactorServiceImpl.class);
|
||||
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
private final LoginOtpService smsLoginOtpService;
|
||||
private final EmailLoginOtpService emailLoginOtpService;
|
||||
private final OTPRequestRepository otpRequestRepository;
|
||||
private final TFAccessTokenRepository tfAccessTokenRepository;
|
||||
private final AccessTokenGenerationService accessTokenGenerationService;
|
||||
private final TwoFactorConfigurationService configurationService;
|
||||
|
||||
@Autowired
|
||||
public MfaFactorBackedTwoFactorServiceImpl(final UserMfaFactorRepository userMfaFactorRepository,
|
||||
final LoginOtpService smsLoginOtpService, final EmailLoginOtpService emailLoginOtpService,
|
||||
final OTPRequestRepository otpRequestRepository, final TFAccessTokenRepository tfAccessTokenRepository,
|
||||
final AccessTokenGenerationService accessTokenGenerationService,
|
||||
final TwoFactorConfigurationService configurationService) {
|
||||
this.userMfaFactorRepository = userMfaFactorRepository;
|
||||
this.smsLoginOtpService = smsLoginOtpService;
|
||||
this.emailLoginOtpService = emailLoginOtpService;
|
||||
this.otpRequestRepository = otpRequestRepository;
|
||||
this.tfAccessTokenRepository = tfAccessTokenRepository;
|
||||
this.accessTokenGenerationService = accessTokenGenerationService;
|
||||
this.configurationService = configurationService;
|
||||
}
|
||||
|
||||
// ── Delivery methods ─────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public List<OTPDeliveryMethod> getDeliveryMethodsForUser(final AppUser user) {
|
||||
final List<OTPDeliveryMethod> methods = new ArrayList<>();
|
||||
for (final UserMfaFactor factor : userMfaFactorRepository.findByAppUser_IdAndDisabledOnIsNull(user.getId())) {
|
||||
if (!factor.isVerified()) {
|
||||
continue;
|
||||
}
|
||||
final String name = mapFactorTypeToDeliveryName(factor.getFactorType());
|
||||
if (name == null) {
|
||||
continue; // TOTP / TOKEN not supported by this OTP path
|
||||
}
|
||||
final String target = factor.getDestination();
|
||||
if (target == null || target.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
methods.add(new OTPDeliveryMethod().setName(name).setTarget(target));
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
// ── Generate OTP ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public OTPRequest createNewOTPToken(final AppUser user, final String deliveryMethodName, final boolean extendedAccessToken) {
|
||||
if (deliveryMethodName == null) {
|
||||
throw new OTPDeliveryMethodInvalidException();
|
||||
}
|
||||
final boolean isSms = TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
|
||||
final boolean isEmail = TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
|
||||
if (!isSms && !isEmail) {
|
||||
throw new OTPDeliveryMethodInvalidException();
|
||||
}
|
||||
|
||||
// The user must have a verified factor of the requested type.
|
||||
final MfaFactorType type = isSms ? MfaFactorType.SMS : MfaFactorType.EMAIL;
|
||||
final UserMfaFactor factor = userMfaFactorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
|
||||
.filter(UserMfaFactor::isVerified)
|
||||
.filter(f -> f.getDisabledOn() == null)
|
||||
.orElseThrow(OTPDeliveryMethodInvalidException::new);
|
||||
|
||||
// Send OTP via the appropriate channel (LincPay / SMTP) and persist the audit row.
|
||||
final OtpGenerateResponse result = isSms
|
||||
? smsLoginOtpService.generate(user.getUsername(), null, null)
|
||||
: emailLoginOtpService.generate(user.getUsername(), null, null);
|
||||
|
||||
if (!result.isSuccess() || result.getOtpId() == null) {
|
||||
LOG.warn("MFA OTP delivery failed for user {} via {}: {}", user.getUsername(), deliveryMethodName, result.getMessage());
|
||||
throw new OTPDeliveryMethodInvalidException();
|
||||
}
|
||||
|
||||
// Mirror an OTPRequest into the legacy in-memory store so the existing /twofactor/validate
|
||||
// path and frontend flow continue to work. The "token" field carries the otpId so we can
|
||||
// route verification back to the right OTP service in createAccessTokenFromOTP.
|
||||
final OTPDeliveryMethod method = new OTPDeliveryMethod().setName(deliveryMethodName).setTarget(factor.getDestination());
|
||||
final int liveTime = configurationService.getOTPTokenLiveTime();
|
||||
final OTPRequest request = OTPRequest.create(String.valueOf(result.getOtpId()), liveTime, extendedAccessToken, method);
|
||||
otpRequestRepository.addOTPRequest(user, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// ── Verify OTP and issue TFA access token ────────────────────────────────
|
||||
|
||||
@Override
|
||||
@CachePut(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
|
||||
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#result.token + 'tok')")
|
||||
public TFAccessToken createAccessTokenFromOTP(final AppUser user, final String otpToken) {
|
||||
final OTPRequest otpRequest = otpRequestRepository.getOTPRequestForUser(user);
|
||||
if (otpRequest == null || !otpRequest.isValid()) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
final Long otpId;
|
||||
try {
|
||||
otpId = Long.parseLong(otpRequest.getToken());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
final OTPMetadata metadata = otpRequest.getMetadata();
|
||||
final String channel = metadata != null && metadata.getDeliveryMethod() != null
|
||||
? metadata.getDeliveryMethod().getName()
|
||||
: null;
|
||||
|
||||
final OtpVerifyResponse verifyResult;
|
||||
if (TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
|
||||
verifyResult = smsLoginOtpService.verify(otpId, otpToken);
|
||||
} else if (TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
|
||||
verifyResult = emailLoginOtpService.verify(otpId, otpToken);
|
||||
} else {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
if (verifyResult == null || !verifyResult.isVerified()) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
otpRequestRepository.deleteOTPRequestForUser(user);
|
||||
|
||||
final int liveTime = metadata != null && metadata.isExtendedAccessToken()
|
||||
? configurationService.getAccessTokenExtendedLiveTime()
|
||||
: configurationService.getAccessTokenLiveTime();
|
||||
final String token = accessTokenGenerationService.generateRandomToken();
|
||||
final TFAccessToken accessToken = TFAccessToken.create(token, user, liveTime);
|
||||
tfAccessTokenRepository.save(accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// ── Token validation / lookup / invalidate (same shape as legacy) ────────
|
||||
|
||||
@Override
|
||||
public void validateTwoFactorAccessToken(final AppUser user, final String token) {
|
||||
final TFAccessToken accessToken = fetchAccessTokenForUser(user, token);
|
||||
if (accessToken == null || !accessToken.isValid()) {
|
||||
throw new AccessTokenInvalidIException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
|
||||
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#token + 'tok')")
|
||||
public TFAccessToken fetchAccessTokenForUser(final AppUser user, final String token) {
|
||||
return tfAccessTokenRepository.findByUserAndToken(user, token);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = "userTFAccessToken", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil)"
|
||||
+ ".getTenant().getTenantIdentifier().concat(#user.username).concat(#result.token + 'tok')")
|
||||
public TFAccessToken invalidateAccessToken(final AppUser user, final JsonCommand command) {
|
||||
final String token = command.stringValueOfParameterNamed("token");
|
||||
final TFAccessToken accessToken = fetchAccessTokenForUser(user, token);
|
||||
if (accessToken == null || !accessToken.isValid()) {
|
||||
throw new AccessTokenInvalidIException();
|
||||
}
|
||||
accessToken.setEnabled(false);
|
||||
tfAccessTokenRepository.save(accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String mapFactorTypeToDeliveryName(final MfaFactorType type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (type) {
|
||||
case SMS -> TwoFactorConstants.SMS_DELIVERY_METHOD_NAME;
|
||||
case EMAIL -> TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME;
|
||||
default -> null; // TOTP and TOKEN aren't routed through this OTP path
|
||||
};
|
||||
}
|
||||
}
|
||||
+54
@@ -107,6 +107,24 @@ public class Address extends AbstractPersistableCustom<Long> {
|
||||
@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<Long> {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+9
-5
@@ -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<AddressData> 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
|
||||
}
|
||||
|
||||
+66
@@ -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<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@@ -44,11 +52,41 @@ public class ClientAddress extends AbstractPersistableCustom<Long> {
|
||||
@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<Long> {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+24
@@ -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<Long> {
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@@ -90,6 +98,22 @@ public class ClientCharge extends AbstractPersistableCustom<Long> {
|
||||
@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() {
|
||||
//
|
||||
}
|
||||
|
||||
+24
@@ -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<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@@ -41,6 +49,22 @@ public class ClientChargePaidBy extends AbstractPersistableCustom<Long> {
|
||||
@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() {
|
||||
|
||||
}
|
||||
|
||||
+63
@@ -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<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@@ -109,6 +119,38 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
||||
@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<Long> {
|
||||
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<Long> {
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
+24
@@ -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<Long> {
|
||||
|
||||
@OneToOne(optional = false)
|
||||
@@ -73,6 +81,22 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
||||
@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) {
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
|
||||
* <ul>
|
||||
* <li><b>TT</b> — Customer type code:
|
||||
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
|
||||
* Priority order when multiple flags overlap: <em>Staff > Minor > Corporate > Individual</em>.</li>
|
||||
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
|
||||
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
|
||||
* The generator handles concurrent inserts by retrying on uniqueness collisions
|
||||
* (the caller's outer save is what actually triggers the unique constraint).
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CifNumberGenerator {
|
||||
|
||||
private static final String TYPE_INDIVIDUAL = "11";
|
||||
private static final String TYPE_CORPORATE = "22";
|
||||
private static final String TYPE_MINOR = "33";
|
||||
private static final String TYPE_STAFF = "44";
|
||||
|
||||
private static final int SEQUENCE_WIDTH = 6;
|
||||
private static final int MAX_COLLISION_RETRIES = 5;
|
||||
|
||||
private final ClientRepository clientRepository;
|
||||
|
||||
/**
|
||||
* Generate the next CIF number for the given client. Caller is responsible for persisting.
|
||||
*/
|
||||
public String generate(final Client client) {
|
||||
final String typeCode = resolveTypeCode(client);
|
||||
final int year = resolveYear(client);
|
||||
final String prefix = typeCode + year;
|
||||
|
||||
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
|
||||
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
|
||||
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
|
||||
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
|
||||
return candidate;
|
||||
}
|
||||
nextSequence++;
|
||||
}
|
||||
// Last resort: append client id to guarantee uniqueness if every attempt collided
|
||||
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
|
||||
}
|
||||
|
||||
private String resolveTypeCode(final Client client) {
|
||||
if (client.isStaff()) {
|
||||
return TYPE_STAFF;
|
||||
}
|
||||
if (Boolean.TRUE.equals(client.getIsMinor())) {
|
||||
return TYPE_MINOR;
|
||||
}
|
||||
final Integer legalForm = client.getLegalForm();
|
||||
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
|
||||
return TYPE_CORPORATE;
|
||||
}
|
||||
return TYPE_INDIVIDUAL;
|
||||
}
|
||||
|
||||
private int resolveYear(final Client client) {
|
||||
final LocalDate submittedOn = client.getSubmittedOnDate();
|
||||
return (submittedOn != null ? submittedOn : LocalDate.now()).getYear();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
|
||||
+7
-2
@@ -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)
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -58,9 +58,10 @@ public class ClientSearchService {
|
||||
Optional<ClientTextSearch> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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")
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.api;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.service.SelfUserMfaFactorService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The currently authenticated user manages their own MFA factors. Works for any authenticated
|
||||
* user — admin or self-service alike — because the path is deliberately NOT under {@code /self/};
|
||||
* Fineract's {@code SelfServiceUserAuthorizationManager} denies non-self-service users from
|
||||
* anything under {@code /self/}, which would otherwise lock admins out of their own MFA settings.
|
||||
*
|
||||
* <p>Authentication is the platform's standard user filter; the principal is resolved via
|
||||
* {@code PlatformSecurityContext.authenticatedUser()} inside the service.
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /mfa/factors — list active factors for the current user</li>
|
||||
* <li>POST /mfa/factors — register / upsert a verified factor</li>
|
||||
* <li>DELETE /mfa/factors/{id} — soft-disable a factor (sets disabled_on)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Path("/v1/mfa/factors")
|
||||
@Component
|
||||
@Tag(name = "MFA Factors", description = "Authenticated user's own MFA factors")
|
||||
@RequiredArgsConstructor
|
||||
public class MfaFactorApiResource {
|
||||
|
||||
private final SelfUserMfaFactorService selfUserMfaFactorService;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "List the current user's MFA factors. By default only active factors are "
|
||||
+ "returned; pass includeDisabled=true to also include disabled rows so the UI can "
|
||||
+ "show them with an 'Enable' (re-enroll via OTP) action.")
|
||||
public List<UserMfaFactorData> list(@QueryParam("includeDisabled") @DefaultValue("false") final boolean includeDisabled) {
|
||||
return includeDisabled
|
||||
? selfUserMfaFactorService.listAllForCurrentUser()
|
||||
: selfUserMfaFactorService.listForCurrentUser();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Register or refresh an MFA factor for the current user (upserts by factor type)")
|
||||
public UserMfaFactorData register(final String body) {
|
||||
final JsonObject payload = parse(body);
|
||||
final MfaFactorType type = MfaFactorType.fromString(stringOrNull(payload, "factorType"));
|
||||
final String destination = stringOrNull(payload, "destination");
|
||||
final String secret = stringOrNull(payload, "secret");
|
||||
final String serial = stringOrNull(payload, "serial");
|
||||
final Long otpId = longOrNull(payload, "otpId");
|
||||
return selfUserMfaFactorService.registerForCurrentUser(type, destination, secret, serial, otpId);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{factorId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Soft-disable an MFA factor owned by the current user")
|
||||
public Map<String, Object> disable(@PathParam("factorId") final Long factorId) {
|
||||
final Long id = selfUserMfaFactorService.disableForCurrentUser(factorId);
|
||||
return Map.of("resourceId", id);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final Exception e) {
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject obj, final String key) {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private Long longOrNull(final JsonObject obj, final String key) {
|
||||
try {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
|
||||
} catch (final Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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();
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
|
||||
public interface SelfUserMfaFactorService {
|
||||
|
||||
/** Active (non-disabled) factors for the currently authenticated user. */
|
||||
List<UserMfaFactorData> listForCurrentUser();
|
||||
|
||||
/**
|
||||
* Every factor for the user — active and disabled — newest first. Used by the manage-factors
|
||||
* screen so users can see disabled factors and re-enroll them via OTP.
|
||||
*/
|
||||
List<UserMfaFactorData> listAllForCurrentUser();
|
||||
|
||||
/** Upsert a factor (verified=true, disabled_on cleared). Returns the persisted DTO. */
|
||||
UserMfaFactorData registerForCurrentUser(MfaFactorType type, String destination, String secret, String serial, Long otpId);
|
||||
|
||||
/** Soft-disable the factor (sets disabled_on=NOW). Returns the disabled factor's id. */
|
||||
Long disableForCurrentUser(Long factorId);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.useradministration.mfa.service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.mfa.data.UserMfaFactorData;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
|
||||
import org.apache.fineract.useradministration.mfa.exception.UserMfaFactorNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SelfUserMfaFactorServiceImpl implements SelfUserMfaFactorService {
|
||||
|
||||
private final PlatformSecurityContext securityContext;
|
||||
private final UserMfaFactorRepository factorRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserMfaFactorData> listForCurrentUser() {
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
return factorRepository.findByAppUser_IdAndDisabledOnIsNull(user.getId()).stream()
|
||||
.map(f -> UserMfaFactorData.from(f, false))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserMfaFactorData> listAllForCurrentUser() {
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
return factorRepository.findByAppUser_IdOrderByIdDesc(user.getId()).stream()
|
||||
.map(f -> UserMfaFactorData.from(f, false))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserMfaFactorData registerForCurrentUser(final MfaFactorType type, final String destination, final String secret,
|
||||
final String serial, final Long otpId) {
|
||||
if (type == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.factor.type.required",
|
||||
"factorType is required (one of SMS, EMAIL, TOTP, TOKEN)");
|
||||
}
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
|
||||
.orElseGet(() -> {
|
||||
final UserMfaFactor f = new UserMfaFactor();
|
||||
f.setAppUser(user);
|
||||
f.setFactorType(type);
|
||||
return f;
|
||||
});
|
||||
|
||||
factor.setDestination(destination);
|
||||
factor.setSecret(secret);
|
||||
factor.setSerial(serial);
|
||||
factor.setVerified(true);
|
||||
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
factor.setOtpLogId(otpId);
|
||||
factor.setDisabledOn(null);
|
||||
factor.setDisabledBy(null);
|
||||
|
||||
final UserMfaFactor saved = factorRepository.saveAndFlush(factor);
|
||||
// TOTP secret is returned only on first enrollment so the UI can show the QR; never on subsequent reads.
|
||||
final boolean includeSecret = type == MfaFactorType.TOTP;
|
||||
return UserMfaFactorData.from(saved, includeSecret);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long disableForCurrentUser(final Long factorId) {
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
final UserMfaFactor factor = factorRepository.findById(factorId)
|
||||
.orElseThrow(() -> new UserMfaFactorNotFoundException(factorId));
|
||||
if (!factor.getAppUser().getId().equals(user.getId())) {
|
||||
// Don't reveal which other user owns it — pretend not found.
|
||||
throw new UserMfaFactorNotFoundException(factorId);
|
||||
}
|
||||
factor.setDisabledOn(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
factor.setDisabledBy(user.getId());
|
||||
factorRepository.saveAndFlush(factor);
|
||||
return factor.getId();
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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<ClientData> 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";
|
||||
}
|
||||
|
||||
+5
-4
@@ -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<String> CREATE_SUPPORTED_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
|
||||
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_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<String> UPDATE_SUPPORTED_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, OFFICE_ID, NOT_SELECTED_ROLES, ROLES,
|
||||
SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_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<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
|
||||
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
|
||||
|
||||
|
||||
@@ -289,4 +289,18 @@
|
||||
<include file="parts/0273_add_gl_account_id_to_current_product.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0276_create_current_account_tables.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0277_fix_current_account_audit_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0278_add_soft_delete_to_family_members.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0279_add_soft_delete_columns_client_tables.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0280_add_deletion_audit_and_version_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0281_normalize_audit_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0282_create_m_otp_log.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0283_add_mobile_to_m_appuser.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0284_otp_log_support_email.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0285_create_m_user_mfa_factor.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0286_drop_obsolete_mobile_number_from_m_appuser.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0287_mfa_factor_enforcement_config.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0288_add_disabled_by_to_m_user_mfa_factor.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Soft-delete flags for family members. is_active=true / is_deleted=false by default. -->
|
||||
<changeSet id="0278_add_is_active_is_deleted_to_family_members" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_family_members" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_family_members">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0278_index_family_members_active" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists tableName="m_family_members" indexName="idx_m_family_members_client_active"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_family_members" indexName="idx_m_family_members_client_active">
|
||||
<column name="client_id"/>
|
||||
<column name="is_deleted"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Standardise soft-delete flags across all user-editable client detail tables.
|
||||
Default state for a row: is_active=TRUE, is_deleted=FALSE.
|
||||
On removal/unlink: is_active=FALSE, is_deleted=TRUE.
|
||||
Read services must filter `WHERE is_deleted = false`.
|
||||
|
||||
Tables that already have `is_active` only get `is_deleted`.
|
||||
Tables that have neither get both.
|
||||
|
||||
Skipped intentionally:
|
||||
- m_client (uses status_enum / sub_status state machine)
|
||||
- m_client_transaction (financial; reverse, don't delete)
|
||||
- m_client_kyc_verification_log, m_client_transfer_details, m_client_attendance (immutable audit logs)
|
||||
- m_ckyc_batch (uses its own `status` field)
|
||||
-->
|
||||
|
||||
<!-- 1. Tables that already have is_active — add is_deleted only -->
|
||||
<changeSet id="0279a_is_deleted_client_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_address" columnName="is_deleted"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_address">
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279b_is_deleted_client_nominee" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_nominee" columnName="is_deleted"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_nominee">
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279c_is_deleted_client_charge" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_charge" columnName="is_deleted"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_charge">
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- 2. Tables that have neither — add both -->
|
||||
<changeSet id="0279d_flags_client_non_person" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_non_person" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_non_person">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279e_flags_client_identifier" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_identifier" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_identifier">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279f_flags_client_kyc" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279g_flags_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_address" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_address">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279h_flags_client_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_priority_sector" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_priority_sector">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279i_flags_client_non_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_non_priority_sector" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_non_priority_sector">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279j_flags_client_msme" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_msme" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_msme">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279k_flags_client_mudra_borrower" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_mudra_borrower" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_mudra_borrower">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279l_flags_client_startup_india" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_startup_india" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_startup_india">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279m_flags_client_eou" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_eou" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_eou">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279n_flags_client_charge_paid_by" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_charge_paid_by" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_charge_paid_by">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0279o_flags_client_collateral_management" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_collateral_management" columnName="is_active"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_collateral_management">
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Adds deletion-audit columns and an optimistic-locking version column to the
|
||||
16 client-related tables already covered by 0278/0279 soft-delete columns.
|
||||
|
||||
- deleted_by BIGINT — FK-style m_appuser.id (matching existing created_by/last_modified_by style)
|
||||
- deleted_at TIMESTAMP — when the soft-delete happened
|
||||
- version BIGINT NOT NULL DEFAULT 0 — used by JPA @Version for optimistic locking
|
||||
|
||||
Maker-checker is intentionally NOT modeled per-row. Use the existing
|
||||
m_portfolio_command_source framework + m_permission.can_maker_checker.
|
||||
-->
|
||||
|
||||
<changeSet id="0280a_deletion_audit_family_members" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_family_members" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_family_members">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280b_deletion_audit_client_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_address" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_address">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280c_deletion_audit_client_nominee" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_nominee" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_nominee">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280d_deletion_audit_client_charge" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_charge">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280e_deletion_audit_client_non_person" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_person" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_non_person">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280f_deletion_audit_client_identifier" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_identifier" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_identifier">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280g_deletion_audit_client_kyc" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_kyc" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280h_deletion_audit_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_address" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_address">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280i_deletion_audit_client_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_priority_sector" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_priority_sector">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280j_deletion_audit_client_non_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_priority_sector" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_non_priority_sector">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280k_deletion_audit_client_msme" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_msme" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_msme">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280l_deletion_audit_client_mudra_borrower" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_mudra_borrower" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_mudra_borrower">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280m_deletion_audit_client_startup_india" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_startup_india" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_startup_india">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280n_deletion_audit_client_eou" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_eou" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_eou">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280o_deletion_audit_client_charge_paid_by" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge_paid_by" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_charge_paid_by">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0280p_deletion_audit_client_collateral_management" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_collateral_management" columnName="deleted_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_collateral_management">
|
||||
<column name="deleted_by" type="bigint"/>
|
||||
<column name="deleted_at" type="datetime"/>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Normalises audit column names across the 16 client-related tables to:
|
||||
created_by BIGINT (FK-style → m_appuser.id; matches existing platform pattern)
|
||||
created_at TIMESTAMP
|
||||
modified_by BIGINT (FK-style)
|
||||
modified_on TIMESTAMP
|
||||
|
||||
Group A — already had columns under different names (renamed):
|
||||
last_modified_by → modified_by
|
||||
created_on_utc → created_at
|
||||
last_modified_on_utc → modified_on
|
||||
created_by → unchanged (already correctly named)
|
||||
|
||||
Group B — entities extend AbstractPersistableCustom and had no audit columns;
|
||||
these are added fresh and populated via Spring Data JPA auditing.
|
||||
|
||||
Note: types are BIGINT (user IDs) to stay consistent with the rest of the
|
||||
platform's existing audit. JOIN m_appuser to fetch usernames in reports.
|
||||
-->
|
||||
|
||||
<!-- =========================== Group A: rename =========================== -->
|
||||
|
||||
<changeSet id="0281a_rename_audit_client_nominee" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_nominee" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_nominee" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_nominee" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_nominee" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281b_rename_audit_client_identifier" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_identifier" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_identifier" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_identifier" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_identifier" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281c_rename_audit_client_kyc" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_kyc" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_kyc" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_kyc" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_kyc" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281d_rename_audit_client_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_priority_sector" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_priority_sector" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_priority_sector" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_priority_sector" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281e_rename_audit_client_non_priority_sector" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_non_priority_sector" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_non_priority_sector" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281f_rename_audit_client_msme" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_msme" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_msme" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_msme" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_msme" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281g_rename_audit_client_mudra_borrower" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_mudra_borrower" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_mudra_borrower" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281h_rename_audit_client_startup_india" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_startup_india" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_startup_india" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_startup_india" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_startup_india" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281i_rename_audit_client_eou" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><columnExists tableName="m_client_eou" columnName="last_modified_by"/></preConditions>
|
||||
<renameColumn tableName="m_client_eou" oldColumnName="last_modified_by" newColumnName="modified_by" columnDataType="bigint"/>
|
||||
<renameColumn tableName="m_client_eou" oldColumnName="created_on_utc" newColumnName="created_at" columnDataType="datetime"/>
|
||||
<renameColumn tableName="m_client_eou" oldColumnName="last_modified_on_utc" newColumnName="modified_on" columnDataType="datetime"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- =========================== Group B: add ============================ -->
|
||||
|
||||
<changeSet id="0281j_add_audit_family_members" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_family_members" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_family_members">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281k_add_audit_client_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_address" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_address">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- m_address intentionally skipped: it already has manual audit columns (created_by VARCHAR,
|
||||
created_on DATE, updated_by VARCHAR, updated_on DATE) populated by service code. -->
|
||||
|
||||
<changeSet id="0281m_add_audit_client_charge" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_charge">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281n_add_audit_client_non_person" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_non_person" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_non_person">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281o_add_audit_client_charge_paid_by" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_charge_paid_by" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_charge_paid_by">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0281p_add_audit_client_collateral_management" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><columnExists tableName="m_client_collateral_management" columnName="created_at"/></not></preConditions>
|
||||
<addColumn tableName="m_client_collateral_management">
|
||||
<column name="created_by" type="bigint"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="bigint"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Audit log for OTP generation/verification. One row per OTP issued.
|
||||
Used by login 2FA (LincPay provider) and extensible to other purposes.
|
||||
-->
|
||||
<changeSet id="0282_create_m_otp_log" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_otp_log"/></not></preConditions>
|
||||
<createTable tableName="m_otp_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
|
||||
<column name="purpose" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="provider" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="user_id" type="BIGINT"/>
|
||||
<column name="username" type="VARCHAR(100)"/>
|
||||
<column name="mobile_number" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="otp_reference" type="VARCHAR(100)"/>
|
||||
<column name="otp_hash" type="VARCHAR(255)"/>
|
||||
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="attempts" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="ip_address" type="VARCHAR(45)"/>
|
||||
<column name="user_agent" type="VARCHAR(500)"/>
|
||||
|
||||
<column name="request_payload" type="LONGTEXT"/>
|
||||
<column name="response_payload" type="LONGTEXT"/>
|
||||
<column name="error_message" type="VARCHAR(1000)"/>
|
||||
|
||||
<column name="expires_at" type="datetime"/>
|
||||
<column name="verified_at" type="datetime"/>
|
||||
|
||||
<column name="is_active" type="boolean" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="bigint" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_at" type="datetime"/>
|
||||
<column name="modified_by" type="BIGINT"/>
|
||||
<column name="modified_on" type="datetime"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_user_status">
|
||||
<column name="user_id"/>
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_otp_reference">
|
||||
<column name="otp_reference"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_mobile_purpose">
|
||||
<column name="mobile_number"/>
|
||||
<column name="purpose"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Mobile number on m_appuser — used by login OTP (2FA) lookup. Nullable; populated at user create time. -->
|
||||
<changeSet id="0283_add_mobile_no_to_m_appuser" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_appuser" columnName="mobile_no"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_appuser">
|
||||
<column name="mobile_no" type="VARCHAR(16)"/>
|
||||
</addColumn>
|
||||
<createIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_no">
|
||||
<column name="mobile_no"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Make m_otp_log work for both SMS-OTP and email-OTP. -->
|
||||
|
||||
<changeSet id="0284_otp_log_mobile_number_nullable" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_otp_log" columnName="mobile_number"/>
|
||||
</preConditions>
|
||||
<dropNotNullConstraint tableName="m_otp_log" columnName="mobile_number" columnDataType="VARCHAR(20)"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0284_otp_log_add_email_address" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_otp_log" columnName="email_address"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_otp_log">
|
||||
<column name="email_address" type="VARCHAR(255)"/>
|
||||
</addColumn>
|
||||
<createIndex tableName="m_otp_log" indexName="idx_m_otp_log_email_purpose">
|
||||
<column name="email_address"/>
|
||||
<column name="purpose"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Persists which MFA factors a given AppUser has enrolled and verified.
|
||||
One row per (appuser_id, factor_type). Soft-disable via disabled_on.
|
||||
-->
|
||||
<changeSet id="0285_create_m_user_mfa_factor" author="fineract">
|
||||
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_mfa_factor"/></not></preConditions>
|
||||
<createTable tableName="m_user_mfa_factor">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="appuser_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="factor_type" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="destination" type="VARCHAR(255)"/>
|
||||
<column name="secret" type="VARCHAR(64)"/>
|
||||
<column name="serial" type="VARCHAR(64)"/>
|
||||
<column name="verified" type="boolean" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="verified_on" type="datetime"/>
|
||||
<column name="otp_log_id" type="BIGINT"/>
|
||||
<column name="created_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="disabled_on" type="datetime"/>
|
||||
</createTable>
|
||||
|
||||
<addUniqueConstraint tableName="m_user_mfa_factor" columnNames="appuser_id, factor_type"
|
||||
constraintName="uq_user_factor"/>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="appuser_id"
|
||||
constraintName="fk_mfa_factor_user"
|
||||
referencedTableName="m_appuser" referencedColumnNames="id"/>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="otp_log_id"
|
||||
constraintName="fk_mfa_factor_otp"
|
||||
referencedTableName="m_otp_log" referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="m_user_mfa_factor" indexName="idx_mfa_factor_user_active">
|
||||
<column name="appuser_id"/>
|
||||
<column name="disabled_on"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- Permissions: list, register, disable factor (Self Service flow) -->
|
||||
<changeSet id="0285_seed_user_mfa_permissions" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_USER_MFA_FACTORS'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="special"/>
|
||||
<column name="code" value="READ_USER_MFA_FACTORS"/>
|
||||
<column name="entity_name" value="USER_MFA_FACTOR"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="special"/>
|
||||
<column name="code" value="CREATE_USER_MFA_FACTOR"/>
|
||||
<column name="entity_name" value="USER_MFA_FACTOR"/>
|
||||
<column name="action_name" value="CREATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="special"/>
|
||||
<column name="code" value="DELETE_USER_MFA_FACTOR"/>
|
||||
<column name="entity_name" value="USER_MFA_FACTOR"/>
|
||||
<column name="action_name" value="DELETE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!-- Grant the new permissions to the Self Service User role (id resolved by name to keep portable). -->
|
||||
<changeSet id="0285_grant_mfa_permissions_to_self_service_role" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r, m_permission p
|
||||
WHERE r.name = 'Self Service User'
|
||||
AND p.code IN ('READ_USER_MFA_FACTORS', 'CREATE_USER_MFA_FACTOR', 'DELETE_USER_MFA_FACTOR')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
m_appuser ended up with both `mobile_number` (legacy from an earlier version of 0283) and
|
||||
`mobile_no` (current). The Java code uses `mobile_no` exclusively. This migration:
|
||||
1. Backfills `mobile_no` from `mobile_number` for any row where `mobile_no` is NULL
|
||||
but `mobile_number` has a value, so we don't lose data.
|
||||
2. Drops the now-unused `idx_m_appuser_mobile_number` index.
|
||||
3. Drops the `mobile_number` column.
|
||||
-->
|
||||
|
||||
<changeSet id="0286_backfill_mobile_no_from_mobile_number" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<columnExists tableName="m_appuser" columnName="mobile_number"/>
|
||||
<columnExists tableName="m_appuser" columnName="mobile_no"/>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_appuser
|
||||
SET mobile_no = mobile_number
|
||||
WHERE mobile_no IS NULL
|
||||
AND mobile_number IS NOT NULL
|
||||
AND mobile_number <> ''
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0286_drop_index_idx_m_appuser_mobile_number" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<indexExists tableName="m_appuser" indexName="idx_m_appuser_mobile_number"/>
|
||||
</preConditions>
|
||||
<dropIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_number"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0286_drop_mobile_number_from_m_appuser" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_appuser" columnName="mobile_number"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_appuser" columnName="mobile_number"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Global kill-switch for MFA-factor-backed login enforcement.
|
||||
- DEFAULT: disabled. Existing users are unaffected on first deploy.
|
||||
- When enabled, AuthenticationApiResource will challenge any user that
|
||||
has at least one verified, non-disabled row in m_user_mfa_factor.
|
||||
- Flip enabled=true in c_configuration to roll out; flip back to false
|
||||
to roll back instantly without a redeploy.
|
||||
-->
|
||||
<changeSet id="0287_seed_mfa_factor_enforcement_config" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM c_configuration WHERE name = 'mfa-factor-enforcement'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="c_configuration">
|
||||
<column name="name" value="mfa-factor-enforcement"/>
|
||||
<column name="enabled" valueBoolean="false"/>
|
||||
<column name="is_trap_door" valueBoolean="false"/>
|
||||
<column name="description"
|
||||
value="When enabled, users with at least one verified MFA factor in m_user_mfa_factor are required to complete a 2FA challenge after password authentication."/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Pre-flight: grant BYPASS_TWO_FACTOR to the Super user role so an admin
|
||||
can always log in even if MFA delivery is broken (LincPay outage, SMTP
|
||||
misconfig, etc.). Idempotent — only inserts if missing.
|
||||
-->
|
||||
<changeSet id="0287_grant_bypass_twofactor_to_super_user" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r, m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.code = 'BYPASS_TWO_FACTOR'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Audit symmetry with our other soft-delete/disable patterns: capture WHO disabled the
|
||||
factor, not just WHEN. Useful when admins disable a user's factor (future support) or
|
||||
for compliance trails — paired with the existing disabled_on column.
|
||||
-->
|
||||
<changeSet id="0288_add_disabled_by_to_m_user_mfa_factor" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_user_mfa_factor" columnName="disabled_by"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_user_mfa_factor">
|
||||
<column name="disabled_by" type="BIGINT"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_user_mfa_factor" baseColumnNames="disabled_by"
|
||||
constraintName="fk_mfa_factor_disabled_by"
|
||||
referencedTableName="m_appuser" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Admin-only read access to the m_otp_log audit trail. -->
|
||||
<changeSet id="0289_seed_read_otp_log_permission" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="special"/>
|
||||
<column name="code" value="READ_OTP_LOG"/>
|
||||
<column name="entity_name" value="OTP_LOG"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!-- Grant to Super user so admins can immediately use the new endpoint. -->
|
||||
<changeSet id="0289_grant_read_otp_log_to_super_user" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Super user'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r, m_permission p
|
||||
WHERE r.name = 'Super user'
|
||||
AND p.code = 'READ_OTP_LOG'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Dedicated single-purpose role that grants READ access to the m_otp_log audit trail.
|
||||
Sized small on purpose so admins can attach it to specific users (compliance auditors,
|
||||
bank admins, support staff) without giving them the full Super user privileges.
|
||||
|
||||
The bankadmin assignment is environment-specific data — it's wrapped in a precondition so
|
||||
the migration is a no-op on installs where that user doesn't exist.
|
||||
-->
|
||||
|
||||
<!-- 1) Create the role -->
|
||||
<changeSet id="0290_create_otp_log_auditor_role" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_role">
|
||||
<column name="name" value="OTP Log Auditor"/>
|
||||
<column name="description" value="Read-only access to the OTP audit trail (m_otp_log). Pair with users who need to inspect OTP send/verify history without having Super user rights."/>
|
||||
<column name="is_disabled" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!-- 2) Grant READ_OTP_LOG to the role -->
|
||||
<changeSet id="0290_grant_read_otp_log_to_otp_log_auditor" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM m_role r, m_permission p
|
||||
WHERE r.name = 'OTP Log Auditor'
|
||||
AND p.code = 'READ_OTP_LOG'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- 3) Assign role to 'bankadmin' if that user exists in this environment.
|
||||
This block is intentionally environment-aware — fresh installs without a bankadmin
|
||||
user will skip silently (preCondition onFail=MARK_RAN). -->
|
||||
<changeSet id="0290_assign_otp_log_auditor_to_bankadmin" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'OTP Log Auditor'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_appuser_role (appuser_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM m_appuser u, m_role r
|
||||
WHERE u.username = 'bankadmin'
|
||||
AND r.name = 'OTP Log Auditor'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_appuser_role ur
|
||||
WHERE ur.appuser_id = u.id AND ur.role_id = r.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Direct grant: attach READ_OTP_LOG to every role the 'bankadmin' user already holds.
|
||||
|
||||
Migration 0290 created a dedicated 'OTP Log Auditor' role and linked it to bankadmin,
|
||||
which is the clean approach. This migration is a parallel, belt-and-braces grant that
|
||||
also adds the permission to bankadmin's *existing* roles (Bank Admin, etc.) so the
|
||||
OTP Audit Logs screen works without anyone toggling checkboxes in Manage Roles UI.
|
||||
|
||||
Idempotent via NOT EXISTS — safe to re-run. No-op on installs without a 'bankadmin' user.
|
||||
-->
|
||||
<changeSet id="0291_grant_read_otp_log_to_bankadmin_roles" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_appuser WHERE username = 'bankadmin'</sqlCheck>
|
||||
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OTP_LOG'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT ur.role_id, p.id
|
||||
FROM m_appuser u
|
||||
JOIN m_appuser_role ur ON ur.appuser_id = u.id
|
||||
CROSS JOIN m_permission p
|
||||
WHERE u.username = 'bankadmin'
|
||||
AND p.code = 'READ_OTP_LOG'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_role_permission rp
|
||||
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
|
||||
)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+16
-1
@@ -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<AuthenticatedUserData> 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)) {
|
||||
|
||||
+30
-13
@@ -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<GrantedAuthority> updatedAuthorities = new ArrayList<>(authentication.getAuthorities());
|
||||
updatedAuthorities.add(new SimpleGrantedAuthority("TWOFACTOR_AUTHENTICATED"));
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "CBSnexora — Login OTP (Mobile + Email)",
|
||||
"description": "Send and verify login OTPs via SMS (LincPay) and email. The `Send OTP` requests automatically capture the returned `otpId` into a collection variable so the matching `Verify OTP` request works in a single click.\n\n## Setup\n\n1. Open the collection's **Variables** tab and set:\n - `baseUrl` — e.g. `https://localhost:8443/fineract-provider/api/v1`\n - `tenantId` — e.g. `default`\n - `username` — the user whose registered mobile/email should receive the OTP\n2. Run **Send Mobile OTP** (or **Send Email OTP**), check the SMS/email for the 6-digit code, paste it into the matching **Verify** request's body, and run.\n\n## Endpoints\n\n- `POST /v1/login/otp/generate` — sends OTP via LincPay SMS\n- `POST /v1/login/otp/verify` — verifies the SMS OTP\n- `POST /v1/login/otp/email/generate` — sends OTP via SMTP email (CBSnexora-branded)\n- `POST /v1/login/otp/email/verify` — verifies the email OTP\n\nAll four endpoints are public (no auth header needed). Tenant header is still required.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Mobile OTP (LincPay SMS)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Send Mobile OTP",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"// Capture otpId so the matching Verify request works without copy/paste",
|
||||
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"if (json && json.otpId) {",
|
||||
" pm.collectionVariables.set('mobileOtpId', json.otpId);",
|
||||
" console.log('Saved mobileOtpId =', json.otpId);",
|
||||
"}",
|
||||
"pm.test('success flag is true', function () {",
|
||||
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"username\": \"{{username}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/login/otp/generate",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["login", "otp", "generate"]
|
||||
},
|
||||
"description": "Sends a 6-digit OTP via LincPay SMS to the user's registered mobile.\n\nMobile is fetched server-side from `m_appuser.mobile_no` based on the username — the UI does not need to pass it.\n\nResponse stores `otpId` and `otpReference` in `m_otp_log`. The `otpId` is auto-saved to the `mobileOtpId` collection variable for the verify step."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Verify Mobile OTP",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.test('verified flag is true', function () {",
|
||||
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"otpId\": {{mobileOtpId}},\n \"otp\": \"123456\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/login/otp/verify",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["login", "otp", "verify"]
|
||||
},
|
||||
"description": "Verifies the SMS OTP delivered by LincPay. Replace `123456` with the OTP you received on your phone before sending.\n\nThe `otpId` is auto-populated from the previous `Send Mobile OTP` request via the `mobileOtpId` collection variable.\n\nVerification is delegated to LincPay's `/verify-mobile-otp` endpoint; the audit row in `m_otp_log` is updated with the result."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Email OTP (SMTP)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Send Email OTP",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"if (json && json.otpId) {",
|
||||
" pm.collectionVariables.set('emailOtpId', json.otpId);",
|
||||
" console.log('Saved emailOtpId =', json.otpId);",
|
||||
"}",
|
||||
"pm.test('success flag is true', function () {",
|
||||
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"username\": \"{{username}}\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/login/otp/email/generate",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["login", "otp", "email", "generate"]
|
||||
},
|
||||
"description": "Generates a 6-digit OTP, stores its SHA-256 hash in `m_otp_log`, and emails the plaintext OTP to the user's registered email (`m_appuser.email`) using the CBSnexora-branded HTML template.\n\nThe `otpId` is auto-saved to the `emailOtpId` collection variable for the verify step.\n\n**Pre-flight**: ensure `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` env vars are populated. If SMTP is not configured, the response will be `{\"success\": false, \"message\": \"OTP send failed\"}` and the audit row marked `FAILED`."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Verify Email OTP",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
|
||||
"var json = pm.response.json();",
|
||||
"pm.test('verified flag is true', function () {",
|
||||
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"otpId\": {{emailOtpId}},\n \"otp\": \"123456\"\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/login/otp/email/verify",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["login", "otp", "email", "verify"]
|
||||
},
|
||||
"description": "Verifies the email OTP. Replace `123456` with the code from the email before sending.\n\nVerification is local: the submitted OTP is SHA-256 hashed and compared against the stored hash. On success the audit row is marked `VERIFIED`; failures increment `attempts` and lock at `max-attempts` (default 5)."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "https://localhost:8443/fineract-provider/api/v1",
|
||||
"type": "string",
|
||||
"description": "Fineract API base URL including the version segment (e.g. /api/v1)."
|
||||
},
|
||||
{
|
||||
"key": "tenantId",
|
||||
"value": "default",
|
||||
"type": "string",
|
||||
"description": "Tenant identifier sent in the `Fineract-Platform-TenantId` header."
|
||||
},
|
||||
{
|
||||
"key": "username",
|
||||
"value": "alice",
|
||||
"type": "string",
|
||||
"description": "Username of the AppUser whose registered mobile / email will receive the OTP."
|
||||
},
|
||||
{
|
||||
"key": "mobileOtpId",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"description": "Auto-populated from `Send Mobile OTP` response — used by the verify request."
|
||||
},
|
||||
{
|
||||
"key": "emailOtpId",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"description": "Auto-populated from `Send Email OTP` response — used by the verify request."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user