Add DSC-based MFA: enrollment, login factor, JWS helper, tenant-scoped serving
Backend:
- m_bank_user_dsc + m_dsc_challenge tables (Liquibase 0292/0293/0294)
- DSC enrollment flow: start → JNLP → JWS helper → callback → upsert
- DSC login factor: challenge → sign → verify → claim minted TFA token
- 2FA gate now also accepts an active DSC mapping (AuthenticationApiResource
+ TwoFactorAuthenticationFilter), defensively wrapped so a missing DSC table
doesn't 500 the auth path
- TenantAwareBasicAuthenticationFilter exempts /api/v*/dsc/helper.jar and
strips Authorization header for that path so JWS's raw GET works without
the user seeing a browser Basic-auth prompt
- DSC helper JAR served at /api/v*/dsc/helper.jar from a configurable
filesystem path (default ~/.cbsnexora/dsc-helper.jar)
Java helper JAR (dsc-helper/):
- Standalone Gradle subproject, Java 8 target, fat jar with Gson shaded in
- Cross-platform token picker (Windows Cert Store / ePass2003 / eToken /
ProxKey / Other) — matches the JNLP arg contract emitted by the backend
- Sets Fineract-Platform-TenantId on its callback POST from the JNLP args
- JNLP age check now reads cbsnexora-dsc.jnlp (current name) AND
SignTender.jnlp (legacy) — picks the youngest, never blocks if none exist
Inline KYC upsert in updateClient (mirror of the create-flow path):
- ClientKycWritePlatformService.upsertKycInline() — creates or merges KYC
fields, bumps status to IN_PROGRESS on real changes; resets PAN verification
on PAN edit of a verified record
- ClientWritePlatformServiceJpaRepositoryImpl.updateClient() handles the
kyc:{} sub-object on PUT /clients/{id}, matching createClient
User uniqueness:
- m_appuser.email + m_appuser.mobile_no UNIQUE (Liquibase 0295)
- Pre-flight assertEmailAndMobileUnique() in create/update with self-exclusion
- handleDataIntegrityIssues maps the two new constraints to clean
field-scoped error messages (race-condition safety net)
Email OTP subject line now: "Your OTP for Login - <AppName> (dd-MM-yy-HH:mm)"
where AppName comes from fineract.email.from-name (default "CBSnexora").
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
5e3212dc54
commit
8284851f67
+22
-1
@@ -39,6 +39,7 @@ import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
|
||||
@@ -65,6 +66,7 @@ import org.springframework.stereotype.Component;
|
||||
@Path("/v1/authentication")
|
||||
@Tag(name = "Authentication HTTP Basic", description = "An API capability that allows client applications to verify authentication details using HTTP Basic Authentication.")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthenticationApiResource {
|
||||
|
||||
@Value("${fineract.security.2fa.enabled}")
|
||||
@@ -83,6 +85,7 @@ public class AuthenticationApiResource {
|
||||
private final ClientReadPlatformService clientReadPlatformService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -145,8 +148,26 @@ public class AuthenticationApiResource {
|
||||
// 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();
|
||||
// DSC factor check is wrapped so a misconfigured/missing m_bank_user_dsc table can't
|
||||
// bring down the entire login path. If the lookup throws (table missing, columns
|
||||
// renamed, JDBC down), we log and fall back to "no DSC factor enrolled" — the user
|
||||
// can still authenticate via their OTP factors (or no 2FA if none enrolled).
|
||||
boolean hasActiveDsc = false;
|
||||
if (enforcementEnabled) {
|
||||
try {
|
||||
hasActiveDsc = bankUserDscRepository
|
||||
.existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
|
||||
principal.getId(),
|
||||
org.apache.fineract.useradministration.dsc.domain.CertStatus.ACTIVE,
|
||||
java.time.LocalDate.now());
|
||||
} catch (final Exception e) {
|
||||
log.warn("DSC factor lookup failed for user {} during login — falling back to non-DSC: {}",
|
||||
principal.getUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
final boolean userHasFactors = enforcementEnabled
|
||||
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
|
||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId())
|
||||
|| hasActiveDsc);
|
||||
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
|
||||
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
||||
Long userId = principal.getId();
|
||||
|
||||
+66
@@ -110,6 +110,45 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
|
||||
// in different origin (domain name)
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
// Fully exempt paths: skip tenant resolution AND make Spring Security's
|
||||
// BasicAuthenticationFilter ignore this request.
|
||||
//
|
||||
// Why this is non-trivial: Fineract has TWO basic-auth filters in the chain —
|
||||
// this one (TenantAwareBasicAuthenticationFilter, extends BasicAuthenticationFilter)
|
||||
// AND Spring's built-in BasicAuthenticationFilter that .httpBasic(…) adds on the
|
||||
// SecurityFilterChain. Skipping this filter via filterChain.doFilter(…) hands the
|
||||
// request to the built-in filter, which still sees the UI interceptor's
|
||||
// Authorization header, tries to authenticate it (without tenant context →
|
||||
// DB query fails), and returns 401 + WWW-Authenticate → the user gets the native
|
||||
// browser Basic-auth dialog.
|
||||
//
|
||||
// Fix: wrap the request to strip the Authorization header for exempt paths so
|
||||
// the built-in BasicAuthenticationFilter has nothing to act on. The controller
|
||||
// is permitAll'd anyway, so missing auth is fine — the JAR endpoint just streams
|
||||
// a file from disk.
|
||||
if (isTenantExemptPath(request)) {
|
||||
final jakarta.servlet.http.HttpServletRequestWrapper stripped =
|
||||
new jakarta.servlet.http.HttpServletRequestWrapper(request) {
|
||||
@Override
|
||||
public String getHeader(final String name) {
|
||||
if ("Authorization".equalsIgnoreCase(name)) {
|
||||
return null;
|
||||
}
|
||||
return super.getHeader(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Enumeration<String> getHeaders(final String name) {
|
||||
if ("Authorization".equalsIgnoreCase(name)) {
|
||||
return java.util.Collections.emptyEnumeration();
|
||||
}
|
||||
return super.getHeaders(name);
|
||||
}
|
||||
};
|
||||
filterChain.doFilter(stripped, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestMatcher.matches(request)) {
|
||||
String tenantIdentifier = request.getHeader(TENANT_ID_REQUEST_HEADER);
|
||||
|
||||
@@ -167,6 +206,33 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that don't need a tenant context to serve a response.
|
||||
*
|
||||
* <p>Only {@code /api/v*\/dsc/helper.jar} qualifies: Java Web Start fetches the helper JAR
|
||||
* with a raw GET (no headers, no UI interceptor), and the endpoint just streams a file from
|
||||
* disk — no DB queries, no tenant-scoped state.
|
||||
*
|
||||
* <p>The JNLP endpoint deliberately does NOT qualify: it queries {@code m_dsc_challenge}
|
||||
* which requires tenant context for Fineract's connection routing. JNLP fetches always come
|
||||
* from the SPA's XHR (which sends the tenant header via the auth interceptor), so the normal
|
||||
* tenant resolution path works for them.
|
||||
*
|
||||
* <p>Without this exemption, JWS receives a 400 + {@code WWW-Authenticate: Basic} response
|
||||
* and pops up the native "Authentication Required" dialog.
|
||||
*/
|
||||
private boolean isTenantExemptPath(final HttpServletRequest request) {
|
||||
final String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean exempt = uri.contains("/dsc/helper.jar");
|
||||
if (exempt) {
|
||||
log.debug("Tenant filter: exempting JWS-bound path '{}' from tenant requirement", uri);
|
||||
}
|
||||
return exempt;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult)
|
||||
throws IOException {
|
||||
|
||||
+20
-1
@@ -28,6 +28,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
|
||||
import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken;
|
||||
@@ -56,11 +57,13 @@ import org.springframework.web.filter.GenericFilterBean;
|
||||
* authority regardless of the value of the 'Fineract-Platform-TFA-Token' header.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TwoFactorAuthenticationFilter extends GenericFilterBean {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
|
||||
@@ -90,8 +93,24 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
|
||||
// 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();
|
||||
// DSC factor check wrapped: a misconfigured/missing m_bank_user_dsc must not bring
|
||||
// down every protected request. Fall back to "no DSC factor" if the lookup throws.
|
||||
boolean hasActiveDsc = false;
|
||||
if (enforcementOn) {
|
||||
try {
|
||||
hasActiveDsc = bankUserDscRepository
|
||||
.existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
|
||||
user.getId(),
|
||||
org.apache.fineract.useradministration.dsc.domain.CertStatus.ACTIVE,
|
||||
java.time.LocalDate.now());
|
||||
} catch (final Exception e) {
|
||||
log.warn("DSC factor lookup failed in 2FA filter for user {} — ignoring: {}",
|
||||
user.getUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
final boolean userHasFactors = enforcementOn
|
||||
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
|
||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId())
|
||||
|| hasActiveDsc);
|
||||
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
|
||||
|
||||
if (shouldChallenge) {
|
||||
|
||||
Reference in New Issue
Block a user