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) ────────
|
// ── Certificate selection UI (shared between Windows and PKCS11) ────────
|
||||||
|
|
||||||
private static void showCertSelectionWindow() {
|
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.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(
|
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
|
@Override
|
||||||
public boolean isCellEditable(final int row, final int column) {
|
public boolean isCellEditable(final int row, final int column) {
|
||||||
return false;
|
return false;
|
||||||
@@ -155,10 +164,20 @@ public final class CbsnexoraDscHelper {
|
|||||||
};
|
};
|
||||||
final JTable table = new JTable(model);
|
final JTable table = new JTable(model);
|
||||||
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
table.getColumnModel().getColumn(0).setPreferredWidth(180);
|
table.setRowHeight(26);
|
||||||
table.getColumnModel().getColumn(1).setPreferredWidth(180);
|
table.setGridColor(Branding.DIVIDER);
|
||||||
table.getColumnModel().getColumn(2).setPreferredWidth(260);
|
table.setShowVerticalLines(false);
|
||||||
table.getColumnModel().getColumn(3).setPreferredWidth(100);
|
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");
|
final SimpleDateFormat dateFmt = new SimpleDateFormat("dd-MMM-yyyy");
|
||||||
// Fully qualified — java.awt.List exists too and collides with the wildcard import.
|
// Fully qualified — java.awt.List exists too and collides with the wildcard import.
|
||||||
@@ -181,10 +200,15 @@ public final class CbsnexoraDscHelper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String subjectDn = x.getSubjectX500Principal().getName();
|
||||||
model.addRow(new Object[] {
|
model.addRow(new Object[] {
|
||||||
extractCN(x.getSubjectX500Principal().getName()),
|
extractCN(subjectDn),
|
||||||
|
alias,
|
||||||
|
extractRdn(subjectDn, "O"),
|
||||||
|
extractDscType(x),
|
||||||
serial,
|
serial,
|
||||||
x.getIssuerX500Principal().getName(),
|
extractCN(x.getIssuerX500Principal().getName()),
|
||||||
|
dateFmt.format(x.getNotBefore()),
|
||||||
dateFmt.format(x.getNotAfter())
|
dateFmt.format(x.getNotAfter())
|
||||||
});
|
});
|
||||||
aliasList.add(alias);
|
aliasList.add(alias);
|
||||||
@@ -202,11 +226,12 @@ public final class CbsnexoraDscHelper {
|
|||||||
System.exit(1);
|
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);
|
proceed.setEnabled(false);
|
||||||
table.getSelectionModel().addListSelectionListener(e -> proceed.setEnabled(table.getSelectedRow() >= 0));
|
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));
|
cancel.addActionListener(e -> System.exit(0));
|
||||||
|
|
||||||
proceed.addActionListener(e -> {
|
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(cancel);
|
||||||
buttons.add(proceed);
|
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.add(buttons, BorderLayout.SOUTH);
|
||||||
frame.setSize(880, 420);
|
frame.setSize(1220, 480);
|
||||||
frame.setLocationRelativeTo(null);
|
frame.setLocationRelativeTo(null);
|
||||||
frame.setVisible(true);
|
frame.setVisible(true);
|
||||||
}
|
}
|
||||||
@@ -249,14 +281,77 @@ public final class CbsnexoraDscHelper {
|
|||||||
|
|
||||||
private static void performEnroll(final String alias) throws Exception {
|
private static void performEnroll(final String alias) throws Exception {
|
||||||
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
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 String certB64 = Base64.getEncoder().encodeToString(cert.getEncoded());
|
||||||
final Map<String, String> body = new LinkedHashMap<>();
|
final Map<String, String> body = new LinkedHashMap<>();
|
||||||
body.put("certB64", certB64);
|
body.put("certB64", certB64);
|
||||||
body.put("dscName", extractCN(cert.getSubjectX500Principal().getName()));
|
body.put("dscName", extractCN(cert.getSubjectX500Principal().getName()));
|
||||||
body.put("dscType", "SIGNING");
|
body.put("dscType", resolveDscTypeForBackend(cert));
|
||||||
postJson(cfg.callbackUrl, body);
|
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 ────────
|
// ── SIGN_CHALLENGE: RSA-SHA256(challenge bytes) → POST signature ────────
|
||||||
|
|
||||||
private static void performSignChallenge(final String alias) throws Exception {
|
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 ──────────
|
// ── JNLP age check — same shape as your existing tender helper ──────────
|
||||||
|
|
||||||
private static boolean validateJnlpAge() {
|
private static boolean validateJnlpAge() {
|
||||||
// Look for whatever filename our UI uses today AND the legacy name. If any candidate
|
// Look for our JNLP in Downloads — and crucially, match browser-renamed copies too
|
||||||
// exists and is recent, accept. If none exist, accept (some browsers stream JNLP directly
|
// ("cbsnexora-dsc (1).jnlp", "cbsnexora-dsc (2).jnlp", ...). Browsers add a numeric suffix
|
||||||
// to JWS without saving). We only fail if a candidate exists AND is too old AND no fresh
|
// when a file by the same name already exists, so a strict exact-name match would pick up
|
||||||
// candidate is present — the server-side challenge TTL is the real authority on staleness.
|
// 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 {
|
try {
|
||||||
final String[] candidates = { "cbsnexora-dsc.jnlp", "SignTender.jnlp" };
|
final String[] prefixes = { "cbsnexora-dsc", "SignTender" };
|
||||||
final String userProfile = System.getenv("USERPROFILE");
|
final String userProfile = System.getenv("USERPROFILE");
|
||||||
final String downloads = (userProfile != null ? userProfile : System.getProperty("user.home"))
|
final String downloads = (userProfile != null ? userProfile : System.getProperty("user.home"))
|
||||||
+ File.separator + "Downloads";
|
+ File.separator + "Downloads";
|
||||||
boolean anyExist = false;
|
final File dir = new File(downloads);
|
||||||
long youngestMinutes = Long.MAX_VALUE;
|
if (!dir.isDirectory()) {
|
||||||
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) {
|
|
||||||
return true;
|
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;
|
return youngestMinutes <= MAX_LINK_AGE_MINUTES;
|
||||||
} catch (final Exception ignored) {
|
} catch (final Exception ignored) {
|
||||||
return true; // never block on env weirdness
|
return true; // never block on env weirdness
|
||||||
@@ -358,16 +469,104 @@ public final class CbsnexoraDscHelper {
|
|||||||
// ── Misc ────────────────────────────────────────────────────────────────
|
// ── Misc ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static String extractCN(final String dn) {
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
for (final String part : dn.split(",")) {
|
try {
|
||||||
final String[] kv = part.trim().split("=", 2);
|
final javax.naming.ldap.LdapName ldap = new javax.naming.ldap.LdapName(dn);
|
||||||
if (kv.length == 2 && "CN".equalsIgnoreCase(kv[0].trim())) {
|
for (final javax.naming.ldap.Rdn rdn : ldap.getRdns()) {
|
||||||
return kv[1].trim();
|
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 && key.equalsIgnoreCase(kv[0].trim())) {
|
||||||
|
return kv[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dn;
|
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) {
|
private static void showError(final String message, final String title) {
|
||||||
@@ -384,6 +583,9 @@ public final class CbsnexoraDscHelper {
|
|||||||
String codebase;
|
String codebase;
|
||||||
String tenantIdentifier; // Fineract tenant — sent as Fineract-Platform-TenantId on callback
|
String tenantIdentifier; // Fineract tenant — sent as Fineract-Platform-TenantId on callback
|
||||||
String title;
|
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) {
|
static HelperArgs parse(final String json) {
|
||||||
final JsonElement root = JsonParser.parseString(json);
|
final JsonElement root = JsonParser.parseString(json);
|
||||||
@@ -400,6 +602,7 @@ public final class CbsnexoraDscHelper {
|
|||||||
a.codebase = optString(o, "codebase");
|
a.codebase = optString(o, "codebase");
|
||||||
a.tenantIdentifier = optString(o, "tenantIdentifier");
|
a.tenantIdentifier = optString(o, "tenantIdentifier");
|
||||||
a.title = optString(o, "title");
|
a.title = optString(o, "title");
|
||||||
|
a.dscType = optString(o, "dscType");
|
||||||
if (a.mode == null || a.callbackUrl == null || a.randomNo == null) {
|
if (a.mode == null || a.callbackUrl == null || a.randomNo == null) {
|
||||||
throw new IllegalArgumentException("randomNo, mode, callbackUrl are required");
|
throw new IllegalArgumentException("randomNo, mode, callbackUrl are required");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,26 @@
|
|||||||
*/
|
*/
|
||||||
package com.cbsnexora.dsc;
|
package com.cbsnexora.dsc;
|
||||||
|
|
||||||
import javax.swing.*;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.*;
|
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.io.File;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Consumer;
|
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
|
* 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 Map<String, String> presets = new LinkedHashMap<>();
|
||||||
private final JComboBox<String> combo = new JComboBox<>();
|
private final JComboBox<String> combo = new JComboBox<>();
|
||||||
private final JTextField customPath = new JTextField(28);
|
private final JTextField customPath = new JTextField(28);
|
||||||
private final JButton browseButton = new JButton("Browse...");
|
private final JButton browseButton = Branding.secondaryButton("Browse...");
|
||||||
private final JButton proceedButton = new JButton("Proceed");
|
private final JButton proceedButton = Branding.primaryButton("Proceed");
|
||||||
private final JButton cancelButton = new JButton("Cancel");
|
private final JButton cancelButton = Branding.secondaryButton("Cancel");
|
||||||
private final JPanel customPanel;
|
private final JPanel customPanel;
|
||||||
private Consumer<String> proceedAction = sel -> {};
|
private Consumer<String> proceedAction = sel -> {};
|
||||||
|
|
||||||
public TokenFrame() {
|
public TokenFrame() {
|
||||||
super("Select Digital Signature Token");
|
super("CBSnexora — Select Digital Signature Token");
|
||||||
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||||
|
Branding.applyWindowIcon(this);
|
||||||
|
getContentPane().setBackground(Branding.BODY_BG);
|
||||||
|
|
||||||
loadPresets();
|
loadPresets();
|
||||||
for (final String name : presets.keySet()) {
|
for (final String name : presets.keySet()) {
|
||||||
@@ -57,34 +72,62 @@ public class TokenFrame extends JFrame {
|
|||||||
}
|
}
|
||||||
combo.addItem("Other (browse for library file)");
|
combo.addItem("Other (browse for library file)");
|
||||||
|
|
||||||
// Layout
|
setLayout(new BorderLayout());
|
||||||
final JPanel main = new JPanel(new BorderLayout(10, 10));
|
add(Branding.buildHeader("Select Digital Signature Token",
|
||||||
main.setBorder(BorderFactory.createEmptyBorder(15, 15, 15, 15));
|
"Choose the keystore or token driver used to read your DSC."), BorderLayout.NORTH);
|
||||||
|
|
||||||
final JPanel chooser = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
|
// Body: form rows on a GridBag so labels align cleanly even after the optional
|
||||||
chooser.add(new JLabel("Token / Keystore:"));
|
// 'Library:' row appears.
|
||||||
chooser.add(combo);
|
final JPanel body = new JPanel(new GridBagLayout());
|
||||||
main.add(chooser, BorderLayout.NORTH);
|
body.setBackground(Branding.BODY_BG);
|
||||||
|
body.setBorder(BorderFactory.createEmptyBorder(20, 24, 20, 24));
|
||||||
|
|
||||||
customPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
|
final GridBagConstraints g = new GridBagConstraints();
|
||||||
customPanel.add(new JLabel("Library:"));
|
g.insets = new Insets(6, 6, 6, 6);
|
||||||
customPanel.add(customPath);
|
g.anchor = GridBagConstraints.WEST;
|
||||||
customPanel.add(browseButton);
|
|
||||||
|
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);
|
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(cancelButton);
|
||||||
actions.add(proceedButton);
|
actions.add(proceedButton);
|
||||||
main.add(actions, BorderLayout.SOUTH);
|
add(actions, BorderLayout.SOUTH);
|
||||||
|
|
||||||
add(main);
|
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
combo.addActionListener(e -> {
|
combo.addActionListener(e -> {
|
||||||
final boolean isOther = combo.getSelectedItem() != null
|
final boolean isOther = combo.getSelectedItem() != null
|
||||||
&& combo.getSelectedItem().toString().startsWith("Other");
|
&& combo.getSelectedItem().toString().startsWith("Other");
|
||||||
customPanel.setVisible(isOther);
|
customPanel.setVisible(isOther);
|
||||||
|
revalidate();
|
||||||
pack();
|
pack();
|
||||||
});
|
});
|
||||||
browseButton.addActionListener(e -> {
|
browseButton.addActionListener(e -> {
|
||||||
@@ -105,7 +148,7 @@ public class TokenFrame extends JFrame {
|
|||||||
proceedAction.accept(selection);
|
proceedAction.accept(selection);
|
||||||
});
|
});
|
||||||
|
|
||||||
setMinimumSize(new Dimension(560, 180));
|
setMinimumSize(new Dimension(620, 240));
|
||||||
pack();
|
pack();
|
||||||
setLocationRelativeTo(null);
|
setLocationRelativeTo(null);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
+2
-1
@@ -22,7 +22,8 @@ public enum MfaFactorType {
|
|||||||
SMS,
|
SMS,
|
||||||
EMAIL,
|
EMAIL,
|
||||||
TOTP,
|
TOTP,
|
||||||
TOKEN;
|
TOKEN,
|
||||||
|
DSC;
|
||||||
|
|
||||||
public static MfaFactorType fromString(final String s) {
|
public static MfaFactorType fromString(final String s) {
|
||||||
if (s == null) {
|
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
|
// /claim hands the minted TFA token back to the browser — must be basic-authed
|
||||||
// so we can prove the caller owns the challenge.
|
// so we can prove the caller owns the challenge.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/*/claim")).fullyAuthenticated()
|
.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/**"))
|
.requestMatchers(API_MATCHER.matcher("/api/**"))
|
||||||
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
|
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
|
||||||
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
|
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
|
||||||
@@ -424,12 +433,10 @@ public class SecurityConfig {
|
|||||||
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
||||||
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
||||||
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
|
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
|
||||||
// DSC factor: same dependency pickup as AuthorizationServerConfig — the filter's
|
// m_user_mfa_factor is the single source of truth for the 2FA gate — DSC enrolment writes
|
||||||
// "user has any factor" check now also accepts an active DSC mapping.
|
// its own row there via DscEnrollmentWriteServiceImpl, so the filter no longer needs to
|
||||||
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
|
// query m_bank_user_dsc directly.
|
||||||
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
|
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
|
||||||
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
|
|
||||||
bankUserDscRepository);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
|
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
|
||||||
|
|||||||
+3
-6
@@ -291,12 +291,9 @@ public class AuthorizationServerConfig {
|
|||||||
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
||||||
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
||||||
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
|
.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
|
// m_user_mfa_factor is the single source of truth — DSC enrolment writes its own row via
|
||||||
// a factor" signal. Same Spring context lookup pattern as the other dependencies above.
|
// DscEnrollmentWriteServiceImpl, so the filter no longer queries m_bank_user_dsc directly.
|
||||||
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
|
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
|
||||||
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
|
|
||||||
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
|
|
||||||
bankUserDscRepository);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
|
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.DscChallengeNotFoundException;
|
||||||
import org.apache.fineract.useradministration.dsc.exception.DscSerialAlreadyEnrolledException;
|
import org.apache.fineract.useradministration.dsc.exception.DscSerialAlreadyEnrolledException;
|
||||||
import org.apache.fineract.useradministration.dsc.util.X509CertParser;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -56,6 +59,7 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
|
|||||||
private final PlatformSecurityContext securityContext;
|
private final PlatformSecurityContext securityContext;
|
||||||
private final DscChallengeRepository challengeRepository;
|
private final DscChallengeRepository challengeRepository;
|
||||||
private final BankUserDscRepository dscRepository;
|
private final BankUserDscRepository dscRepository;
|
||||||
|
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||||
private final X509CertParser certParser;
|
private final X509CertParser certParser;
|
||||||
private final DscJnlpGenerator jnlpGenerator;
|
private final DscJnlpGenerator jnlpGenerator;
|
||||||
|
|
||||||
@@ -205,6 +209,10 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
|
|||||||
}
|
}
|
||||||
final BankUserDsc saved = dscRepository.saveAndFlush(dsc);
|
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.setStatus(DscChallengeStatus.VERIFIED);
|
||||||
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
ch.setCertSerialReturned(parsed.getSerial());
|
ch.setCertSerialReturned(parsed.getSerial());
|
||||||
@@ -240,6 +248,32 @@ public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService
|
|||||||
.build();
|
.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) {
|
private void failChallenge(final DscChallenge ch, final String reason) {
|
||||||
ch.setStatus(DscChallengeStatus.FAILED);
|
ch.setStatus(DscChallengeStatus.FAILED);
|
||||||
ch.setErrorMessage(reason);
|
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.BankUserDscRepository;
|
||||||
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
|
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
|
||||||
import org.apache.fineract.useradministration.dsc.exception.DscNotFoundException;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -42,6 +44,7 @@ public class DscReadPlatformServiceImpl implements DscReadPlatformService {
|
|||||||
|
|
||||||
private final PlatformSecurityContext securityContext;
|
private final PlatformSecurityContext securityContext;
|
||||||
private final BankUserDscRepository dscRepository;
|
private final BankUserDscRepository dscRepository;
|
||||||
|
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -92,6 +95,24 @@ public class DscReadPlatformServiceImpl implements DscReadPlatformService {
|
|||||||
d.setDeletedBy(caller.getId());
|
d.setDeletedBy(caller.getId());
|
||||||
d.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
d.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
dscRepository.saveAndFlush(d);
|
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);
|
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) {
|
final String serial, final Long otpId) {
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.factor.type.required",
|
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 AppUser user = securityContext.authenticatedUser();
|
||||||
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
|
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
|
||||||
|
|||||||
+6
-20
@@ -85,7 +85,6 @@ public class AuthenticationApiResource {
|
|||||||
private final ClientReadPlatformService clientReadPlatformService;
|
private final ClientReadPlatformService clientReadPlatformService;
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -148,26 +147,13 @@ public class AuthenticationApiResource {
|
|||||||
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant
|
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant
|
||||||
// rollback (flip the c_configuration row off and everyone is unchallenged).
|
// rollback (flip the c_configuration row off and everyone is unchallenged).
|
||||||
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
|
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
|
||||||
// DSC factor check is wrapped so a misconfigured/missing m_bank_user_dsc table can't
|
// m_user_mfa_factor is the single source of truth for "does this user require 2FA?"
|
||||||
// bring down the entire login path. If the lookup throws (table missing, columns
|
// DSC enrolment writes a row there via DscEnrollmentWriteServiceImpl, and revoking the
|
||||||
// renamed, JDBC down), we log and fall back to "no DSC factor enrolled" — the user
|
// DSC or disabling it from the Access Control UI flips disabled_on on the same row.
|
||||||
// can still authenticate via their OTP factors (or no 2FA if none enrolled).
|
// This keeps the disable flow honest — flipping a factor off in the UI actually stops
|
||||||
boolean hasActiveDsc = false;
|
// the challenge at login, instead of being silently overridden by a stale cert mapping.
|
||||||
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
|
final boolean userHasFactors = enforcementEnabled
|
||||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId())
|
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
|
||||||
|| hasActiveDsc);
|
|
||||||
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
|
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
|
||||||
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
||||||
Long userId = principal.getId();
|
Long userId = principal.getId();
|
||||||
|
|||||||
+5
-18
@@ -63,7 +63,6 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
|
|||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
|
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).
|
// has no enrolled factors (or the global kill-switch is off).
|
||||||
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
||||||
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
|
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
|
||||||
// DSC factor check wrapped: a misconfigured/missing m_bank_user_dsc must not bring
|
// m_user_mfa_factor is the single source of truth — mirrors AuthenticationApiResource.
|
||||||
// down every protected request. Fall back to "no DSC factor" if the lookup throws.
|
// Disabling a factor row from the UI flips disabled_on, which makes this check fall
|
||||||
boolean hasActiveDsc = false;
|
// through to "no challenge needed". Without that single-table behaviour the filter and
|
||||||
if (enforcementOn) {
|
// the login response would disagree, leaving users stuck in a 2FA loop.
|
||||||
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
|
final boolean userHasFactors = enforcementOn
|
||||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId())
|
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
|
||||||
|| hasActiveDsc);
|
|
||||||
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
|
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
|
||||||
|
|
||||||
if (shouldChallenge) {
|
if (shouldChallenge) {
|
||||||
|
|||||||
Reference in New Issue
Block a user