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:
co-authored by
Claude Opus 4.7
parent
8284851f67
commit
e1d7db9b7c
+13
-6
@@ -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() {
|
||||
|
||||
+3
-6
@@ -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() {
|
||||
|
||||
+34
@@ -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);
|
||||
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user