merge code feature/Backend-DSC-MFA this branch date 15-05-2026
This commit is contained in:
@@ -37,10 +37,25 @@
|
||||
"Bash(curl -sk \"https://localhost:8443/fineract-provider/api/v1/clients?limit=5\" -H \"Fineract-Platform-TenantId: default\" -u \"mifos:password\")",
|
||||
"Bash(curl -sk -X POST \"https://localhost:8443/fineract-provider/api/v1/authentication\" -H \"Fineract-Platform-TenantId: default\" -H \"Content-Type: application/json\" -d '{\"\"username\"\":\"\"mifos\"\",\"\"password\"\":\"\"password\"\"}')",
|
||||
"Bash(curl -sk \"https://localhost:8443/fineract-provider/api/v1/clients?limit=5\" -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\")",
|
||||
"Bash(./gradlew :fineract-provider:devRun --console=plain)",
|
||||
"Bash(curl -k -o /tmp/test-helper.jar -w \"HTTP %{http_code} size=%{size_download} bytes\\\\n\" https://localhost:8443/fineract-provider/api/v1/dsc/helper.jar)",
|
||||
"Bash(powershell -Command \"Get-Process java -ErrorAction SilentlyContinue | Select-Object Id, StartTime | Format-List\")",
|
||||
"Bash(./gradlew :fineract-provider:compileJava --console=plain)",
|
||||
"Bash(./gradlew :fineract-provider:compileJava :fineract-security:compileJava -q)",
|
||||
"Bash(./gradlew :fineract-provider:compileJava -q)",
|
||||
"PowerShell(Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalPort, OwningProcess, @{Name='ProcessName';Expression={\\(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue\\).ProcessName}}, @{Name='StartTime';Expression={\\(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue\\).StartTime}})",
|
||||
"PowerShell(Get-NetTCPConnection -LocalPort 8443 -ErrorAction SilentlyContinue | Format-Table LocalPort, State, OwningProcess, @{Name='ProcessName';Expression={\\(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue\\).ProcessName}} -AutoSize; netstat -ano | Select-String \":8443\")",
|
||||
"PowerShell(Get-Process -Id 9340 -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime, @{Name='MemMB';Expression={[math]::Round\\($_.WorkingSet64/1MB,0\\)}}, Path | Format-List)",
|
||||
"Bash(git -c core.editor=true rebase --continue)",
|
||||
"Bash(git rebase *)",
|
||||
"Bash(rm -rf .git/rebase-merge; git status -sb | head -3)"
|
||||
"Bash(curl -sk \"https://localhost:8443/fineract-provider/api/v1/clients?limit=5\" -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\")",
|
||||
"Bash(git add *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\adity\\Music\\CBS\\cfcfrms-bank-api"
|
||||
"c:\\Users\\adity\\Music\\CBS\\cfcfrms-bank-api",
|
||||
"C:\\Users\\adity\\Music\\CBS\\UI\\web-app\\src\\assets\\translations",
|
||||
"C:\\Users\\adity\\Music\\CBS\\fineract\\.git\\rebase-merge"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
for (final String part : dn.split(",")) {
|
||||
final String[] kv = part.trim().split("=", 2);
|
||||
if (kv.length == 2 && "CN".equalsIgnoreCase(kv[0].trim())) {
|
||||
return kv[1].trim();
|
||||
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 && 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) {
|
||||
@@ -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 |
+17
@@ -43,4 +43,21 @@ public interface ClientNomineeRepository extends JpaRepository<ClientNominee, Lo
|
||||
|
||||
@Query("SELECT COUNT(n) FROM ClientNominee n WHERE n.accountNo = :accountNo AND n.accountType = :accountType AND n.isDeleted = false")
|
||||
long countByAccountNoAndAccountType(@Param("accountNo") String accountNo, @Param("accountType") String accountType);
|
||||
|
||||
/**
|
||||
* Counts non-deleted nominee rows that link {@code nomineeClientId} as a nominee under
|
||||
* {@code clientId} for the given {@code accountType}. Backs the rule that the same person
|
||||
* may not be linked as a nominee more than once per (account owner, account type).
|
||||
* {@code excludeNomineeId} is set on update so a row doesn't conflict with itself.
|
||||
*/
|
||||
@Query("SELECT COUNT(n) FROM ClientNominee n "
|
||||
+ "WHERE n.client.id = :clientId "
|
||||
+ "AND n.nomineeClient.id = :nomineeClientId "
|
||||
+ "AND n.accountType = :accountType "
|
||||
+ "AND n.isDeleted = false "
|
||||
+ "AND (:excludeNomineeId IS NULL OR n.id <> :excludeNomineeId)")
|
||||
long countActiveByClientNomineeAndAccountType(@Param("clientId") Long clientId,
|
||||
@Param("nomineeClientId") Long nomineeClientId,
|
||||
@Param("accountType") String accountType,
|
||||
@Param("excludeNomineeId") Long excludeNomineeId);
|
||||
}
|
||||
|
||||
+6
-1
@@ -23,5 +23,10 @@ import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface SearchingClientRepository {
|
||||
|
||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly);
|
||||
/**
|
||||
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
|
||||
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
|
||||
*/
|
||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||
Integer legalFormValue);
|
||||
}
|
||||
|
||||
+9
-1
@@ -52,7 +52,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
private final CriteriaQueryFactory criteriaQueryFactory;
|
||||
|
||||
@Override
|
||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly) {
|
||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||
Integer legalFormValue) {
|
||||
/*
|
||||
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
|
||||
* this point the upgrade is too costly
|
||||
@@ -85,6 +86,13 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
||||
predicates.add(cb.equal(r.get("status"), ClientStatus.ACTIVE.getValue()));
|
||||
}
|
||||
|
||||
// Restrict to one legal form when requested. Used by guardian/nominee/family-member
|
||||
// pickers so non-individual entities (companies, partnerships) never appear as
|
||||
// selectable parties — they can never legally hold those relationships.
|
||||
if (legalFormValue != null) {
|
||||
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
||||
|
||||
+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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+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() {
|
||||
|
||||
+16
@@ -21,8 +21,24 @@ package org.apache.fineract.portfolio.client.domain;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface ClientFamilyMembersRepository
|
||||
extends JpaRepository<ClientFamilyMembers, Long>, JpaSpecificationExecutor<ClientFamilyMembers> {
|
||||
|
||||
/**
|
||||
* Counts non-deleted family members for a client that hold a given relationship code, optionally
|
||||
* ignoring one specific family member (used at update time so a row doesn't conflict with itself).
|
||||
* Backs the single-use relationship rule for Mother / Father / Spouse.
|
||||
*/
|
||||
@Query("SELECT COUNT(fm) FROM ClientFamilyMembers fm "
|
||||
+ "WHERE fm.client.id = :clientId "
|
||||
+ "AND fm.relationship.id = :relationshipId "
|
||||
+ "AND fm.isDeleted = false "
|
||||
+ "AND (:excludeFamilyMemberId IS NULL OR fm.id <> :excludeFamilyMemberId)")
|
||||
long countActiveByClientAndRelationship(@Param("clientId") Long clientId,
|
||||
@Param("relationshipId") Long relationshipId,
|
||||
@Param("excludeFamilyMemberId") Long excludeFamilyMemberId);
|
||||
|
||||
}
|
||||
|
||||
+61
@@ -27,11 +27,15 @@ import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers;
|
||||
@@ -47,6 +51,16 @@ import org.springframework.stereotype.Service;
|
||||
public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamilyMembersWritePlatformService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ClientFamilyMembersWritePlatformServiceImpl.class);
|
||||
|
||||
/**
|
||||
* Relationship labels that may appear at most once per client. Compared case-insensitively
|
||||
* against the CodeValue label, so admins can rename the values to match local spellings
|
||||
* without breaking the rule. Common synonyms ({@code husband}/{@code wife}) are treated as
|
||||
* spouse so the rule isn't defeated by data entry.
|
||||
*/
|
||||
private static final Set<String> SINGLE_USE_RELATIONSHIPS = Set.of(
|
||||
"mother", "father", "spouse", "husband", "wife");
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CodeValueRepository codeValueRepository;
|
||||
private final ClientFamilyMembersRepository clientFamilyRepository;
|
||||
@@ -65,6 +79,38 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the "single-use" rule for relationships in {@link #SINGLE_USE_RELATIONSHIPS}.
|
||||
* Throws {@link PlatformApiDataValidationException} when a duplicate is detected so the UI
|
||||
* gets a clean validation error in the standard envelope.
|
||||
*
|
||||
* @param clientId the parent client
|
||||
* @param relationship the CodeValue being applied; may be {@code null} (e.g. user left it blank)
|
||||
* @param excludeMemberId {@code null} on create, the row id on update — prevents a row from
|
||||
* conflicting with itself when remarks or other non-relationship fields
|
||||
* change
|
||||
*/
|
||||
private void validateRelationshipUniqueness(final Long clientId, final CodeValue relationship,
|
||||
final Long excludeMemberId) {
|
||||
if (clientId == null || relationship == null || relationship.getLabel() == null) {
|
||||
return;
|
||||
}
|
||||
final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT);
|
||||
if (!SINGLE_USE_RELATIONSHIPS.contains(label)) {
|
||||
return;
|
||||
}
|
||||
final long existing = clientFamilyRepository.countActiveByClientAndRelationship(
|
||||
clientId, relationship.getId(), excludeMemberId);
|
||||
if (existing > 0) {
|
||||
final String displayName = relationship.getLabel();
|
||||
throw new PlatformApiDataValidationException(java.util.List.of(ApiParameterError.parameterError(
|
||||
"error.msg.client.family.member.relationship.duplicate",
|
||||
"A family member with relationship '" + displayName + "' already exists for this client. "
|
||||
+ "Only one " + displayName + " is permitted.",
|
||||
"relationshipId", relationship.getId())));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) {
|
||||
|
||||
@@ -125,6 +171,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
|
||||
dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth");
|
||||
|
||||
// Single-use relationship guard (Mother / Father / Spouse) — refuse the add before we
|
||||
// construct the entity so the DB stays consistent and the caller gets a clean validation
|
||||
// error rather than a generic IO failure.
|
||||
validateRelationshipUniqueness(clientId, relationship, null);
|
||||
|
||||
ClientFamilyMembers clientFamilyMembers = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification,
|
||||
mobileNumber, age, isDependent, relationship, maritalStatus, gender, dateOfBirth, profession,
|
||||
nominationType, percentageShare, remarks);
|
||||
@@ -299,6 +350,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
familyMember.setMarriageAuthority(member.get("marriageAuthority").getAsString());
|
||||
}
|
||||
|
||||
// Single-use relationship guard, evaluated per row so a batch payload with two
|
||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||
|
||||
}
|
||||
@@ -447,6 +502,12 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
||||
}
|
||||
}
|
||||
|
||||
// Single-use relationship guard. Exclude the row being updated so it doesn't conflict with
|
||||
// itself when only non-relationship fields changed.
|
||||
final Client ownerForValidation = clientFamilyMember.getClient();
|
||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||
clientFamilyMember.getRelationship(), familyMemberId);
|
||||
|
||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||
|
||||
+27
-9
@@ -150,16 +150,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
final Map<String, Object> changes = kyc.update(command);
|
||||
|
||||
if (!changes.isEmpty()) {
|
||||
// If PAN-related fields changed on a VERIFIED record, reset PAN verification
|
||||
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
|
||||
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
|
||||
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (panChanged && currentStatus.isVerified()) {
|
||||
kyc.resetPanVerification();
|
||||
changes.put("panVerified", false);
|
||||
}
|
||||
// Move to IN_PROGRESS when documents are updated
|
||||
if (!kyc.getKycStatus().isInProgress()) {
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
|
||||
}
|
||||
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
|
||||
// Handles the common case of editing remarks / risk on a fully-verified KYC.
|
||||
kyc.tryAutoVerify();
|
||||
if (kyc.getKycStatus().isVerified()) {
|
||||
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
|
||||
}
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
}
|
||||
|
||||
@@ -972,15 +983,22 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
}
|
||||
|
||||
// Same audit semantics as the explicit updateKyc endpoint:
|
||||
// • PAN-edited on a VERIFIED row → reset PAN verification (forces re-verify)
|
||||
// • Any change moves status to IN_PROGRESS so the row is no longer mistaken for a
|
||||
// completed KYC until verify-pan / verify-aadhaar run again.
|
||||
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
|
||||
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
|
||||
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
|
||||
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
|
||||
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
|
||||
final boolean panChanged = changes.containsKey("panNumber");
|
||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
||||
|
||||
if (currentStatus.isVerified() && panChanged) {
|
||||
kyc.resetPanVerification();
|
||||
}
|
||||
if (!kyc.getKycStatus().isInProgress()) {
|
||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
||||
kyc.markInProgress();
|
||||
}
|
||||
kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
||||
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
||||
|
||||
+29
@@ -83,6 +83,11 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
}
|
||||
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
|
||||
|
||||
// ── Linked-nominee uniqueness per (account owner, account type) ──
|
||||
// Same person can be a nominee on a Savings (SB) AND a Current (CB) account, but not twice
|
||||
// within the same account type — even across different account numbers of that type.
|
||||
validateNomineeUniquePerAccountType(clientId, nomineeClientId, accountType, null);
|
||||
|
||||
// ── Percentage share validation (per account, not per client) ──
|
||||
final BigDecimal newShare = command.bigDecimalValueOfParameterNamed("percentageShare");
|
||||
validatePercentageShareForAccount(accountNo, accountType, null, newShare);
|
||||
@@ -147,6 +152,8 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
throw new PlatformDataIntegrityException("error.msg.nominee.cannot.be.self",
|
||||
"Client cannot be their own nominee.", "nomineeClientId");
|
||||
}
|
||||
// Re-check uniqueness when the nominee link is being changed on update.
|
||||
validateNomineeUniquePerAccountType(clientId, nomineeClientId, nominee.getAccountType(), nomineeId);
|
||||
final Client nomineeClient = this.clientRepository.findOneWithNotFoundDetection(nomineeClientId);
|
||||
nominee.setNomineeClient(nomineeClient);
|
||||
}
|
||||
@@ -269,6 +276,28 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the rule that the same linked nominee may appear at most once per (account owner,
|
||||
* account type). Same person CAN still be nominee for the owner's Savings (SB) and Current (CB)
|
||||
* accounts simultaneously — uniqueness is scoped by accountType, not accountNo.
|
||||
* {@code excludeNomineeId} is set on update so a row doesn't conflict with itself.
|
||||
*/
|
||||
private void validateNomineeUniquePerAccountType(final Long clientId, final Long nomineeClientId,
|
||||
final String accountType, final Long excludeNomineeId) {
|
||||
if (clientId == null || nomineeClientId == null || accountType == null || accountType.isBlank()) {
|
||||
return;
|
||||
}
|
||||
final long existing = nomineeRepository.countActiveByClientNomineeAndAccountType(
|
||||
clientId, nomineeClientId, accountType, excludeNomineeId);
|
||||
if (existing > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.nominee.already.linked.for.account.type",
|
||||
"This person is already a nominee for one of the client's "
|
||||
+ accountType + " accounts. Same nominee can appear only once per account type "
|
||||
+ "(but is allowed across different account types).",
|
||||
"nomineeClientId", nomineeClientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique nominee identifier: NOM-{TYPE}{ACCNO}-{SEQ}
|
||||
* e.g. NOM-SB00123456-01, NOM-CB00123456-02, NOM-LA00123456-01
|
||||
|
||||
+49
-8
@@ -317,10 +317,14 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||
final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
||||
final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||
final String firstname = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
|
||||
final String middlename = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
|
||||
final String lastname = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
|
||||
final String fullname = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
|
||||
// Title-case names at the boundary so every persistence path (REST, internal,
|
||||
// bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the
|
||||
// input. This is the single write seam for new clients — normalising here means we
|
||||
// never have to re-clean the data downstream (display name, KYC compare, reports).
|
||||
final String firstname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
|
||||
final String middlename = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
|
||||
final String lastname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
|
||||
final String fullname = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
|
||||
final boolean isStaff = command.booleanPrimitiveValueOfParameterNamed(ClientApiConstants.isStaffParamName);
|
||||
final LocalDate dataOfBirth = command.localDateValueOfParameterNamed(ClientApiConstants.dateOfBirthParamName);
|
||||
|
||||
@@ -672,26 +676,29 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
// Title-case incoming names so an update with "ADITYA" or "aditya" still ends up
|
||||
// stored as "Aditya". Compare the *normalised* value against the persisted one so
|
||||
// we don't churn the audit log with a no-op change when the only difference is case.
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.firstnameParamName, clientForUpdate.getFirstname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.firstnameParamName));
|
||||
changes.put(ClientApiConstants.firstnameParamName, newValue);
|
||||
clientForUpdate.setFirstname(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.middlenameParamName, clientForUpdate.getMiddlename())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.middlenameParamName));
|
||||
changes.put(ClientApiConstants.middlenameParamName, newValue);
|
||||
clientForUpdate.setMiddlename(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.lastnameParamName, clientForUpdate.getLastname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.lastnameParamName));
|
||||
changes.put(ClientApiConstants.lastnameParamName, newValue);
|
||||
clientForUpdate.setLastname(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
|
||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.fullnameParamName, clientForUpdate.getFullname())) {
|
||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName);
|
||||
final String newValue = toTitleCase(command.stringValueOfParameterNamed(ClientApiConstants.fullnameParamName));
|
||||
changes.put(ClientApiConstants.fullnameParamName, newValue);
|
||||
clientForUpdate.setFullname(newValue);
|
||||
}
|
||||
@@ -1574,4 +1581,38 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-case a person name. "aditya kumar vyas" / "ADITYA KUMAR VYAS" → "Aditya Kumar Vyas".
|
||||
* Splits on whitespace, uppercases the first character of each word and lowercases the rest;
|
||||
* preserves hyphens / apostrophes inside words but does NOT re-capitalise after them (so
|
||||
* "smith-jones" → "Smith-jones", which keeps the rule simple and predictable for clerks
|
||||
* verifying KYC names against ID documents). Null / blank → null so we don't accidentally
|
||||
* persist an empty string in place of an unset field.
|
||||
*/
|
||||
private static String toTitleCase(final String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String trimmed = raw.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final String[] parts = trimmed.split("\\s+");
|
||||
final StringBuilder sb = new StringBuilder(trimmed.length());
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
final String p = parts[i];
|
||||
if (p.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sb.append(Character.toUpperCase(p.charAt(0)));
|
||||
if (p.length() > 1) {
|
||||
sb.append(p.substring(1).toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+22
-1
@@ -24,6 +24,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.PagedRequest;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
||||
import org.apache.fineract.portfolio.client.service.search.domain.ClientSearchData;
|
||||
import org.apache.fineract.portfolio.client.service.search.domain.ClientTextSearch;
|
||||
import org.apache.fineract.portfolio.client.service.search.mapper.ClientSearchDataMapper;
|
||||
@@ -59,9 +60,29 @@ public class ClientSearchService {
|
||||
String requestSearchText = request.map(ClientTextSearch::getText).orElse(null);
|
||||
String searchText = Objects.toString(requestSearchText, "");
|
||||
boolean activeOnly = request.map(ClientTextSearch::getActiveOnly).filter(Boolean::booleanValue).isPresent();
|
||||
Integer legalFormValue = request.map(ClientTextSearch::getLegalFormType).map(ClientSearchService::parseLegalForm).orElse(null);
|
||||
|
||||
Pageable pageable = searchRequest.toPageable();
|
||||
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly).map(clientSearchDataMapper::map);
|
||||
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue)
|
||||
.map(clientSearchDataMapper::map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the request-level legal-form hint to the integer stored on m_client.legal_form_enum.
|
||||
* Accepts a few aliases callers in the wild use ("INDIVIDUAL", "PERSON", "NON_INDIVIDUAL",
|
||||
* "ENTITY") and the bare numeric strings. Anything else returns null = no filter, which is the
|
||||
* safe default — an unrecognised value should not silently drop all clients from results.
|
||||
*/
|
||||
private static Integer parseLegalForm(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String v = raw.trim().toUpperCase();
|
||||
return switch (v) {
|
||||
case "INDIVIDUAL", "PERSON", "1" -> LegalForm.PERSON.getValue();
|
||||
case "NON_INDIVIDUAL", "NONINDIVIDUAL", "ENTITY", "2" -> LegalForm.ENTITY.getValue();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -31,4 +31,14 @@ public class ClientTextSearch {
|
||||
* clients should never appear. Defaults to false to preserve behavior of the general client list.
|
||||
*/
|
||||
private Boolean activeOnly;
|
||||
|
||||
/**
|
||||
* Restricts results to a single legal form. Accepts "INDIVIDUAL" (legalFormEnum=1) or
|
||||
* "NON_INDIVIDUAL" / "ENTITY" (legalFormEnum=2). Used by guardian / nominee / family-member
|
||||
* link-client pickers, where only natural persons make sense as linkable parties — picking an
|
||||
* entity as a "guardian" or "spouse" is a data-entry error, so the backend filter is the
|
||||
* authoritative gate (the SPA also passes the same hint, but a hostile or older client must
|
||||
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
|
||||
*/
|
||||
private String legalFormType;
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#
|
||||
# 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
|
||||
@@ -23,7 +25,7 @@ fineract.node-id=${FINERACT_NODE_ID:1}
|
||||
|
||||
fineract.security.basicauth.enabled=${FINERACT_SECURITY_BASICAUTH_ENABLED:true}
|
||||
fineract.security.oauth2.enabled=${FINERACT_SECURITY_OAUTH_ENABLED:false}
|
||||
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:false}
|
||||
fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:true}
|
||||
fineract.security.hsts.enabled=${FINERACT_SECURITY_HSTS_ENABLED:false}
|
||||
|
||||
#CORS configuration
|
||||
@@ -44,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:root}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -664,6 +666,7 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
|
||||
|
||||
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
|
||||
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
|
||||
|
||||
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
||||
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
||||
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
|
||||
@@ -684,4 +687,3 @@ spring.mail.properties.mail.transport.protocol=smtp
|
||||
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
|
||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
||||
|
||||
|
||||
@@ -312,4 +312,5 @@
|
||||
<include file="parts/0293_create_m_dsc_challenge.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0294_add_tfa_token_to_dsc_challenge.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0295_unique_email_mobile_on_m_appuser.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0296_unique_nominee_per_account_type.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
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
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Belt-and-braces DB constraint matching the service-level uniqueness check in
|
||||
ClientNomineeWritePlatformServiceImpl: the same person may not be linked as a nominee
|
||||
more than once per (account owner, account type). Soft-deleted rows are excluded via the
|
||||
WHERE clause so revoke + re-add stays possible.
|
||||
|
||||
Partial unique index (Postgres). If the platform ever runs on MariaDB / MySQL the WHERE
|
||||
clause must be dropped — JpaSpecification fallback in the service will still enforce the
|
||||
rule but at slightly higher cost.
|
||||
-->
|
||||
<changeSet id="0296_unique_nominee_per_account_type" author="fineract" dbms="postgresql">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_client_nominee"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_client_nominee_per_account_type
|
||||
ON m_client_nominee (client_id, nominee_client_id, account_type)
|
||||
WHERE is_deleted = false;
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS uq_client_nominee_per_account_type;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+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) {
|
||||
|
||||
+1826
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user