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
@@ -0,0 +1,113 @@
|
||||
# Generate a realistic self-signed DSC for testing the CBSnexora helper.
|
||||
#
|
||||
# Replaces the UUID-CN bare-bones dev cert with one that has:
|
||||
# - Real-looking subject DN: CN=Test User, O=CBSnexora Pvt Ltd, OU=IT, L=Mumbai, ST=Maharashtra, C=IN
|
||||
# - KeyUsage = DigitalSignature (matches real Indian Class 3 DSCs)
|
||||
# - Strong private-key protection enabled — Windows pops a PIN dialog at creation time, then
|
||||
# prompts you for that PIN on every signing operation (matches the UX of a real USB token).
|
||||
#
|
||||
# Run from PowerShell (no elevation needed; user store only):
|
||||
# .\generate-test-dsc.ps1
|
||||
#
|
||||
# Why certreq + INF instead of New-SelfSignedCertificate:
|
||||
# New-SelfSignedCertificate (Windows PowerShell 5.x) doesn't expose ProtectKey / strong-protection
|
||||
# knobs — it creates a CNG/KSP key that won't prompt. certreq.exe with an INF using the legacy
|
||||
# "Microsoft Enhanced RSA and AES Cryptographic Provider" CSP IS the supported path for forcing
|
||||
# user-confirmation on key access.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== Step 1: Clean up old UUID-CN test certs from Windows-MY ===" -ForegroundColor Cyan
|
||||
$old = Get-ChildItem Cert:\CurrentUser\My | Where-Object {
|
||||
$_.Subject -match "^CN=[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
||||
}
|
||||
if ($old) {
|
||||
Write-Host "Found $($old.Count) old UUID-CN cert(s) - removing:" -ForegroundColor Yellow
|
||||
foreach ($c in $old) {
|
||||
Write-Host " $($c.Thumbprint) $($c.Subject)"
|
||||
Remove-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" -Force
|
||||
}
|
||||
} else {
|
||||
Write-Host "No old UUID-CN test certs in the user store."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Step 2: Build INF for certreq ===" -ForegroundColor Cyan
|
||||
|
||||
# Random container name avoids collisions when the script is re-run before old containers expire.
|
||||
$containerName = "CBSnexora-DSC-Test-$(Get-Random -Maximum 999999)"
|
||||
$inf = @"
|
||||
[Version]
|
||||
Signature = "`$Windows NT`$"
|
||||
|
||||
[NewRequest]
|
||||
Subject = "CN=Test User, OU=IT Department, O=CBSnexora Pvt Ltd, L=Mumbai, ST=Maharashtra, C=IN"
|
||||
KeyAlgorithm = RSA
|
||||
KeyLength = 2048
|
||||
KeyUsage = "CERT_DIGITAL_SIGNATURE_KEY_USAGE"
|
||||
MachineKeySet = false
|
||||
KeyContainer = "$containerName"
|
||||
ProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider"
|
||||
KeySpec = AT_SIGNATURE
|
||||
ProtectKey = true
|
||||
SMIME = false
|
||||
RequestType = Cert
|
||||
Exportable = false
|
||||
HashAlgorithm = SHA256
|
||||
ValidityPeriod = Years
|
||||
ValidityPeriodUnits = 2
|
||||
|
||||
[Extensions]
|
||||
2.5.29.37 = "{text}1.3.6.1.5.5.7.3.4"
|
||||
"@
|
||||
|
||||
$infPath = Join-Path $env:TEMP "cbsnexora-test-dsc.inf"
|
||||
$cerPath = Join-Path $env:TEMP "cbsnexora-test-dsc.cer"
|
||||
$inf | Out-File -FilePath $infPath -Encoding ASCII -Force
|
||||
Write-Host "INF written to $infPath"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Step 3: certreq -new ===" -ForegroundColor Cyan
|
||||
Write-Host "WINDOWS WILL POP A DIALOG asking 'Creating an RSA exchange key':" -ForegroundColor Yellow
|
||||
Write-Host " -> Click 'Set Security Level...'" -ForegroundColor Yellow
|
||||
Write-Host " -> Select 'High'" -ForegroundColor Yellow
|
||||
Write-Host " -> Click 'Next', then set a PIN you'll remember" -ForegroundColor Yellow
|
||||
Write-Host " -> Click 'Finish' then 'OK'" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
& certreq.exe -new $infPath $cerPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "certreq -new failed with exit code $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Step 4: New cert summary ===" -ForegroundColor Cyan
|
||||
$new = Get-ChildItem Cert:\CurrentUser\My | Where-Object {
|
||||
$_.Subject -like "*CN=Test User*"
|
||||
} | Sort-Object NotBefore -Descending | Select-Object -First 1
|
||||
|
||||
if ($new) {
|
||||
Write-Host "Subject: $($new.Subject)" -ForegroundColor Green
|
||||
Write-Host "Issuer: $($new.Issuer)"
|
||||
Write-Host "Serial: $($new.SerialNumber)"
|
||||
Write-Host "Thumbprint: $($new.Thumbprint)"
|
||||
Write-Host "Valid From: $($new.NotBefore)"
|
||||
Write-Host "Valid Until: $($new.NotAfter)"
|
||||
Write-Host "KeyUsage: $($new.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' } | ForEach-Object { $_.KeyUsages })"
|
||||
} else {
|
||||
Write-Warning "Cert created but not findable - check certmgr.msc -> Personal -> Certificates"
|
||||
}
|
||||
|
||||
# Cleanup temp files
|
||||
Remove-Item $infPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $cerPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Done ===" -ForegroundColor Green
|
||||
Write-Host "Next:"
|
||||
Write-Host " 1. Clear JWS cache:"
|
||||
Write-Host " Remove-Item -Recurse -Force `"`$env:USERPROFILE\.cache\icedtea-web`""
|
||||
Write-Host " 2. Trigger DSC enroll from the web UI."
|
||||
Write-Host " 3. Pick 'Test User' from the cert table - now shows real Holder Name and Organisation."
|
||||
Write-Host " 4. Windows pops the PIN prompt; enter the PIN you set above. Helper proceeds to enroll."
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
package com.cbsnexora.dsc;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Font;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Image;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Window;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSeparator;
|
||||
import javax.swing.SwingConstants;
|
||||
|
||||
/**
|
||||
* Shared branding chrome for every CBSnexora DSC helper window — logo header bar, primary/secondary
|
||||
* button styling, palette constants. Centralised so the look is identical across the token picker
|
||||
* and the certificate selection screen, and so a future logo swap is one file change.
|
||||
*/
|
||||
final class Branding {
|
||||
|
||||
/** Brand colours sampled from cbsnexora-logo.png. */
|
||||
static final Color ACCENT_BLUE = new Color(0x2BAEEC);
|
||||
static final Color ACCENT_BLUE_DARK = new Color(0x1E8FC4);
|
||||
static final Color TITLE_DARK = new Color(0x1F2D3D);
|
||||
static final Color SUBTITLE_GREY = new Color(0x6B7280);
|
||||
static final Color HEADER_BG = Color.WHITE;
|
||||
static final Color BODY_BG = new Color(0xF7F9FC);
|
||||
static final Color DIVIDER = new Color(0xE2E8F0);
|
||||
static final Color BUTTON_NEUTRAL_BG = new Color(0xF1F5F9);
|
||||
static final Color BUTTON_NEUTRAL_FG = new Color(0x334155);
|
||||
|
||||
private static final String LOGO_RESOURCE = "/cbsnexora-logo.png";
|
||||
private static final int HEADER_LOGO_HEIGHT = 36;
|
||||
|
||||
private Branding() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Header bar: logo on the left, title + subtitle in the middle, a thin divider at the bottom.
|
||||
* Title falls back to a plain text wordmark if the logo resource can't be loaded — keeps the
|
||||
* UI usable on a stripped-down JRE that didn't bundle the resources jar entry for some reason.
|
||||
*/
|
||||
static JPanel buildHeader(final String title, final String subtitle) {
|
||||
final JPanel header = new JPanel(new BorderLayout());
|
||||
header.setBackground(HEADER_BG);
|
||||
header.setBorder(BorderFactory.createEmptyBorder(14, 18, 14, 18));
|
||||
|
||||
final JPanel row = new JPanel(new GridBagLayout());
|
||||
row.setOpaque(false);
|
||||
final GridBagConstraints g = new GridBagConstraints();
|
||||
g.gridy = 0;
|
||||
g.anchor = GridBagConstraints.WEST;
|
||||
g.insets = new Insets(0, 0, 0, 14);
|
||||
|
||||
final JLabel logo = loadLogo();
|
||||
if (logo != null) {
|
||||
g.gridx = 0;
|
||||
row.add(logo, g);
|
||||
}
|
||||
|
||||
final JPanel text = new JPanel();
|
||||
text.setOpaque(false);
|
||||
text.setLayout(new javax.swing.BoxLayout(text, javax.swing.BoxLayout.Y_AXIS));
|
||||
|
||||
final JLabel titleLabel = new JLabel(title != null ? title : "CBSnexora");
|
||||
titleLabel.setForeground(TITLE_DARK);
|
||||
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 16f));
|
||||
titleLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||
text.add(titleLabel);
|
||||
|
||||
if (subtitle != null && !subtitle.isEmpty()) {
|
||||
final JLabel sub = new JLabel(subtitle);
|
||||
sub.setForeground(SUBTITLE_GREY);
|
||||
sub.setFont(sub.getFont().deriveFont(Font.PLAIN, 12f));
|
||||
sub.setAlignmentX(Component.LEFT_ALIGNMENT);
|
||||
sub.setBorder(BorderFactory.createEmptyBorder(2, 0, 0, 0));
|
||||
text.add(sub);
|
||||
}
|
||||
|
||||
g.gridx = 1;
|
||||
g.insets = new Insets(0, 0, 0, 0);
|
||||
g.weightx = 1.0;
|
||||
g.fill = GridBagConstraints.HORIZONTAL;
|
||||
row.add(text, g);
|
||||
|
||||
header.add(row, BorderLayout.CENTER);
|
||||
|
||||
final JSeparator divider = new JSeparator(SwingConstants.HORIZONTAL);
|
||||
divider.setForeground(DIVIDER);
|
||||
divider.setBackground(DIVIDER);
|
||||
header.add(divider, BorderLayout.SOUTH);
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Primary CTA — solid accent fill, white text. */
|
||||
static JButton primaryButton(final String text) {
|
||||
final JButton b = new JButton(text);
|
||||
b.setBackground(ACCENT_BLUE);
|
||||
b.setForeground(Color.WHITE);
|
||||
b.setFocusPainted(false);
|
||||
b.setBorderPainted(false);
|
||||
b.setOpaque(true);
|
||||
b.setFont(b.getFont().deriveFont(Font.BOLD, 13f));
|
||||
b.setBorder(BorderFactory.createEmptyBorder(8, 18, 8, 18));
|
||||
b.addPropertyChangeListener("enabled", e -> {
|
||||
b.setBackground(b.isEnabled() ? ACCENT_BLUE : ACCENT_BLUE_DARK.darker());
|
||||
b.setForeground(b.isEnabled() ? Color.WHITE : new Color(0xD0D5DB));
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Secondary button — light fill, slate text. Used for Cancel and the like. */
|
||||
static JButton secondaryButton(final String text) {
|
||||
final JButton b = new JButton(text);
|
||||
b.setBackground(BUTTON_NEUTRAL_BG);
|
||||
b.setForeground(BUTTON_NEUTRAL_FG);
|
||||
b.setFocusPainted(false);
|
||||
b.setBorderPainted(false);
|
||||
b.setOpaque(true);
|
||||
b.setFont(b.getFont().deriveFont(Font.PLAIN, 13f));
|
||||
b.setBorder(BorderFactory.createEmptyBorder(8, 18, 8, 18));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Set the same logo as the window icon so the taskbar / titlebar stays branded. */
|
||||
static void applyWindowIcon(final Window window) {
|
||||
final URL url = Branding.class.getResource(LOGO_RESOURCE);
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.setIconImage(ImageIO.read(url));
|
||||
} catch (final IOException ignored) {
|
||||
// Non-fatal — falls back to the JVM default icon.
|
||||
}
|
||||
}
|
||||
|
||||
private static JLabel loadLogo() {
|
||||
final URL url = Branding.class.getResource(LOGO_RESOURCE);
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final Image raw = ImageIO.read(url);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
// Scale to a fixed header height preserving aspect ratio. SCALE_SMOOTH for the text in
|
||||
// the wordmark — the bitmap render is otherwise legibly poor at small heights.
|
||||
final int targetH = HEADER_LOGO_HEIGHT;
|
||||
final int targetW = Math.max(1, raw.getWidth(null) * targetH / Math.max(1, raw.getHeight(null)));
|
||||
final Image scaled = raw.getScaledInstance(targetW, targetH, Image.SCALE_SMOOTH);
|
||||
return new JLabel(new ImageIcon(scaled));
|
||||
} catch (final IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,12 +142,21 @@ public final class CbsnexoraDscHelper {
|
||||
// ── Certificate selection UI (shared between Windows and PKCS11) ────────
|
||||
|
||||
private static void showCertSelectionWindow() {
|
||||
final JFrame frame = new JFrame(cfg.title != null ? cfg.title : "Select Digital Signature Certificate");
|
||||
final String windowTitle = cfg.title != null ? cfg.title : "Select Digital Signature Certificate";
|
||||
final JFrame frame = new JFrame("CBSnexora — " + windowTitle);
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setLayout(new BorderLayout(10, 10));
|
||||
Branding.applyWindowIcon(frame);
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.getContentPane().setBackground(Branding.BODY_BG);
|
||||
|
||||
final String subtitle = "ENROLL".equals(cfg.mode)
|
||||
? "Pick the certificate to map to your CBSnexora account."
|
||||
: "Pick the certificate to sign the login challenge.";
|
||||
frame.add(Branding.buildHeader(windowTitle, subtitle), BorderLayout.NORTH);
|
||||
|
||||
final DefaultTableModel model = new DefaultTableModel(
|
||||
new Object[] { "Common Name", "Serial No", "Issuer", "Expires" }, 0) {
|
||||
new Object[] { "Holder Name", "Alias", "Organisation", "DSC Type", "Serial No",
|
||||
"Issuer (CA)", "Valid From", "Expires" }, 0) {
|
||||
@Override
|
||||
public boolean isCellEditable(final int row, final int column) {
|
||||
return false;
|
||||
@@ -155,10 +164,20 @@ public final class CbsnexoraDscHelper {
|
||||
};
|
||||
final JTable table = new JTable(model);
|
||||
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
table.getColumnModel().getColumn(0).setPreferredWidth(180);
|
||||
table.getColumnModel().getColumn(1).setPreferredWidth(180);
|
||||
table.getColumnModel().getColumn(2).setPreferredWidth(260);
|
||||
table.getColumnModel().getColumn(3).setPreferredWidth(100);
|
||||
table.setRowHeight(26);
|
||||
table.setGridColor(Branding.DIVIDER);
|
||||
table.setShowVerticalLines(false);
|
||||
table.getTableHeader().setFont(table.getTableHeader().getFont().deriveFont(java.awt.Font.BOLD, 12f));
|
||||
table.getTableHeader().setBackground(Branding.HEADER_BG);
|
||||
table.getTableHeader().setForeground(Branding.TITLE_DARK);
|
||||
// Holder Name / Alias / Organisation / DSC Type / Serial / Issuer / Valid From / Expires.
|
||||
// Alias is the keystore label (Windows friendly name or auto-generated handle) — useful when
|
||||
// the CN is opaque (UUID test cert, hashed-CN tokens) but the user has set a recognisable
|
||||
// friendly name in certmgr.msc.
|
||||
final int[] widths = { 160, 130, 160, 110, 150, 210, 100, 100 };
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
table.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
|
||||
}
|
||||
|
||||
final SimpleDateFormat dateFmt = new SimpleDateFormat("dd-MMM-yyyy");
|
||||
// Fully qualified — java.awt.List exists too and collides with the wildcard import.
|
||||
@@ -181,10 +200,15 @@ public final class CbsnexoraDscHelper {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subjectDn = x.getSubjectX500Principal().getName();
|
||||
model.addRow(new Object[] {
|
||||
extractCN(x.getSubjectX500Principal().getName()),
|
||||
extractCN(subjectDn),
|
||||
alias,
|
||||
extractRdn(subjectDn, "O"),
|
||||
extractDscType(x),
|
||||
serial,
|
||||
x.getIssuerX500Principal().getName(),
|
||||
extractCN(x.getIssuerX500Principal().getName()),
|
||||
dateFmt.format(x.getNotBefore()),
|
||||
dateFmt.format(x.getNotAfter())
|
||||
});
|
||||
aliasList.add(alias);
|
||||
@@ -202,11 +226,12 @@ public final class CbsnexoraDscHelper {
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
final JButton proceed = new JButton(cfg.mode.equals("ENROLL") ? "Map Selected DSC" : "Sign with Selected DSC");
|
||||
final JButton proceed = Branding.primaryButton(
|
||||
cfg.mode.equals("ENROLL") ? "Map Selected DSC" : "Sign with Selected DSC");
|
||||
proceed.setEnabled(false);
|
||||
table.getSelectionModel().addListSelectionListener(e -> proceed.setEnabled(table.getSelectedRow() >= 0));
|
||||
|
||||
final JButton cancel = new JButton("Cancel");
|
||||
final JButton cancel = Branding.secondaryButton("Cancel");
|
||||
cancel.addActionListener(e -> System.exit(0));
|
||||
|
||||
proceed.addActionListener(e -> {
|
||||
@@ -234,13 +259,20 @@ public final class CbsnexoraDscHelper {
|
||||
}
|
||||
});
|
||||
|
||||
final JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 10));
|
||||
final JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 14));
|
||||
buttons.setBackground(Branding.BODY_BG);
|
||||
buttons.setBorder(javax.swing.BorderFactory.createEmptyBorder(0, 18, 10, 18));
|
||||
buttons.add(cancel);
|
||||
buttons.add(proceed);
|
||||
|
||||
frame.add(new JScrollPane(table), BorderLayout.CENTER);
|
||||
final JScrollPane scroll = new JScrollPane(table);
|
||||
scroll.getViewport().setBackground(Color.WHITE);
|
||||
scroll.setBorder(javax.swing.BorderFactory.createCompoundBorder(
|
||||
javax.swing.BorderFactory.createEmptyBorder(12, 18, 6, 18),
|
||||
javax.swing.BorderFactory.createLineBorder(Branding.DIVIDER)));
|
||||
frame.add(scroll, BorderLayout.CENTER);
|
||||
frame.add(buttons, BorderLayout.SOUTH);
|
||||
frame.setSize(880, 420);
|
||||
frame.setSize(1220, 480);
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setVisible(true);
|
||||
}
|
||||
@@ -249,14 +281,77 @@ public final class CbsnexoraDscHelper {
|
||||
|
||||
private static void performEnroll(final String alias) throws Exception {
|
||||
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
||||
|
||||
// Proof-of-possession: sign a 32-byte random nonce with the private key and verify with
|
||||
// the cert's public key. Two effects in one call —
|
||||
// 1. forces the PKCS11 PIN dialog (initSign needs the private key, triggers the callback)
|
||||
// 2. proves the user actually has the matching private key, not just a readable cert
|
||||
//
|
||||
// Signing (not RSA encrypt/decrypt) is used because it works for every cert we care about:
|
||||
// - RSA Signing-only certs (Indian Class 3 DSCs — most common case)
|
||||
// - RSA Signing+Encryption certs
|
||||
// - EC / ECDSA certs (newer DSCs, test certs)
|
||||
// The encrypt/decrypt approach used by the legacy MultiTenderActionSign fails on Signing-
|
||||
// only certs (no keyEncipherment KeyUsage) and on EC keys (no RSA cipher support).
|
||||
proveKeyPossessionViaSignature(alias, cert);
|
||||
|
||||
final String certB64 = Base64.getEncoder().encodeToString(cert.getEncoded());
|
||||
final Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("certB64", certB64);
|
||||
body.put("dscName", extractCN(cert.getSubjectX500Principal().getName()));
|
||||
body.put("dscType", "SIGNING");
|
||||
body.put("dscType", resolveDscTypeForBackend(cert));
|
||||
postJson(cfg.callbackUrl, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a random nonce with the alias's private key and verifies it with the cert's public
|
||||
* key. Chooses the signature algorithm from the key's algorithm — SHA256withRSA for RSA,
|
||||
* SHA256withECDSA for EC. The {@code initSign} call cascades into the PKCS11
|
||||
* {@link SwingPasswordCallbackHandler}, which is what shows the PIN dialog at enroll time.
|
||||
*/
|
||||
private static void proveKeyPossessionViaSignature(final String alias, final X509Certificate cert)
|
||||
throws GeneralSecurityException {
|
||||
final java.security.PrivateKey privateKey = (java.security.PrivateKey) keyStore.getKey(alias, null);
|
||||
if (privateKey == null) {
|
||||
throw new GeneralSecurityException("Private key for alias " + alias + " is not accessible");
|
||||
}
|
||||
final String sigAlgo = signatureAlgorithmFor(cert.getPublicKey().getAlgorithm());
|
||||
final byte[] nonce = new byte[32];
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
|
||||
final Signature signer = Signature.getInstance(sigAlgo);
|
||||
signer.initSign(privateKey); // PIN prompt happens here on PKCS11
|
||||
signer.update(nonce);
|
||||
final byte[] signature = signer.sign();
|
||||
|
||||
final Signature verifier = Signature.getInstance(sigAlgo);
|
||||
verifier.initVerify(cert.getPublicKey());
|
||||
verifier.update(nonce);
|
||||
if (!verifier.verify(signature)) {
|
||||
throw new GeneralSecurityException(
|
||||
"Cert/key pair verification failed — the certificate's public key did not verify "
|
||||
+ "the signature produced by the private key on the token.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a public key's algorithm name to the SHA-256 signature variant that key supports. */
|
||||
private static String signatureAlgorithmFor(final String keyAlgorithm) throws GeneralSecurityException {
|
||||
if (keyAlgorithm == null) {
|
||||
throw new GeneralSecurityException("Certificate public key has no algorithm identifier");
|
||||
}
|
||||
final String normalised = keyAlgorithm.toUpperCase(Locale.ROOT);
|
||||
if ("RSA".equals(normalised)) {
|
||||
return "SHA256withRSA";
|
||||
}
|
||||
if ("EC".equals(normalised) || "ECDSA".equals(normalised)) {
|
||||
return "SHA256withECDSA";
|
||||
}
|
||||
if ("DSA".equals(normalised)) {
|
||||
return "SHA256withDSA";
|
||||
}
|
||||
throw new GeneralSecurityException("Unsupported key algorithm: " + keyAlgorithm);
|
||||
}
|
||||
|
||||
// ── SIGN_CHALLENGE: RSA-SHA256(challenge bytes) → POST signature ────────
|
||||
|
||||
private static void performSignChallenge(final String alias) throws Exception {
|
||||
@@ -324,31 +419,47 @@ public final class CbsnexoraDscHelper {
|
||||
// ── JNLP age check — same shape as your existing tender helper ──────────
|
||||
|
||||
private static boolean validateJnlpAge() {
|
||||
// Look for whatever filename our UI uses today AND the legacy name. If any candidate
|
||||
// exists and is recent, accept. If none exist, accept (some browsers stream JNLP directly
|
||||
// to JWS without saving). We only fail if a candidate exists AND is too old AND no fresh
|
||||
// candidate is present — the server-side challenge TTL is the real authority on staleness.
|
||||
// Look for our JNLP in Downloads — and crucially, match browser-renamed copies too
|
||||
// ("cbsnexora-dsc (1).jnlp", "cbsnexora-dsc (2).jnlp", ...). Browsers add a numeric suffix
|
||||
// when a file by the same name already exists, so a strict exact-name match would pick up
|
||||
// the original (stale) download and ignore the freshly-saved one.
|
||||
//
|
||||
// Decision rule: take the youngest matching JNLP across all prefixes. Accept if it's
|
||||
// within MAX_LINK_AGE_MINUTES, else fail. If no JNLP on disk at all, accept (JWS may have
|
||||
// streamed it directly without saving). The server-side challenge TTL is the real
|
||||
// authority on staleness — this is a fast pre-flight to avoid prompting for the PIN on
|
||||
// a link the server has already expired.
|
||||
try {
|
||||
final String[] candidates = { "cbsnexora-dsc.jnlp", "SignTender.jnlp" };
|
||||
final String[] prefixes = { "cbsnexora-dsc", "SignTender" };
|
||||
final String userProfile = System.getenv("USERPROFILE");
|
||||
final String downloads = (userProfile != null ? userProfile : System.getProperty("user.home"))
|
||||
+ File.separator + "Downloads";
|
||||
boolean anyExist = false;
|
||||
long youngestMinutes = Long.MAX_VALUE;
|
||||
for (final String name : candidates) {
|
||||
final File f = new File(downloads, name);
|
||||
if (f.exists()) {
|
||||
anyExist = true;
|
||||
final long m = (System.currentTimeMillis() - f.lastModified()) / 60_000L;
|
||||
if (m < youngestMinutes) {
|
||||
youngestMinutes = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No JNLP on disk → JWS likely streamed it directly. Don't block.
|
||||
if (!anyExist) {
|
||||
final File dir = new File(downloads);
|
||||
if (!dir.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
final File[] candidates = dir.listFiles((d, name) -> {
|
||||
if (name == null || !name.toLowerCase(Locale.ROOT).endsWith(".jnlp")) {
|
||||
return false;
|
||||
}
|
||||
final String base = name.toLowerCase(Locale.ROOT);
|
||||
for (final String prefix : prefixes) {
|
||||
if (base.startsWith(prefix.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (candidates == null || candidates.length == 0) {
|
||||
return true; // no JNLP on disk → JWS likely streamed it
|
||||
}
|
||||
long youngestMs = Long.MIN_VALUE;
|
||||
for (final File f : candidates) {
|
||||
if (f.lastModified() > youngestMs) {
|
||||
youngestMs = f.lastModified();
|
||||
}
|
||||
}
|
||||
final long youngestMinutes = (System.currentTimeMillis() - youngestMs) / 60_000L;
|
||||
return youngestMinutes <= MAX_LINK_AGE_MINUTES;
|
||||
} catch (final Exception ignored) {
|
||||
return true; // never block on env weirdness
|
||||
@@ -358,16 +469,104 @@ public final class CbsnexoraDscHelper {
|
||||
// ── Misc ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static String extractCN(final String dn) {
|
||||
if (dn == null) {
|
||||
return extractRdn(dn, "CN");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a single RDN value (CN, O, OU…) out of an X.500 distinguished name string. Uses
|
||||
* {@link javax.naming.ldap.LdapName} so escaped commas inside values (e.g.
|
||||
* {@code CN=Smith\, John, O=Foo}) parse correctly — the legacy split-on-comma approach
|
||||
* would have returned "Smith\\" in that case. Falls back to the simple parser only if
|
||||
* LdapName rejects the string.
|
||||
*/
|
||||
private static String extractRdn(final String dn, final String key) {
|
||||
if (dn == null || dn.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
final javax.naming.ldap.LdapName ldap = new javax.naming.ldap.LdapName(dn);
|
||||
for (final javax.naming.ldap.Rdn rdn : ldap.getRdns()) {
|
||||
if (key.equalsIgnoreCase(rdn.getType())) {
|
||||
final Object value = rdn.getValue();
|
||||
return value != null ? value.toString() : "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
} catch (final javax.naming.InvalidNameException ignored) {
|
||||
// Fall back to a permissive comma-split parse — covers malformed-but-readable DNs.
|
||||
for (final String part : dn.split(",")) {
|
||||
final String[] kv = part.trim().split("=", 2);
|
||||
if (kv.length == 2 && "CN".equalsIgnoreCase(kv[0].trim())) {
|
||||
if (kv.length == 2 && key.equalsIgnoreCase(kv[0].trim())) {
|
||||
return kv[1].trim();
|
||||
}
|
||||
}
|
||||
return dn;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-friendly DSC type. Resolution order:
|
||||
* 1. {@code cfg.dscType} from JNLP args (server-supplied hint) if present
|
||||
* 2. cert's KeyUsage extension bits (RFC 5280: 0=digitalSignature, 2=keyEncipherment, 3=dataEncipherment)
|
||||
* 3. fallback to "Signing" — the dominant DSC purpose, matches backend's default
|
||||
*/
|
||||
private static String extractDscType(final X509Certificate cert) {
|
||||
final String hint = cfg != null ? cfg.dscType : null;
|
||||
if (hint != null && !hint.isEmpty()) {
|
||||
// Normalise "SIGNING"/"signing"/"Signing" to display form
|
||||
final String upper = hint.toUpperCase(Locale.ROOT);
|
||||
if ("SIGNING".equals(upper)) {
|
||||
return "Signing";
|
||||
}
|
||||
if ("ENCRYPTION".equals(upper)) {
|
||||
return "Encryption";
|
||||
}
|
||||
return hint; // unknown value — show as-is rather than silently dropping it
|
||||
}
|
||||
final boolean[] ku = cert.getKeyUsage();
|
||||
if (ku == null) {
|
||||
return "Signing";
|
||||
}
|
||||
final boolean signing = ku.length > 0 && ku[0];
|
||||
final boolean encryption = (ku.length > 2 && ku[2]) || (ku.length > 3 && ku[3]);
|
||||
if (signing && encryption) {
|
||||
return "Signing + Encryption";
|
||||
}
|
||||
if (signing) {
|
||||
return "Signing";
|
||||
}
|
||||
if (encryption) {
|
||||
return "Encryption";
|
||||
}
|
||||
return "Signing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the cert's type to the {@code DscType} enum the backend persists. JNLP arg wins, falls
|
||||
* back to KeyUsage, finally defaults to SIGNING. Same resolution order as {@link #extractDscType}.
|
||||
*/
|
||||
private static String resolveDscTypeForBackend(final X509Certificate cert) {
|
||||
final String hint = cfg != null ? cfg.dscType : null;
|
||||
if (hint != null && !hint.isEmpty()) {
|
||||
final String upper = hint.toUpperCase(Locale.ROOT);
|
||||
if ("SIGNING".equals(upper) || "ENCRYPTION".equals(upper)) {
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
final boolean[] ku = cert.getKeyUsage();
|
||||
if (ku != null) {
|
||||
final boolean signing = ku.length > 0 && ku[0];
|
||||
final boolean encryption = (ku.length > 2 && ku[2]) || (ku.length > 3 && ku[3]);
|
||||
// Backend enum has only SIGNING / ENCRYPTION. A combined cert is treated as SIGNING
|
||||
// because our login-2FA flow uses the signing key — encryption is incidental.
|
||||
if (signing) {
|
||||
return "SIGNING";
|
||||
}
|
||||
if (encryption) {
|
||||
return "ENCRYPTION";
|
||||
}
|
||||
}
|
||||
return "SIGNING";
|
||||
}
|
||||
|
||||
private static void showError(final String message, final String title) {
|
||||
@@ -384,6 +583,9 @@ public final class CbsnexoraDscHelper {
|
||||
String codebase;
|
||||
String tenantIdentifier; // Fineract tenant — sent as Fineract-Platform-TenantId on callback
|
||||
String title;
|
||||
String dscType; // optional server hint: "SIGNING" | "ENCRYPTION". Falls back to
|
||||
// KeyUsage derivation when absent. Matches the legacy pattern
|
||||
// where the server told the helper which type to label/store.
|
||||
|
||||
static HelperArgs parse(final String json) {
|
||||
final JsonElement root = JsonParser.parseString(json);
|
||||
@@ -400,6 +602,7 @@ public final class CbsnexoraDscHelper {
|
||||
a.codebase = optString(o, "codebase");
|
||||
a.tenantIdentifier = optString(o, "tenantIdentifier");
|
||||
a.title = optString(o, "title");
|
||||
a.dscType = optString(o, "dscType");
|
||||
if (a.mode == null || a.callbackUrl == null || a.randomNo == null) {
|
||||
throw new IllegalArgumentException("randomNo, mode, callbackUrl are required");
|
||||
}
|
||||
|
||||
@@ -11,13 +11,26 @@
|
||||
*/
|
||||
package com.cbsnexora.dsc;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.io.File;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JFileChooser;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
|
||||
/**
|
||||
* Cross-platform DSC token / store picker. On Windows the first entry is "Windows Certificate
|
||||
@@ -41,15 +54,17 @@ public class TokenFrame extends JFrame {
|
||||
private final Map<String, String> presets = new LinkedHashMap<>();
|
||||
private final JComboBox<String> combo = new JComboBox<>();
|
||||
private final JTextField customPath = new JTextField(28);
|
||||
private final JButton browseButton = new JButton("Browse...");
|
||||
private final JButton proceedButton = new JButton("Proceed");
|
||||
private final JButton cancelButton = new JButton("Cancel");
|
||||
private final JButton browseButton = Branding.secondaryButton("Browse...");
|
||||
private final JButton proceedButton = Branding.primaryButton("Proceed");
|
||||
private final JButton cancelButton = Branding.secondaryButton("Cancel");
|
||||
private final JPanel customPanel;
|
||||
private Consumer<String> proceedAction = sel -> {};
|
||||
|
||||
public TokenFrame() {
|
||||
super("Select Digital Signature Token");
|
||||
super("CBSnexora — Select Digital Signature Token");
|
||||
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
Branding.applyWindowIcon(this);
|
||||
getContentPane().setBackground(Branding.BODY_BG);
|
||||
|
||||
loadPresets();
|
||||
for (final String name : presets.keySet()) {
|
||||
@@ -57,34 +72,62 @@ public class TokenFrame extends JFrame {
|
||||
}
|
||||
combo.addItem("Other (browse for library file)");
|
||||
|
||||
// Layout
|
||||
final JPanel main = new JPanel(new BorderLayout(10, 10));
|
||||
main.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
|
||||
setLayout(new BorderLayout());
|
||||
add(Branding.buildHeader("Select Digital Signature Token",
|
||||
"Choose the keystore or token driver used to read your DSC."), BorderLayout.NORTH);
|
||||
|
||||
final JPanel chooser = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
|
||||
chooser.add(new JLabel("Token / Keystore:"));
|
||||
chooser.add(combo);
|
||||
main.add(chooser, BorderLayout.NORTH);
|
||||
// Body: form rows on a GridBag so labels align cleanly even after the optional
|
||||
// 'Library:' row appears.
|
||||
final JPanel body = new JPanel(new GridBagLayout());
|
||||
body.setBackground(Branding.BODY_BG);
|
||||
body.setBorder(BorderFactory.createEmptyBorder(20, 24, 20, 24));
|
||||
|
||||
customPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
|
||||
customPanel.add(new JLabel("Library:"));
|
||||
customPanel.add(customPath);
|
||||
customPanel.add(browseButton);
|
||||
final GridBagConstraints g = new GridBagConstraints();
|
||||
g.insets = new Insets(6, 6, 6, 6);
|
||||
g.anchor = GridBagConstraints.WEST;
|
||||
|
||||
final JLabel comboLabel = new JLabel("Token / Keystore:");
|
||||
comboLabel.setFont(comboLabel.getFont().deriveFont(Font.BOLD, 13f));
|
||||
comboLabel.setForeground(Branding.TITLE_DARK);
|
||||
g.gridx = 0; g.gridy = 0; body.add(comboLabel, g);
|
||||
combo.setPreferredSize(new Dimension(360, 28));
|
||||
g.gridx = 1; g.weightx = 1.0; g.fill = GridBagConstraints.HORIZONTAL;
|
||||
body.add(combo, g);
|
||||
|
||||
customPanel = new JPanel(new GridBagLayout());
|
||||
customPanel.setOpaque(false);
|
||||
final GridBagConstraints cg = new GridBagConstraints();
|
||||
cg.insets = new Insets(0, 0, 0, 6);
|
||||
cg.anchor = GridBagConstraints.WEST;
|
||||
final JLabel libLabel = new JLabel("Library:");
|
||||
libLabel.setFont(libLabel.getFont().deriveFont(Font.PLAIN, 12f));
|
||||
libLabel.setForeground(Branding.SUBTITLE_GREY);
|
||||
cg.gridx = 0; cg.gridy = 0; customPanel.add(libLabel, cg);
|
||||
cg.gridx = 1; cg.weightx = 1.0; cg.fill = GridBagConstraints.HORIZONTAL;
|
||||
customPanel.add(customPath, cg);
|
||||
cg.gridx = 2; cg.weightx = 0; cg.fill = GridBagConstraints.NONE;
|
||||
customPanel.add(browseButton, cg);
|
||||
customPanel.setVisible(false);
|
||||
main.add(customPanel, BorderLayout.CENTER);
|
||||
|
||||
final JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8));
|
||||
g.gridx = 0; g.gridy = 1; g.gridwidth = 2; g.weightx = 1.0;
|
||||
g.fill = GridBagConstraints.HORIZONTAL;
|
||||
body.add(customPanel, g);
|
||||
|
||||
add(body, BorderLayout.CENTER);
|
||||
|
||||
final JPanel actions = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT, 10, 14));
|
||||
actions.setBackground(Branding.BODY_BG);
|
||||
actions.setBorder(BorderFactory.createEmptyBorder(0, 18, 10, 18));
|
||||
actions.add(cancelButton);
|
||||
actions.add(proceedButton);
|
||||
main.add(actions, BorderLayout.SOUTH);
|
||||
|
||||
add(main);
|
||||
add(actions, BorderLayout.SOUTH);
|
||||
|
||||
// Hooks
|
||||
combo.addActionListener(e -> {
|
||||
final boolean isOther = combo.getSelectedItem() != null
|
||||
&& combo.getSelectedItem().toString().startsWith("Other");
|
||||
customPanel.setVisible(isOther);
|
||||
revalidate();
|
||||
pack();
|
||||
});
|
||||
browseButton.addActionListener(e -> {
|
||||
@@ -105,7 +148,7 @@ public class TokenFrame extends JFrame {
|
||||
proceedAction.accept(selection);
|
||||
});
|
||||
|
||||
setMinimumSize(new Dimension(560, 180));
|
||||
setMinimumSize(new Dimension(620, 240));
|
||||
pack();
|
||||
setLocationRelativeTo(null);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
+2
-1
@@ -22,7 +22,8 @@ public enum MfaFactorType {
|
||||
SMS,
|
||||
EMAIL,
|
||||
TOTP,
|
||||
TOKEN;
|
||||
TOKEN,
|
||||
DSC;
|
||||
|
||||
public static MfaFactorType fromString(final String s) {
|
||||
if (s == null) {
|
||||
|
||||
+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)
|
||||
|
||||
+6
-20
@@ -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();
|
||||
|
||||
+5
-18
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user