DSC as first-class MFA factor + branded helper UI

- m_user_mfa_factor is now the single 2FA gate. DSC enroll callback writes a
  factor row (factor_type=DSC), revoke flips disabled_on when the last cert
  goes. AuthenticationApiResource and TwoFactorAuthenticationFilter drop the
  direct m_bank_user_dsc check so disabling from the UI actually stops the
  challenge at login.
- SecurityConfig exempts /api/*/dsc/** and /api/*/mfa/factors from the
  TWOFACTOR_AUTHENTICATED gate. Without this a user with a verified factor
  can't manage their own factors after they break the device.
- MfaFactorType gains a DSC enum value; SelfUserMfaFactorServiceImpl error
  message lists it.
- dsc-helper: CBSnexora-branded header (logo, title, subtitle, accent palette),
  8-column cert table (Holder Name, Alias, Organisation, DSC Type, Serial,
  Issuer, Valid From, Expires), LdapName-based DN parsing, sign+verify proof-
  of-possession that picks SHA256withRSA / SHA256withECDSA from the key's own
  algorithm (works on RSA, RSA Signing-only, EC), pattern-matched JNLP age
  check, dscType JNLP-arg override with KeyUsage fallback.
- generate-test-dsc.ps1 produces a realistic self-signed DSC with strong key
  protection so dev testing sees real CN/O values and a PIN prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-12 18:05:12 +05:30
co-authored by Claude Opus 4.7
parent 8284851f67
commit e1d7db9b7c
13 changed files with 685 additions and 115 deletions
@@ -85,7 +85,6 @@ 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 })
@@ -148,26 +147,13 @@ 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());
}
}
// m_user_mfa_factor is the single source of truth for "does this user require 2FA?"
// DSC enrolment writes a row there via DscEnrollmentWriteServiceImpl, and revoking the
// DSC or disabling it from the Access Control UI flips disabled_on on the same row.
// This keeps the disable flow honest — flipping a factor off in the UI actually stops
// the challenge at login, instead of being silently overridden by a stale cert mapping.
final boolean userHasFactors = enforcementEnabled
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId())
|| hasActiveDsc);
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
Long userId = principal.getId();
@@ -63,7 +63,6 @@ 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 {
@@ -93,24 +92,12 @@ 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());
}
}
// m_user_mfa_factor is the single source of truth — mirrors AuthenticationApiResource.
// Disabling a factor row from the UI flips disabled_on, which makes this check fall
// through to "no challenge needed". Without that single-table behaviour the filter and
// the login response would disagree, leaving users stuck in a 2FA loop.
final boolean userHasFactors = enforcementOn
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId())
|| hasActiveDsc);
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
if (shouldChallenge) {