merge code feature/Backend-DSC-MFA this branch date 15-05-2026

This commit is contained in:
Nitesh Yadav
2026-05-15 13:08:15 +05:30
32 changed files with 24462 additions and 139 deletions
@@ -370,6 +370,15 @@ public class SecurityConfig {
// /claim hands the minted TFA token back to the browser — must be basic-authed
// so we can prove the caller owns the challenge.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/*/claim")).fullyAuthenticated()
// DSC certificate management (map / list / revoke) and MFA-factor self-management
// are exempt from the TWOFACTOR_AUTHENTICATED gate. Without this exemption the
// user can't manage their own 2FA settings until they've already completed 2FA —
// a chicken-and-egg that locks people out when their device changes or a factor
// breaks. fullyAuthenticated() still requires the user to have passed password
// auth, so an unauthenticated attacker can't touch these endpoints.
.requestMatchers(API_MATCHER.matcher("/api/*/dsc/**")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors/**")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/**"))
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
@@ -424,12 +433,10 @@ public class SecurityConfig {
.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);
// DSC factor: same dependency pickup as AuthorizationServerConfig — the filter's
// "user has any factor" check now also accepts an active DSC mapping.
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
bankUserDscRepository);
// m_user_mfa_factor is the single source of truth for the 2FA gate — DSC enrolment writes
// its own row there via DscEnrollmentWriteServiceImpl, so the filter no longer needs to
// query m_bank_user_dsc directly.
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
}
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -291,12 +291,9 @@ public class AuthorizationServerConfig {
.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);
// DSC factor: the filter's 2FA gate now also accepts an active DSC mapping as a "user has
// a factor" signal. Same Spring context lookup pattern as the other dependencies above.
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
bankUserDscRepository);
// m_user_mfa_factor is the single source of truth — DSC enrolment writes its own row via
// DscEnrollmentWriteServiceImpl, so the filter no longer queries m_bank_user_dsc directly.
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
}
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -21,8 +21,24 @@ package org.apache.fineract.portfolio.client.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientFamilyMembersRepository
extends JpaRepository<ClientFamilyMembers, Long>, JpaSpecificationExecutor<ClientFamilyMembers> {
/**
* Counts non-deleted family members for a client that hold a given relationship code, optionally
* ignoring one specific family member (used at update time so a row doesn't conflict with itself).
* Backs the single-use relationship rule for Mother / Father / Spouse.
*/
@Query("SELECT COUNT(fm) FROM ClientFamilyMembers fm "
+ "WHERE fm.client.id = :clientId "
+ "AND fm.relationship.id = :relationshipId "
+ "AND fm.isDeleted = false "
+ "AND (:excludeFamilyMemberId IS NULL OR fm.id <> :excludeFamilyMemberId)")
long countActiveByClientAndRelationship(@Param("clientId") Long clientId,
@Param("relationshipId") Long relationshipId,
@Param("excludeFamilyMemberId") Long excludeFamilyMemberId);
}
@@ -27,11 +27,15 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.Set;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers;
@@ -47,6 +51,16 @@ import org.springframework.stereotype.Service;
public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamilyMembersWritePlatformService {
private static final Logger LOG = LoggerFactory.getLogger(ClientFamilyMembersWritePlatformServiceImpl.class);
/**
* Relationship labels that may appear at most once per client. Compared case-insensitively
* against the CodeValue label, so admins can rename the values to match local spellings
* without breaking the rule. Common synonyms ({@code husband}/{@code wife}) are treated as
* spouse so the rule isn't defeated by data entry.
*/
private static final Set<String> SINGLE_USE_RELATIONSHIPS = Set.of(
"mother", "father", "spouse", "husband", "wife");
private final PlatformSecurityContext context;
private final CodeValueRepository codeValueRepository;
private final ClientFamilyMembersRepository clientFamilyRepository;
@@ -65,6 +79,38 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
}
/**
* Enforces the "single-use" rule for relationships in {@link #SINGLE_USE_RELATIONSHIPS}.
* Throws {@link PlatformApiDataValidationException} when a duplicate is detected so the UI
* gets a clean validation error in the standard envelope.
*
* @param clientId the parent client
* @param relationship the CodeValue being applied; may be {@code null} (e.g. user left it blank)
* @param excludeMemberId {@code null} on create, the row id on update — prevents a row from
* conflicting with itself when remarks or other non-relationship fields
* change
*/
private void validateRelationshipUniqueness(final Long clientId, final CodeValue relationship,
final Long excludeMemberId) {
if (clientId == null || relationship == null || relationship.getLabel() == null) {
return;
}
final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT);
if (!SINGLE_USE_RELATIONSHIPS.contains(label)) {
return;
}
final long existing = clientFamilyRepository.countActiveByClientAndRelationship(
clientId, relationship.getId(), excludeMemberId);
if (existing > 0) {
final String displayName = relationship.getLabel();
throw new PlatformApiDataValidationException(java.util.List.of(ApiParameterError.parameterError(
"error.msg.client.family.member.relationship.duplicate",
"A family member with relationship '" + displayName + "' already exists for this client. "
+ "Only one " + displayName + " is permitted.",
"relationshipId", relationship.getId())));
}
}
@Override
public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) {
@@ -125,6 +171,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth");
// Single-use relationship guard (Mother / Father / Spouse) — refuse the add before we
// construct the entity so the DB stays consistent and the caller gets a clean validation
// error rather than a generic IO failure.
validateRelationshipUniqueness(clientId, relationship, null);
ClientFamilyMembers clientFamilyMembers = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification,
mobileNumber, age, isDependent, relationship, maritalStatus, gender, dateOfBirth, profession,
nominationType, percentageShare, remarks);
@@ -299,6 +350,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
familyMember.setMarriageAuthority(member.get("marriageAuthority").getAsString());
}
// Single-use relationship guard, evaluated per row so a batch payload with two
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
this.clientFamilyRepository.saveAndFlush(familyMember);
}
@@ -447,6 +502,12 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
}
}
// Single-use relationship guard. Exclude the row being updated so it doesn't conflict with
// itself when only non-relationship fields changed.
final Client ownerForValidation = clientFamilyMember.getClient();
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
clientFamilyMember.getRelationship(), familyMemberId);
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
@@ -150,16 +150,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = kyc.update(command);
if (!changes.isEmpty()) {
// If PAN-related fields changed on a VERIFIED record, reset PAN verification
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (panChanged && currentStatus.isVerified()) {
kyc.resetPanVerification();
changes.put("panVerified", false);
}
// Move to IN_PROGRESS when documents are updated
if (!kyc.getKycStatus().isInProgress()) {
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
}
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
// Handles the common case of editing remarks / risk on a fully-verified KYC.
kyc.tryAutoVerify();
if (kyc.getKycStatus().isVerified()) {
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
}
kycRepositoryWrapper.saveAndFlush(kyc);
}
@@ -972,15 +983,22 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
}
// Same audit semantics as the explicit updateKyc endpoint:
// • PAN-edited on a VERIFIED row → reset PAN verification (forces re-verify)
// • Any change moves status to IN_PROGRESS so the row is no longer mistaken for a
// completed KYC until verify-pan / verify-aadhaar run again.
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
final boolean panChanged = changes.containsKey("panNumber");
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
final boolean gstinChanged = changes.containsKey("gstinNumber");
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
if (currentStatus.isVerified() && panChanged) {
kyc.resetPanVerification();
}
if (!kyc.getKycStatus().isInProgress()) {
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
}
kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
@@ -83,6 +83,11 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
}
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
// ── Linked-nominee uniqueness per (account owner, account type) ──
// Same person can be a nominee on a Savings (SB) AND a Current (CB) account, but not twice
// within the same account type — even across different account numbers of that type.
validateNomineeUniquePerAccountType(clientId, nomineeClientId, accountType, null);
// ── Percentage share validation (per account, not per client) ──
final BigDecimal newShare = command.bigDecimalValueOfParameterNamed("percentageShare");
validatePercentageShareForAccount(accountNo, accountType, null, newShare);
@@ -147,6 +152,8 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
throw new PlatformDataIntegrityException("error.msg.nominee.cannot.be.self",
"Client cannot be their own nominee.", "nomineeClientId");
}
// Re-check uniqueness when the nominee link is being changed on update.
validateNomineeUniquePerAccountType(clientId, nomineeClientId, nominee.getAccountType(), nomineeId);
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
nominee.setNomineeClient(nomineeClient);
}
@@ -269,6 +276,28 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
}
}
/**
* Enforces the rule that the same linked nominee may appear at most once per (account owner,
* account type). Same person CAN still be nominee for the owner's Savings (SB) and Current (CB)
* accounts simultaneously — uniqueness is scoped by accountType, not accountNo.
* {@code excludeNomineeId} is set on update so a row doesn't conflict with itself.
*/
private void validateNomineeUniquePerAccountType(final Long clientId, final Long nomineeClientId,
final String accountType, final Long excludeNomineeId) {
if (clientId == null || nomineeClientId == null || accountType == null || accountType.isBlank()) {
return;
}
final long existing = nomineeRepository.countActiveByClientNomineeAndAccountType(
clientId, nomineeClientId, accountType, excludeNomineeId);
if (existing > 0) {
throw new PlatformDataIntegrityException("error.msg.nominee.already.linked.for.account.type",
"This person is already a nominee for one of the client's "
+ accountType + " accounts. Same nominee can appear only once per account type "
+ "(but is allowed across different account types).",
"nomineeClientId", nomineeClientId);
}
}
/**
* Generates a unique nominee identifier: NOM-{TYPE}{ACCNO}-{SEQ}
* e.g. NOM-SB00123456-01, NOM-CB00123456-02, NOM-LA00123456-01
@@ -317,10 +317,14 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
final String firstname = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
final String middlename = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
final String lastname = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
final String fullname = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
// Title-case names at the boundary so every persistence path (REST, internal,
// bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the
// input. This is the single write seam for new clients — normalising here means we
// never have to re-clean the data downstream (display name, KYC compare, reports).
final String firstname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
final String middlename = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
final String lastname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
final String fullname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
final boolean isStaff = command.booleanPrimitiveValueOfParameterNamed(ClientApiConstants.isStaffParamName);
final LocalDate dataOfBirth = command.localDateValueOfParameterNamed(ClientApiConstants.dateOfBirthParamName);
@@ -672,26 +676,29 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
}
// Title-case incoming names so an update with "ADITYA" or "aditya" still ends up
// stored as "Aditya". Compare the *normalised* value against the persisted one so
// we don't churn the audit log with a no-op change when the only difference is case.
if (command.isChangeInStringParameterNamed(ClientApiConstants.firstnameParamName, clientForUpdate.getFirstname())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
changes.put(ClientApiConstants.firstnameParamName, newValue);
clientForUpdate.setFirstname(StringUtils.defaultIfEmpty(newValue, null));
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.middlenameParamName, clientForUpdate.getMiddlename())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
changes.put(ClientApiConstants.middlenameParamName, newValue);
clientForUpdate.setMiddlename(StringUtils.defaultIfEmpty(newValue, null));
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.lastnameParamName, clientForUpdate.getLastname())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
changes.put(ClientApiConstants.lastnameParamName, newValue);
clientForUpdate.setLastname(StringUtils.defaultIfEmpty(newValue, null));
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.fullnameParamName, clientForUpdate.getFullname())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
changes.put(ClientApiConstants.fullnameParamName, newValue);
clientForUpdate.setFullname(newValue);
}
@@ -1574,4 +1581,38 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
.build();
}
/**
* Title-case a person name. "aditya kumar vyas" / "ADITYA KUMAR VYAS" → "Aditya Kumar Vyas".
* Splits on whitespace, uppercases the first character of each word and lowercases the rest;
* preserves hyphens / apostrophes inside words but does NOT re-capitalise after them (so
* "smith-jones" → "Smith-jones", which keeps the rule simple and predictable for clerks
* verifying KYC names against ID documents). Null / blank → null so we don't accidentally
* persist an empty string in place of an unset field.
*/
private static String toTitleCase(final String raw) {
if (raw == null) {
return null;
}
final String trimmed = raw.trim();
if (trimmed.isEmpty()) {
return null;
}
final String[] parts = trimmed.split("\\s+");
final StringBuilder sb = new StringBuilder(trimmed.length());
for (int i = 0; i < parts.length; i++) {
if (i > 0) {
sb.append(' ');
}
final String p = parts[i];
if (p.isEmpty()) {
continue;
}
sb.append(Character.toUpperCase(p.charAt(0)));
if (p.length() > 1) {
sb.append(p.substring(1).toLowerCase(Locale.ROOT));
}
}
return sb.toString();
}
}
@@ -24,6 +24,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.PagedRequest;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.apache.fineract.portfolio.client.service.search.domain.ClientSearchData;
import org.apache.fineract.portfolio.client.service.search.domain.ClientTextSearch;
import org.apache.fineract.portfolio.client.service.search.mapper.ClientSearchDataMapper;
@@ -59,9 +60,29 @@ public class ClientSearchService {
String requestSearchText = request.map(ClientTextSearch::getText).orElse(null);
String searchText = Objects.toString(requestSearchText, "");
boolean activeOnly = request.map(ClientTextSearch::getActiveOnly).filter(Boolean::booleanValue).isPresent();
Integer legalFormValue = request.map(ClientTextSearch::getLegalFormType).map(ClientSearchService::parseLegalForm).orElse(null);
Pageable pageable = searchRequest.toPageable();
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly).map(clientSearchDataMapper::map);
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue)
.map(clientSearchDataMapper::map);
}
/**
* Maps the request-level legal-form hint to the integer stored on m_client.legal_form_enum.
* Accepts a few aliases callers in the wild use ("INDIVIDUAL", "PERSON", "NON_INDIVIDUAL",
* "ENTITY") and the bare numeric strings. Anything else returns null = no filter, which is the
* safe default — an unrecognised value should not silently drop all clients from results.
*/
private static Integer parseLegalForm(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String v = raw.trim().toUpperCase();
return switch (v) {
case "INDIVIDUAL", "PERSON", "1" -> LegalForm.PERSON.getValue();
case "NON_INDIVIDUAL", "NONINDIVIDUAL", "ENTITY", "2" -> LegalForm.ENTITY.getValue();
default -> null;
};
}
}
@@ -31,4 +31,14 @@ public class ClientTextSearch {
* clients should never appear. Defaults to false to preserve behavior of the general client list.
*/
private Boolean activeOnly;
/**
* Restricts results to a single legal form. Accepts "INDIVIDUAL" (legalFormEnum=1) or
* "NON_INDIVIDUAL" / "ENTITY" (legalFormEnum=2). Used by guardian / nominee / family-member
* link-client pickers, where only natural persons make sense as linkable parties — picking an
* entity as a "guardian" or "spouse" is a data-entry error, so the backend filter is the
* authoritative gate (the SPA also passes the same hint, but a hostile or older client must
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
*/
private String legalFormType;
}
@@ -41,6 +41,9 @@ import org.apache.fineract.useradministration.dsc.exception.DscChallengeInvalidE
import org.apache.fineract.useradministration.dsc.exception.DscChallengeNotFoundException;
import org.apache.fineract.useradministration.dsc.exception.DscSerialAlreadyEnrolledException;
import org.apache.fineract.useradministration.dsc.util.X509CertParser;
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.Value;
@@ -56,6 +59,7 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
private final PlatformSecurityContext securityContext;
private final DscChallengeRepository challengeRepository;
private final BankUserDscRepository dscRepository;
private final UserMfaFactorRepository userMfaFactorRepository;
private final X509CertParser certParser;
private final DscJnlpGenerator jnlpGenerator;
@@ -205,6 +209,10 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
}
final BankUserDsc saved = dscRepository.saveAndFlush(dsc);
// Mirror the mapping into m_user_mfa_factor so the login gate sees DSC as a registered
// factor — same shape SMS/EMAIL enrollment uses via LoginOtpServiceImpl.autoPromoteFactor.
upsertDscMfaFactor(owner, saved);
ch.setStatus(DscChallengeStatus.VERIFIED);
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
ch.setCertSerialReturned(parsed.getSerial());
@@ -240,6 +248,32 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
.build();
}
/**
* Upsert the user's DSC MFA factor on a successful enroll callback. Mirrors
* {@code LoginOtpServiceImpl.autoPromoteFactor} for SMS — the cert proves possession of the
* key, so the factor row is the natural side-effect of that proof. Re-enrolling a new serial
* for the same user refreshes the serial/destination on the same factor row (uniqueness is on
* appuser_id + factor_type, so a user has exactly one DSC factor regardless of how many cert
* mappings they hold).
*/
private void upsertDscMfaFactor(final AppUser user, final BankUserDsc dsc) {
final UserMfaFactor factor = userMfaFactorRepository
.findByAppUser_IdAndFactorType(user.getId(), MfaFactorType.DSC)
.orElseGet(() -> {
final UserMfaFactor f = new UserMfaFactor();
f.setAppUser(user);
f.setFactorType(MfaFactorType.DSC);
return f;
});
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
factor.setSerial(dsc.getDscSerialNo());
factor.setDestination(dsc.getCommonName() != null ? dsc.getCommonName() : dsc.getDscName());
factor.setDisabledOn(null);
factor.setDisabledBy(null);
userMfaFactorRepository.saveAndFlush(factor);
}
private void failChallenge(final DscChallenge ch, final String reason) {
ch.setStatus(DscChallengeStatus.FAILED);
ch.setErrorMessage(reason);
@@ -29,6 +29,8 @@ import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository;
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
import org.apache.fineract.useradministration.dsc.exception.DscNotFoundException;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -42,6 +44,7 @@ public class DscReadPlatformServiceImpl implements DscReadPlatformService {
private final PlatformSecurityContext securityContext;
private final BankUserDscRepository dscRepository;
private final UserMfaFactorRepository userMfaFactorRepository;
@Override
@Transactional(readOnly = true)
@@ -92,6 +95,24 @@ public class DscReadPlatformServiceImpl implements DscReadPlatformService {
d.setDeletedBy(caller.getId());
d.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
dscRepository.saveAndFlush(d);
// A user may hold multiple DSC mappings. Only soft-disable the m_user_mfa_factor row when
// no other ACTIVE+non-deleted cert remains — otherwise the user still has a usable DSC and
// the factor must stay enabled.
final boolean stillHasActiveDsc = !dscRepository
.findByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatus(d.getAppUser().getId(), CertStatus.ACTIVE)
.isEmpty();
if (!stillHasActiveDsc) {
userMfaFactorRepository
.findByAppUser_IdAndFactorType(d.getAppUser().getId(), MfaFactorType.DSC)
.filter(f -> f.getDisabledOn() == null)
.ifPresent(f -> {
f.setDisabledOn(OffsetDateTime.now(ZoneOffset.UTC));
f.setDisabledBy(caller.getId());
userMfaFactorRepository.saveAndFlush(f);
});
}
LOG.info("DSC revoked — dscId={}, user={}, reason={}", dscId, caller.getUsername(), reason);
}
}
@@ -64,7 +64,7 @@ public class SelfUserMfaFactorServiceImpl implements SelfUserMfaFactorService {
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)");
"factorType is required (one of SMS, EMAIL, TOTP, TOKEN, DSC)");
}
final AppUser user = securityContext.authenticatedUser();
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
@@ -1,5 +1,7 @@
#
# 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
@@ -23,7 +25,7 @@ fineract.node-id=${FINERACT_NODE_ID:1}
fineract.security.basicauth.enabled=${FINERACT_SECURITY_BASICAUTH_ENABLED:true}
fineract.security.oauth2.enabled=${FINERACT_SECURITY_OAUTH_ENABLED:false}
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:false}
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:true}
fineract.security.hsts.enabled=${FINERACT_SECURITY_HSTS_ENABLED:false}
#CORS configuration
@@ -44,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:root}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -664,6 +666,7 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
@@ -684,4 +687,3 @@ spring.mail.properties.mail.transport.protocol=smtp
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
@@ -312,4 +312,5 @@
<include file="parts/0293_create_m_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0294_add_tfa_token_to_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0295_unique_email_mobile_on_m_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0296_unique_nominee_per_account_type.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,42 @@
<?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">
<!--
Belt-and-braces DB constraint matching the service-level uniqueness check in
ClientNomineeWritePlatformServiceImpl: the same person may not be linked as a nominee
more than once per (account owner, account type). Soft-deleted rows are excluded via the
WHERE clause so revoke + re-add stays possible.
Partial unique index (Postgres). If the platform ever runs on MariaDB / MySQL the WHERE
clause must be dropped — JpaSpecification fallback in the service will still enforce the
rule but at slightly higher cost.
-->
<changeSet id="0296_unique_nominee_per_account_type" author="fineract" dbms="postgresql">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_client_nominee"/>
</preConditions>
<sql>
CREATE UNIQUE INDEX IF NOT EXISTS uq_client_nominee_per_account_type
ON m_client_nominee (client_id, nominee_client_id, account_type)
WHERE is_deleted = false;
</sql>
<rollback>
<sql>DROP INDEX IF EXISTS uq_client_nominee_per_account_type;</sql>
</rollback>
</changeSet>
</databaseChangeLog>