diff --git a/dsc-helper/scripts/generate-test-dsc.ps1 b/dsc-helper/scripts/generate-test-dsc.ps1 new file mode 100644 index 000000000..fcc3a50d9 --- /dev/null +++ b/dsc-helper/scripts/generate-test-dsc.ps1 @@ -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." diff --git a/dsc-helper/src/main/java/com/cbsnexora/dsc/Branding.java b/dsc-helper/src/main/java/com/cbsnexora/dsc/Branding.java new file mode 100644 index 000000000..6ac75e444 --- /dev/null +++ b/dsc-helper/src/main/java/com/cbsnexora/dsc/Branding.java @@ -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; + } + } +} diff --git a/dsc-helper/src/main/java/com/cbsnexora/dsc/CbsnexoraDscHelper.java b/dsc-helper/src/main/java/com/cbsnexora/dsc/CbsnexoraDscHelper.java index bd07bd725..104963e02 100644 --- a/dsc-helper/src/main/java/com/cbsnexora/dsc/CbsnexoraDscHelper.java +++ b/dsc-helper/src/main/java/com/cbsnexora/dsc/CbsnexoraDscHelper.java @@ -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 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"); } diff --git a/dsc-helper/src/main/java/com/cbsnexora/dsc/TokenFrame.java b/dsc-helper/src/main/java/com/cbsnexora/dsc/TokenFrame.java index 6f655693f..9f6657e7c 100644 --- a/dsc-helper/src/main/java/com/cbsnexora/dsc/TokenFrame.java +++ b/dsc-helper/src/main/java/com/cbsnexora/dsc/TokenFrame.java @@ -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 presets = new LinkedHashMap<>(); private final JComboBox 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 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); } diff --git a/dsc-helper/src/main/resources/cbsnexora-logo.png b/dsc-helper/src/main/resources/cbsnexora-logo.png new file mode 100644 index 000000000..f763f4e19 Binary files /dev/null and b/dsc-helper/src/main/resources/cbsnexora-logo.png differ diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java index d41dfa665..e6c1a359d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/mfa/domain/MfaFactorType.java @@ -22,7 +22,8 @@ public enum MfaFactorType { SMS, EMAIL, TOTP, - TOKEN; + TOKEN, + DSC; public static MfaFactorType fromString(final String s) { if (s == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index c580b2aea..8f6392c50 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -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() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java index 7d6c76125..0b918007d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/config/AuthorizationServerConfig.java @@ -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() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscEnrollmentWriteServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscEnrollmentWriteServiceImpl.java index bd184ee49..e2e79c237 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscEnrollmentWriteServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscEnrollmentWriteServiceImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscReadPlatformServiceImpl.java index e6c7d008b..dde5626d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/dsc/service/DscReadPlatformServiceImpl.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java index 194a63f6f..2addb0b55 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/mfa/service/SelfUserMfaFactorServiceImpl.java @@ -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) diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java index 7b5ce9d70..c330b60ef 100644 --- a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java @@ -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(); diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java index 853a59860..02aba3674 100644 --- a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/filter/TwoFactorAuthenticationFilter.java @@ -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) {