merege code feature/Backend-DSC-MFA branch

This commit is contained in:
Nitesh Yadav
2026-05-12 12:48:43 +05:30
54 changed files with 5428 additions and 9 deletions
+51
View File
@@ -0,0 +1,51 @@
/*
* Builds the cbsnexora-dsc-helper.jar consumed by the JNLP from /api/v1/dsc/helper.jar.
*
* Targets Java 1.8 so the broadest set of client JREs (incl. legacy OpenWebStart installs)
* can launch the helper. The JAR is a "fat" jar with Gson shaded in — JNLP <resources> can only
* list one <jar href="..."> cleanly, and avoiding network round-trips for tiny Gson is a win.
*/
plugins {
id 'java'
}
group = 'com.cbsnexora'
version = '1.0.0'
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
repositories {
mavenCentral()
}
dependencies {
implementation 'com.google.code.gson:gson:2.10.1'
}
jar {
archiveBaseName = 'cbsnexora-dsc-helper'
archiveClassifier = ''
manifest {
attributes(
'Main-Class' : 'com.cbsnexora.dsc.CbsnexoraDscHelper',
'Permissions' : 'all-permissions',
'Application-Name' : 'CBSnexora DSC Helper',
'Codebase' : '*',
'Implementation-Version': project.version
)
}
// Shade Gson into the same jar so JNLP only needs one <jar> entry.
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
// Strip signatures from shaded deps so re-signing the fat jar is clean.
exclude 'META-INF/*.SF'
exclude 'META-INF/*.DSA'
exclude 'META-INF/*.RSA'
}
+21
View File
@@ -0,0 +1,21 @@
/*
* Standalone Gradle project for the CBSnexora DSC helper JAR. Intentionally NOT included in the
* root fineract settings.gradle — different Java target (1.8) and a tiny dependency tree.
*
* Build:
* cd dsc-helper && gradle clean build
* → dsc-helper/build/libs/cbsnexora-dsc-helper.jar (fat jar with Gson embedded)
*
* Sign (self-signed for now; replace cert later without rebuilding):
* keytool -genkeypair -alias cbsnexora -keyalg RSA -keysize 2048 \
* -validity 730 -keystore cbsnexora-keystore.jks
* jarsigner -keystore cbsnexora-keystore.jks build/libs/cbsnexora-dsc-helper.jar cbsnexora
*
* Deploy:
* cp build/libs/cbsnexora-dsc-helper.jar ~/.cbsnexora/dsc-helper.jar
* (path is configurable via fineract.dsc.helper.jar.path; default is ~/.cbsnexora/dsc-helper.jar)
*
* Fineract will then stream it on demand at GET /api/v1/dsc/helper.jar — the JNLP <jar href="..."/>
* already points at that URL.
*/
rootProject.name = 'cbsnexora-dsc-helper'
@@ -0,0 +1,419 @@
/*
* 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 com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import javax.swing.*;
import javax.swing.table.DefaultTableModel;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* Entry point launched by Java Web Start. Reads its single argument as JSON:
* <pre>
* {
* "randomNo": "...",
* "mode": "ENROLL" | "SIGN_CHALLENGE",
* "callbackUrl": "https://.../api/v1/dsc/{enroll|login/dsc}/{rn}/{callback|verify}",
* "challenge": "<hex>", // SIGN_CHALLENGE only
* "expectedSerial": "<hex>", // optional — locks selection to a single cert
* "title": "...",
* "codebase": "..."
* }
* </pre>
*
* <p>Windows uses the OS keystore ({@code Windows-MY}). Linux/Mac falls back to a PKCS11 token
* driver the user points at via {@link TokenFrame}.
*
* <p>Server is the source of truth for what gets persisted — this helper extracts and POSTs;
* the server parses, validates, and stores.
*/
public final class CbsnexoraDscHelper {
/** A freshly-launched JNLP must be less than this many minutes old. Prevents stale-link reuse. */
private static final long MAX_LINK_AGE_MINUTES = 5;
private static HelperArgs cfg;
private static KeyStore keyStore;
private static Provider pkcs11Provider; // Linux/Mac
public static void main(final String[] args) {
try {
if (!validateJnlpAge()) {
showError("This signing link has expired. Please request a fresh one from the portal.",
"Link Expired");
System.exit(1);
}
if (args == null || args.length < 1 || args[0] == null || args[0].isEmpty()) {
showError("Helper launched without arguments.", "Bad Launch");
System.exit(1);
}
cfg = HelperArgs.parse(args[0]);
// Token picker is now shown on every OS — Windows users can pick either the OS-native
// certificate store or a PKCS11 .dll for vendor-specific USB tokens (ePass2003, eToken,
// ProxKey, etc.). Linux / macOS only see PKCS11 options.
SwingUtilities.invokeLater(CbsnexoraDscHelper::launchTokenPicker);
} catch (final Exception e) {
e.printStackTrace();
showError("Unexpected error: " + e.getMessage(), "Error");
System.exit(1);
}
}
// ── Token picker (cross-platform) ───────────────────────────────────────
private static void launchTokenPicker() {
final TokenFrame tokenFrame = new TokenFrame();
tokenFrame.setProceedAction(selection -> {
try {
if (TokenFrame.SELECTION_WINDOWS_MY.equals(selection)) {
// OS-native keystore — no PIN dialog needed; the platform handles authn.
keyStore = KeyStore.getInstance("Windows-MY");
keyStore.load(null, null);
} else {
// PKCS11 library — drives a Swing PIN dialog via the callback handler.
initializePkcs11(selection);
keyStore = loadPkcs11KeyStore();
if (keyStore == null) {
showError("Failed to open token keystore. Wrong PIN?", "Login Failed");
return;
}
}
tokenFrame.dispose();
showCertSelectionWindow();
} catch (final Exception e) {
e.printStackTrace();
showError("Could not open keystore: " + e.getMessage(), "Token Error");
}
});
tokenFrame.setVisible(true);
}
private static void initializePkcs11(final String libraryPath) throws Exception {
final String config = "name = CBSnexoraDSC\nlibrary = " + libraryPath + "\n";
final ByteArrayInputStream in = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
try {
// Java 9+
final Class<?> sunPkcs11 = Class.forName("sun.security.pkcs11.SunPKCS11");
pkcs11Provider = (Provider) sunPkcs11.getConstructor(InputStream.class).newInstance(in);
} catch (final ClassNotFoundException e) {
// Fallback — same path; preserved for old JREs that resolve the class differently.
pkcs11Provider = (Provider) Class.forName("sun.security.pkcs11.SunPKCS11")
.getConstructor(InputStream.class).newInstance(in);
}
Security.removeProvider(pkcs11Provider.getName());
Security.addProvider(pkcs11Provider);
}
private static KeyStore loadPkcs11KeyStore() throws Exception {
final AuthProvider authProvider = (AuthProvider) pkcs11Provider;
final KeyStore.CallbackHandlerProtection protection = new KeyStore.CallbackHandlerProtection(
new SwingPasswordCallbackHandler());
final KeyStore.Builder builder = KeyStore.Builder.newInstance("PKCS11", authProvider, protection);
return builder.getKeyStore();
}
// ── 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");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLayout(new BorderLayout(10, 10));
final DefaultTableModel model = new DefaultTableModel(
new Object[] { "Common Name", "Serial No", "Issuer", "Expires" }, 0) {
@Override
public boolean isCellEditable(final int row, final int column) {
return false;
}
};
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);
final SimpleDateFormat dateFmt = new SimpleDateFormat("dd-MMM-yyyy");
// Fully qualified — java.awt.List exists too and collides with the wildcard import.
final java.util.List<String> aliasList = new ArrayList<>();
try {
final Enumeration<String> en = keyStore.aliases();
while (en.hasMoreElements()) {
final String alias = en.nextElement();
final java.security.cert.Certificate cert = keyStore.getCertificate(alias);
if (!(cert instanceof X509Certificate)) {
continue;
}
final X509Certificate x = (X509Certificate) cert;
final String serial = x.getSerialNumber().toString(16).toUpperCase(Locale.ROOT);
// SIGN_CHALLENGE with expectedSerial → only show the one cert the server expects.
if ("SIGN_CHALLENGE".equals(cfg.mode) && cfg.expectedSerial != null
&& !cfg.expectedSerial.equalsIgnoreCase(serial)) {
continue;
}
model.addRow(new Object[] {
extractCN(x.getSubjectX500Principal().getName()),
serial,
x.getIssuerX500Principal().getName(),
dateFmt.format(x.getNotAfter())
});
aliasList.add(alias);
}
} catch (final KeyStoreException e) {
e.printStackTrace();
showError("Could not read certificates: " + e.getMessage(), "Keystore Error");
return;
}
if (model.getRowCount() == 0) {
showError("SIGN_CHALLENGE".equals(cfg.mode)
? "No DSC matching the expected serial was found in this keystore."
: "No certificates were found in the keystore.", "Nothing to Sign");
System.exit(1);
}
final JButton proceed = new JButton(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");
cancel.addActionListener(e -> System.exit(0));
proceed.addActionListener(e -> {
final int row = table.getSelectedRow();
if (row < 0) {
return;
}
final String alias = aliasList.get(row);
try {
if ("ENROLL".equals(cfg.mode)) {
performEnroll(alias);
} else if ("SIGN_CHALLENGE".equals(cfg.mode)) {
performSignChallenge(alias);
} else {
showError("Unknown mode: " + cfg.mode, "Bad Mode");
return;
}
JOptionPane.showMessageDialog(frame, "Done. You can close this window and return to the browser.",
"Success", JOptionPane.INFORMATION_MESSAGE);
frame.dispose();
System.exit(0);
} catch (final Exception ex) {
ex.printStackTrace();
showError("Operation failed: " + ex.getMessage(), "Error");
}
});
final JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 10));
buttons.add(cancel);
buttons.add(proceed);
frame.add(new JScrollPane(table), BorderLayout.CENTER);
frame.add(buttons, BorderLayout.SOUTH);
frame.setSize(880, 420);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
// ── ENROLL: send the cert (base64 DER) up to the server ─────────────────
private static void performEnroll(final String alias) throws Exception {
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
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");
postJson(cfg.callbackUrl, body);
}
// ── SIGN_CHALLENGE: RSA-SHA256(challenge bytes) → POST signature ────────
private static void performSignChallenge(final String alias) throws Exception {
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null);
if (privateKey == null) {
throw new GeneralSecurityException("Private key for alias " + alias + " is not accessible");
}
final X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
// Sanity: chain + cert exist (also surfaces token issues early).
final Certificate[] chain = keyStore.getCertificateChain(alias);
if (chain == null || chain.length == 0) {
throw new GeneralSecurityException("Cert chain for alias " + alias + " is missing");
}
final Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(cfg.challenge.getBytes(StandardCharsets.UTF_8));
final String signatureB64 = Base64.getEncoder().encodeToString(sig.sign());
final Map<String, String> body = new LinkedHashMap<>();
body.put("certSerial", cert.getSerialNumber().toString(16).toUpperCase(Locale.ROOT));
body.put("signatureB64", signatureB64);
postJson(cfg.callbackUrl, body);
}
// ── HTTP helpers ────────────────────────────────────────────────────────
private static void postJson(final String urlStr, final Object body) throws IOException {
final String json = new Gson().toJson(body);
final URL url = new URL(urlStr);
final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
// The helper runs outside the browser (JWS-launched JVM process) so it doesn't get the
// tenant header from any HTTP interceptor — pass it explicitly from the JNLP args. The
// backend's TenantAwareBasicAuthenticationFilter requires this header (or a
// ?tenantIdentifier= query param) on every /api/* request.
if (cfg != null && cfg.tenantIdentifier != null && !cfg.tenantIdentifier.isEmpty()) {
conn.setRequestProperty("Fineract-Platform-TenantId", cfg.tenantIdentifier);
}
conn.setDoOutput(true);
try (OutputStream out = conn.getOutputStream()) {
out.write(json.getBytes(StandardCharsets.UTF_8));
}
final int code = conn.getResponseCode();
// Read body for diagnostics; surface non-2xx to the user.
final String resp;
try (InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
if (in != null) {
final byte[] buf = new byte[2048];
int n;
while ((n = in.read(buf)) > 0) {
sink.write(buf, 0, n);
}
}
resp = sink.toString("UTF-8");
}
if (code < 200 || code >= 300) {
throw new IOException("Server returned HTTP " + code + ": " + resp);
}
}
// ── 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.
try {
final String[] candidates = { "cbsnexora-dsc.jnlp", "SignTender.jnlp" };
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) {
return true;
}
return youngestMinutes <= MAX_LINK_AGE_MINUTES;
} catch (final Exception ignored) {
return true; // never block on env weirdness
}
}
// ── Misc ────────────────────────────────────────────────────────────────
private static String extractCN(final String dn) {
if (dn == null) {
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();
}
}
return dn;
}
private static void showError(final String message, final String title) {
JOptionPane.showMessageDialog(null, message, title, JOptionPane.ERROR_MESSAGE);
}
/** Parsed JSON-arg payload. Mutable for convenience; not exposed beyond this class. */
static final class HelperArgs {
String randomNo;
String mode; // ENROLL | SIGN_CHALLENGE
String callbackUrl;
String challenge; // SIGN_CHALLENGE only
String expectedSerial; // optional
String codebase;
String tenantIdentifier; // Fineract tenant — sent as Fineract-Platform-TenantId on callback
String title;
static HelperArgs parse(final String json) {
final JsonElement root = JsonParser.parseString(json);
if (!root.isJsonObject()) {
throw new IllegalArgumentException("argument is not a JSON object");
}
final JsonObject o = root.getAsJsonObject();
final HelperArgs a = new HelperArgs();
a.randomNo = optString(o, "randomNo");
a.mode = optString(o, "mode");
a.callbackUrl = optString(o, "callbackUrl");
a.challenge = optString(o, "challenge");
a.expectedSerial = optString(o, "expectedSerial");
a.codebase = optString(o, "codebase");
a.tenantIdentifier = optString(o, "tenantIdentifier");
a.title = optString(o, "title");
if (a.mode == null || a.callbackUrl == null || a.randomNo == null) {
throw new IllegalArgumentException("randomNo, mode, callbackUrl are required");
}
if ("SIGN_CHALLENGE".equals(a.mode) && (a.challenge == null || a.challenge.isEmpty())) {
throw new IllegalArgumentException("challenge is required for SIGN_CHALLENGE mode");
}
return a;
}
private static String optString(final JsonObject o, final String k) {
return o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null;
}
}
private CbsnexoraDscHelper() {
}
}
@@ -0,0 +1,70 @@
/*
* 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 javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.swing.*;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.util.concurrent.CancellationException;
/**
* Pops a Swing dialog asking for the PKCS11 token PIN. Used by the {@code KeyStore.Builder} on
* Linux/Mac when opening a hardware-token keystore — Windows-MY doesn't need it (the OS keystore
* handles auth via the platform UI).
*/
public class SwingPasswordCallbackHandler implements CallbackHandler {
@Override
public void handle(final Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (final Callback cb : callbacks) {
if (cb instanceof PasswordCallback) {
handlePasswordCallback((PasswordCallback) cb);
} else {
throw new UnsupportedCallbackException(cb, "Callback not supported: " + cb.getClass().getName());
}
}
}
private void handlePasswordCallback(final PasswordCallback passCb) {
final JPanel panel = new JPanel(new GridLayout(2, 1, 5, 5));
panel.add(new JLabel(passCb.getPrompt() != null ? passCb.getPrompt() : "Enter token PIN:"));
final JPasswordField pin = new JPasswordField(20);
panel.add(pin);
final JOptionPane pane = new JOptionPane(panel, JOptionPane.QUESTION_MESSAGE, JOptionPane.OK_CANCEL_OPTION);
final JDialog dialog = pane.createDialog(null, "Token PIN");
dialog.addWindowListener(new WindowAdapter() {
@Override
public void windowOpened(final WindowEvent e) {
pin.requestFocusInWindow();
}
});
dialog.setVisible(true);
dialog.dispose();
final Object val = pane.getValue();
final int choice = val instanceof Integer ? (Integer) val : JOptionPane.CANCEL_OPTION;
if (choice == JOptionPane.OK_OPTION) {
passCb.setPassword(pin.getPassword());
} else {
// Mirrors the original tender helper: user cancel → exit. Throwing the cancel up to
// KeyStore.Builder would leave the JNLP launch hanging.
throw new CancellationException("Token PIN entry cancelled by user");
}
}
}
@@ -0,0 +1,167 @@
/*
* 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 javax.swing.*;
import java.awt.*;
import java.io.File;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/**
* Cross-platform DSC token / store picker. On Windows the first entry is "Windows Certificate
* Store" (which routes to the {@code Windows-MY} keystore — what most Indian DSC USB tokens auto-
* register with). Below that are common PKCS11 libraries for ePass2003 / eToken / ProxKey / etc.,
* plus an "Other" option that takes a custom {@code .dll} / {@code .so} / {@code .dylib} path.
*
* <p>On Linux / macOS the Windows entry is omitted and only PKCS11 options are listed.
*
* <p>The caller registers a {@link #setProceedAction(Consumer)} callback that receives either:
* <ul>
* <li>{@link #SELECTION_WINDOWS_MY} — caller opens {@code KeyStore.getInstance("Windows-MY")}</li>
* <li>An absolute filesystem path to a PKCS11 module — caller spins up {@code SunPKCS11}</li>
* </ul>
*/
public class TokenFrame extends JFrame {
/** Sentinel returned to the proceed callback when the user picks the OS-native Windows store. */
public static final String SELECTION_WINDOWS_MY = "WINDOWS-MY";
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 JPanel customPanel;
private Consumer<String> proceedAction = sel -> {};
public TokenFrame() {
super("Select Digital Signature Token");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
loadPresets();
for (final String name : presets.keySet()) {
combo.addItem(name);
}
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));
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);
customPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 8));
customPanel.add(new JLabel("Library:"));
customPanel.add(customPath);
customPanel.add(browseButton);
customPanel.setVisible(false);
main.add(customPanel, BorderLayout.CENTER);
final JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8));
actions.add(cancelButton);
actions.add(proceedButton);
main.add(actions, BorderLayout.SOUTH);
add(main);
// Hooks
combo.addActionListener(e -> {
final boolean isOther = combo.getSelectedItem() != null
&& combo.getSelectedItem().toString().startsWith("Other");
customPanel.setVisible(isOther);
pack();
});
browseButton.addActionListener(e -> {
final JFileChooser fc = new JFileChooser();
if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
customPath.setText(fc.getSelectedFile().getAbsolutePath());
}
});
cancelButton.addActionListener(e -> System.exit(0));
proceedButton.addActionListener(e -> {
final String selection = resolveSelection();
if (selection == null) {
JOptionPane.showMessageDialog(this,
"Please pick a token / keystore option (or browse to a library file).",
"No Selection", JOptionPane.WARNING_MESSAGE);
return;
}
proceedAction.accept(selection);
});
setMinimumSize(new Dimension(560, 180));
pack();
setLocationRelativeTo(null);
}
public void setProceedAction(final Consumer<String> action) {
this.proceedAction = action != null ? action : sel -> {};
}
/** Translates the UI state into the value handed to the proceed callback. */
private String resolveSelection() {
final Object item = combo.getSelectedItem();
if (item == null) {
return null;
}
if (item.toString().startsWith("Other")) {
final String path = customPath.getText();
return (path != null && !path.trim().isEmpty()) ? path.trim() : null;
}
return presets.get(item.toString());
}
/**
* Default token / keystore presets. Adjust paths per install if your customers run different
* driver locations — most CCA-licensed CA tokens stick to the defaults below.
*/
private void loadPresets() {
final String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
presets.put("Windows Certificate Store (default)", SELECTION_WINDOWS_MY);
presets.put("ePass2003 (eMudhra / Capricorn)", "C:\\Windows\\System32\\eps2003csp11.dll");
presets.put("ePass2003 v2", "C:\\Windows\\System32\\eps2003csp11v2.dll");
presets.put("SafeNet eToken 5110", "C:\\Windows\\System32\\eTPKCS11.dll");
presets.put("WatchData ProxKey", "C:\\Windows\\System32\\WDPKCS.dll");
presets.put("Aladdin eToken", "C:\\Windows\\System32\\eTPkcs11.dll");
presets.put("HYP2003 / Trust Key", "C:\\Windows\\System32\\SignatureP11.dll");
presets.put("mToken CryptoID", "C:\\Windows\\System32\\mToken.dll");
} else if (os.contains("mac") || os.contains("darwin")) {
presets.put("ePass2003 (eMudhra / Capricorn)", "/usr/local/lib/libcastle_v2.dylib");
presets.put("SafeNet eToken", "/usr/local/lib/libeTPkcs11.dylib");
presets.put("WatchData ProxKey", "/usr/local/lib/libwdpkcs.dylib");
} else {
// Linux
presets.put("ePass2003 (eMudhra / Capricorn)", "/usr/lib/x86_64-linux-gnu/libcastle_v2.so");
presets.put("ePass2003 alt path", "/usr/lib/libcastle_v2.so");
presets.put("SafeNet eToken", "/usr/lib/libeToken.so");
presets.put("SafeNet eToken (Ubuntu)", "/usr/lib/x86_64-linux-gnu/libeToken.so");
presets.put("WatchData ProxKey", "/usr/lib/libwdpkcs.so");
presets.put("OpenSC (generic)", "/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so");
}
// Drop entries whose underlying file doesn't exist — keeps the picker honest.
presets.values().removeIf(path -> !SELECTION_WINDOWS_MY.equals(path) && !new File(path).exists());
// Always keep at least Windows-MY on Windows even if the rest were stripped.
if (os.contains("win") && presets.isEmpty()) {
presets.put("Windows Certificate Store (default)", SELECTION_WINDOWS_MY);
}
}
}
@@ -0,0 +1,162 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One row per DSC mapping. A user may have multiple rows — we filter by {@link CertStatus} to find
* active mappings. The cert serial is globally unique (one cert in the world has one serial), so it
* doubles as a natural key.
*
* <p>Mirrors the shape of an externally-reference {@code bank_user_dsc} table from the procurement
* project, with Fineract conventions layered on top: FK to {@code m_appuser}, snake_case columns,
* proper DATE/TIMESTAMP types, soft-delete + audit columns, and optimistic locking via
* {@code version}.
*/
@Entity
@Table(name = "m_bank_user_dsc")
@Getter
@Setter
@NoArgsConstructor
public class BankUserDsc extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
/**
* Free-text user identifier retained for parity with the source schema (often the username or
* an external system ID). The {@link #appUser} FK is authoritative; this is for display /
* cross-system reconciliation only.
*/
@Column(name = "dsc_user_id", length = 64)
private String dscUserId;
@Column(name = "dsc_serial_no", length = 128, nullable = false, unique = true)
private String dscSerialNo;
@Column(name = "dsc_name", length = 255)
private String dscName;
/** Base64-encoded SubjectPublicKeyInfo. Used at login-factor time to verify the signature. */
@Column(name = "dsc_public_key", columnDefinition = "TEXT", nullable = false)
private String dscPublicKey;
@Column(name = "dsc_expiry_date", nullable = false)
private LocalDate dscExpiryDate;
@Column(name = "date_of_submission", nullable = false)
private OffsetDateTime dateOfSubmission;
@Enumerated(EnumType.STRING)
@Column(name = "dsc_type", length = 32, nullable = false)
private DscType dscType;
@Column(name = "common_name", length = 255)
private String commonName;
/** Cert subject DN. */
@Column(name = "issued_to", length = 512)
private String issuedTo;
/** Cert issuer DN — useful in the UI ("eMudhra Sub CA for Class 3 ..."). */
@Column(name = "issued_by", length = 512)
private String issuedBy;
@Enumerated(EnumType.STRING)
@Column(name = "cert_status", length = 16, nullable = false)
private CertStatus certStatus;
@Column(name = "revoked_on")
private OffsetDateTime revokedOn;
@Column(name = "revoked_by")
private Long revokedBy;
@Column(name = "revoke_reason", length = 255)
private String revokeReason;
@Column(name = "is_active", nullable = false)
private boolean active;
@Column(name = "is_deleted", nullable = false)
private boolean deleted;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "modified_by")
private Long modifiedBy;
@Column(name = "modified_on")
private OffsetDateTime modifiedOn;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted_at")
private OffsetDateTime deletedAt;
@Version
@Column(name = "version", nullable = false)
private Integer version;
@PrePersist
private void prePersist() {
final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
if (createdAt == null) {
createdAt = now;
}
if (dateOfSubmission == null) {
dateOfSubmission = now;
}
if (certStatus == null) {
certStatus = CertStatus.ACTIVE;
}
}
/**
* Convenience flip: returns true if this row is the kind we'd present in the UI / accept as an
* MFA factor. Cheaper to call than re-checking the three flags everywhere.
*/
public boolean isUsable() {
return active && !deleted && certStatus == CertStatus.ACTIVE;
}
}
@@ -0,0 +1,50 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BankUserDscRepository extends JpaRepository<BankUserDsc, Long> {
/** All non-deleted rows for the user, newest first — admin view + self list. */
List<BankUserDsc> findByAppUser_IdAndDeletedFalseOrderByIdDesc(Long userId);
/** Only rows usable as an MFA factor (active + not deleted + cert_status=ACTIVE). */
List<BankUserDsc> findByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatus(Long userId, CertStatus certStatus);
/** Lookup by globally-unique cert serial — used at login-verify time. */
Optional<BankUserDsc> findByDscSerialNo(String dscSerialNo);
/** Used at login-verify time to confirm "this user enrolled this serial". */
Optional<BankUserDsc> findByAppUser_IdAndDscSerialNo(Long userId, String dscSerialNo);
/**
* Login gate: returns true if the user has at least one usable DSC mapping that's also not
* past its notAfter date. The {@code dscExpiryDate &gt;= today} clause prevents 2FA-gating a
* user who's enrolled but whose cert silently expired between log-ins — they'd otherwise be
* stuck in a verification loop with a cert the helper would sign with but the server would
* accept (the verify side filters too, but the gate is what kicks the user into the DSC flow
* in the first place).
*/
boolean existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
Long userId, CertStatus certStatus, LocalDate today);
}
@@ -0,0 +1,46 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
/**
* Lifecycle of a DSC mapping row in m_bank_user_dsc.
*
* <ul>
* <li>{@link #ACTIVE} — enrolled and usable as an MFA factor.</li>
* <li>{@link #REVOKED} — voluntarily removed by user or admin; kept for audit.</li>
* <li>{@link #EXPIRED} — cert past notAfter date; flipped by a scheduled job, not on read.</li>
* </ul>
*/
public enum CertStatus {
ACTIVE,
REVOKED,
EXPIRED;
public static CertStatus fromString(final String s) {
if (s == null) {
return null;
}
for (final CertStatus t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -0,0 +1,133 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One-shot challenge nonce for a DSC enroll or login-2FA cycle.
*
* <p>The {@link #randomNo} is the public handle the JNLP file and the helper callback use; it must
* be a sufficiently long random value (UUID v4) to make guessing infeasible. The {@link #challenge}
* is the raw bytes the helper actually signs in LOGIN_2FA flow.
*
* <p>The row is single-use: once {@code status} flips to VERIFIED or FAILED, further callbacks
* referencing the same randomNo must be rejected. Cleanup of EXPIRED/FAILED rows is left to a
* scheduled job (TBD) — we keep them for audit.
*/
@Entity
@Table(name = "m_dsc_challenge")
@Getter
@Setter
@NoArgsConstructor
public class DscChallenge extends AbstractPersistableCustom<Long> {
@Column(name = "random_no", length = 64, nullable = false, unique = true)
private String randomNo;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id")
private AppUser appUser;
@Enumerated(EnumType.STRING)
@Column(name = "purpose", length = 32, nullable = false)
private DscChallengePurpose purpose;
/** Random 32-byte payload (hex-encoded) the helper signs with the user's DSC private key. */
@Column(name = "challenge", length = 128, nullable = false)
private String challenge;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private DscChallengeStatus status;
/** Serial returned by the helper — set in callback; the server verifies it matches the user's enrolled cert. */
@Column(name = "cert_serial_returned", length = 128)
private String certSerialReturned;
/** Base64-encoded RSA-SHA256 signature returned by the helper. NULL for ENROLL purpose. */
@Column(name = "signature_b64", columnDefinition = "TEXT")
private String signatureB64;
/** FK populated on successful LOGIN_2FA verify — tells us *which* DSC row was used. */
@Column(name = "dsc_id")
private Long dscId;
@Column(name = "error_message", length = 512)
private String errorMessage;
@Column(name = "attempts", nullable = false)
private int attempts;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
@Column(name = "verified_at")
private OffsetDateTime verifiedAt;
/**
* TFA access token minted during a successful LOGIN_2FA verify. Held on the row until the
* browser claims it via {@code POST /v1/login/dsc/{rn}/claim}; that call clears the field by
* stamping {@link #tfaTokenClaimedAt}. Single-use.
*/
@Column(name = "tfa_token", length = 255)
private String tfaToken;
@Column(name = "tfa_token_claimed_at")
private OffsetDateTime tfaTokenClaimedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "ip_address", length = 64)
private String ipAddress;
@Column(name = "user_agent", length = 512)
private String userAgent;
@PrePersist
private void prePersist() {
if (createdAt == null) {
createdAt = OffsetDateTime.now(ZoneOffset.UTC);
}
if (status == null) {
status = DscChallengeStatus.PENDING;
}
}
/** True if the row is past its TTL — used by the verify endpoint before any signature math. */
public boolean isExpired() {
return expiresAt != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(expiresAt);
}
}
@@ -0,0 +1,48 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
/**
* Why a particular {@code m_dsc_challenge} row was created.
*
* <ul>
* <li>{@link #ENROLL} — helper returns cert metadata only; server upserts m_bank_user_dsc.</li>
* <li>{@link #LOGIN_2FA} — helper signs the challenge nonce; server verifies the signature
* against the stored public key and grants TWOFACTOR_AUTHENTICATED on success.</li>
* <li>{@link #APPROVAL} — same as LOGIN_2FA but bound to a specific business action.
* Future use; persistence layer is ready.</li>
* </ul>
*/
public enum DscChallengePurpose {
ENROLL,
LOGIN_2FA,
APPROVAL;
public static DscChallengePurpose fromString(final String s) {
if (s == null) {
return null;
}
for (final DscChallengePurpose t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -0,0 +1,28 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DscChallengeRepository extends JpaRepository<DscChallenge, Long> {
/** Single lookup used by every callback / status / verify endpoint. */
Optional<DscChallenge> findByRandomNo(String randomNo);
}
@@ -0,0 +1,38 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
public enum DscChallengeStatus {
PENDING,
VERIFIED,
FAILED,
EXPIRED;
public static DscChallengeStatus fromString(final String s) {
if (s == null) {
return null;
}
for (final DscChallengeStatus t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -0,0 +1,36 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.domain;
public enum DscType {
SIGNING,
ENCRYPTION;
public static DscType fromString(final String s) {
if (s == null) {
return null;
}
for (final DscType t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
File diff suppressed because it is too large Load Diff
@@ -152,6 +152,21 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/verify")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/generate")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/generate")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/verify")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/verify")).permitAll()
// DSC enrollment — nonce-gated endpoints the JWS helper and the browser hit
// without a Fineract auth header. The randomNo is the capability token.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/enroll/*.jnlp")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/dsc/enroll/*/callback")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/enroll/*/status")).permitAll()
// Helper JAR — Java Web Start fetches this without an auth header. The JAR is
// (self-)signed; that signature is what the JVM enforces, not Fineract auth.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/helper.jar")).permitAll()
// DSC login factor — JNLP / helper-verify / status are nonce-gated. The
// /challenge create endpoint is NOT permitAll'd here because it must be reached
// by a basic-authed (but not-yet-2FA'd) user — handled by the twofactor matcher
// block below for /api/*/login/dsc which only requires fullyAuthenticated.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/login/dsc/*.jnlp")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/*/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/login/dsc/*/status")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
// businessdate // businessdate
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*")) .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*"))
@@ -348,6 +363,13 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated() .requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
// POST /login/dsc/challenge requires a basic-authed user (no TFA yet) — same
// posture as /twofactor/validate. The other /login/dsc/** paths are nonce-gated
// permitAll above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/challenge")).fullyAuthenticated()
// /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()
.requestMatchers(API_MATCHER.matcher("/api/**")) .requestMatchers(API_MATCHER.matcher("/api/**"))
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0]))); .access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable) }).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
@@ -402,7 +424,12 @@ public class SecurityConfig {
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class); .getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class); .getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository); // 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);
} }
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() { public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -291,7 +291,12 @@ public class AuthorizationServerConfig {
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class); .getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class); .getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository); // 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);
} }
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() { public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat; import java.util.HexFormat;
import org.apache.fineract.infrastructure.email.service.EmailSenderService; import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService; import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
@@ -57,12 +59,17 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
private static final String STATUS_VERIFIED = "VERIFIED"; private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXPIRED = "EXPIRED"; private static final String STATUS_EXPIRED = "EXPIRED";
private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm");
@Value("${fineract.security.otp.ttl-seconds:300}") @Value("${fineract.security.otp.ttl-seconds:300}")
private int ttlSeconds; private int ttlSeconds;
@Value("${fineract.security.otp.max-attempts:5}") @Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts; private int maxAttempts;
@Value("${fineract.email.from-name:CBSnexora}")
private String appName;
private final OtpLogRepository otpLogRepository; private final OtpLogRepository otpLogRepository;
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final EmailSenderService emailSenderService; private final EmailSenderService emailSenderService;
@@ -120,7 +127,7 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
// Render the branded template — same look and feel for OTP, welcome, password-reset, alerts. // Render the branded template — same look and feel for OTP, welcome, password-reset, alerts.
final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes); final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes);
final String subject = "Your CBSnexora Sign-in OTP"; final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP) + ")";
final boolean sent = emailSenderService.sendHtml(email, subject, html); final boolean sent = emailSenderService.sendHtml(email, subject, html);
if (sent) { if (sent) {
@@ -34,6 +34,18 @@ public interface ClientKycWritePlatformService {
*/ */
void initiateKycInline(Client client, String kycJson); void initiateKycInline(Client client, String kycJson);
/**
* Create-or-update inline counterpart to {@link #initiateKycInline} — used by the edit-client
* flow so the {@code kyc} sub-object on a {@code PUT /clients/{id}} behaves symmetrically with
* the create-time inline behaviour. If a KYC row already exists for this client, the editable
* fields are merged in and the status is bumped to IN_PROGRESS (matching the existing
* {@link #updateKyc} semantics). If no row exists yet, it falls back to {@link #initiateKycInline}.
*
* <p>Does NOT trigger PAN / Aadhaar verification — those still go through the dedicated
* verify-* endpoints. This method only keeps the KYC row's static fields in sync.
*/
void upsertKycInline(Client client, String kycJson);
/** Update PAN / Aadhaar / GSTIN details; triggers IN_PROGRESS status. */ /** Update PAN / Aadhaar / GSTIN details; triggers IN_PROGRESS status. */
CommandProcessingResult updateKyc(Long clientId, Long kycId, JsonCommand command); CommandProcessingResult updateKyc(Long clientId, Long kycId, JsonCommand command);
@@ -937,6 +937,55 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
} }
@Override
@Transactional
public void upsertKycInline(final Client client, final String kycJson) {
// No row yet → defer to the standard inline create path. This is the case where the user
// is editing a client who never had a KYC row created (e.g. created before KYC was rolled
// out, or KYC was skipped at create time).
if (!kycRepositoryWrapper.existsByClientId(client.getId())) {
LOG.info("upsertKycInline: no KYC row for client {}. Falling back to initiate.", client.getId());
initiateKycInline(client, kycJson);
return;
}
// Row exists → merge editable fields. Validation mirrors the explicit /kyc update endpoint
// so the user gets the same error messages whether they go through edit-client or
// edit-kyc directly.
final ClientKycCommand kycCommand = deserializer.commandFromApiJson(
kycJson, ClientKycApiConstants.UPDATE_REQUEST_PARAMS);
kycCommand.validateForUpdate();
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
final ClientKycStatus currentStatus = kyc.getKycStatus();
final Map<String, Object> changes = kyc.update(jsonCommand);
if (changes.isEmpty()) {
return; // no-op — edit-client sent the same KYC values that are already on the row
}
// 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")) {
kyc.resetPanVerification();
}
if (!kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
}
kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
}
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
private CommandProcessingResult buildResult(final JsonCommand command, final Client client, private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
@@ -1142,6 +1142,21 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
extractAndCreateClientNonPerson(clientForUpdate, command); extractAndCreateClientNonPerson(clientForUpdate, command);
} }
} }
// ── Inline KYC upsert (optional) ─────────────────────────────────
// Mirror of the create-side block at the top of createClient(): if the edit payload
// contains a `kyc` sub-object, hand it to the KYC service so the row is created (if
// missing) or its editable fields are updated (status bumps to IN_PROGRESS).
// Verification still requires the UI to call /verify-pan + /verify-aadhaar.
final JsonElement updateRoot = this.fromApiJsonHelper.parse(command.json());
if (updateRoot.isJsonObject() && updateRoot.getAsJsonObject().has(ClientApiConstants.kycParamName)) {
final JsonElement kycElement = updateRoot.getAsJsonObject().get(ClientApiConstants.kycParamName);
if (kycElement != null && !kycElement.isJsonNull()) {
this.clientKycWritePlatformService.upsertKycInline(clientForUpdate,
this.fromApiJsonHelper.toJson(kycElement));
}
}
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) // .withCommandId(command.commandId()) //
.withEntityExternalId(clientForUpdate.getExternalId()) // .withEntityExternalId(clientForUpdate.getExternalId()) //
@@ -31,4 +31,27 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long>, JpaSpec
AppUser findAppUserByName(@Param("username") String username); AppUser findAppUserByName(@Param("username") String username);
Collection<AppUser> findByOfficeId(Long officeId); Collection<AppUser> findByOfficeId(Long officeId);
/**
* Case-insensitive email lookup. Used by the createUser flow to surface a clean
* "email already registered" error before hitting the DB unique constraint.
*/
@Query("SELECT u FROM AppUser u WHERE LOWER(u.email) = LOWER(:email)")
java.util.Optional<AppUser> findByEmailIgnoreCase(@Param("email") String email);
/**
* Same as {@link #findByEmailIgnoreCase} but excludes a given user id — used by the updateUser
* flow so a user editing their profile but keeping the same email isn't told it's taken.
*/
@Query("SELECT u FROM AppUser u WHERE LOWER(u.email) = LOWER(:email) AND u.id <> :excludeUserId")
java.util.Optional<AppUser> findByEmailIgnoreCaseAndIdNot(@Param("email") String email,
@Param("excludeUserId") Long excludeUserId);
/** Exact-match mobile lookup — used by the createUser flow's pre-flight unique check. */
java.util.Optional<AppUser> findByMobileNo(String mobileNo);
/** Same as {@link #findByMobileNo} but excludes a given user id, for the update flow. */
@Query("SELECT u FROM AppUser u WHERE u.mobileNo = :mobileNo AND u.id <> :excludeUserId")
java.util.Optional<AppUser> findByMobileNoAndIdNot(@Param("mobileNo") String mobileNo,
@Param("excludeUserId") Long excludeUserId);
} }
@@ -0,0 +1,213 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
import org.apache.fineract.useradministration.dsc.service.DscEnrollmentWriteService;
import org.apache.fineract.useradministration.dsc.service.DscReadPlatformService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Digital Signature Certificate (DSC) mappings + enrollment lifecycle.
*
* <ul>
* <li>POST /v1/dsc/enroll/start — kicks off enrollment; returns randomNo + jnlpUrl</li>
* <li>GET /v1/dsc/enroll/{randomNo}.jnlp — streams the JNLP that launches the helper</li>
* <li>POST /v1/dsc/enroll/{randomNo}/callback — helper POSTs cert details here</li>
* <li>GET /v1/dsc/enroll/{randomNo}/status — UI polls for completion</li>
* <li>GET /v1/dsc/me — current user's DSC mappings (masked serials)</li>
* <li>GET /v1/dsc/{dscId} — single mapping, unmasked</li>
* <li>DELETE /v1/dsc/{dscId} — soft-revoke</li>
* </ul>
*
* <p>The .jnlp and /callback endpoints are nonce-gated (anyone with the randomNo can hit them).
* Everything else requires the authenticated user.
*/
@Path("/v1/dsc")
@Component
@Tag(name = "DSC", description = "Digital Signature Certificate enrollment and management")
@RequiredArgsConstructor
public class DscApiResource {
private final DscEnrollmentWriteService enrollmentService;
private final DscReadPlatformService readService;
/**
* Filesystem path where the signed helper JAR is dropped. Default lets ops put it next to the
* Fineract install so a redeploy of the .jar (after re-signing with a new cert) doesn't require
* rebuilding the Spring app — just {@code cp} into this path and the next JNLP launch picks it up.
*/
@Value("${fineract.dsc.helper.jar.path:${user.home}/.cbsnexora/dsc-helper.jar}")
private String helperJarPath;
@POST
@Path("/enroll/start")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a DSC enrollment challenge for the current user. Returns a randomNo "
+ "plus the .jnlp URL the UI should trigger.")
public DscEnrollStartResponse start(@Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String ip = request.getRemoteAddr();
final String ua = request.getHeader("User-Agent");
return enrollmentService.start(codebase, ip, ua);
}
@GET
@Path("/enroll/{randomNo}.jnlp")
@Produces("application/x-java-jnlp-file")
@Operation(summary = "Streams the JNLP file. Nonce-gated; no auth header required so the browser "
+ "can hand the URL directly to OpenWebStart.")
public Response jnlp(@PathParam("randomNo") final String randomNo, @Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String body = enrollmentService.renderJnlp(randomNo, codebase);
return Response.ok(body, "application/x-java-jnlp-file")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-enroll.jnlp\"")
.header("Cache-Control", "no-store")
.build();
}
@POST
@Path("/enroll/{randomNo}/callback")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Helper-driven endpoint. Accepts the base64 cert; server parses, validates "
+ "and upserts the m_bank_user_dsc row.")
public DscChallengeStatusResponse callback(@PathParam("randomNo") final String randomNo,
final DscEnrollCallbackRequest req) {
return enrollmentService.callback(randomNo, req);
}
@GET
@Path("/enroll/{randomNo}/status")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "UI polls this. Returns PENDING until helper completes; then VERIFIED with "
+ "the saved DSC row, or FAILED / EXPIRED with errorMessage.")
public DscChallengeStatusResponse status(@PathParam("randomNo") final String randomNo) {
return enrollmentService.getStatus(randomNo);
}
@GET
@Path("/me")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Current user's DSC mappings (serial numbers are masked).")
public List<BankUserDscData> listMine() {
return readService.listForCurrentUser();
}
@GET
@Path("/user/{userId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "DSC mappings for an arbitrary user — used by the admin view inside the "
+ "user-details page. Returns the same shape as /me with serials masked. Read-only.")
public List<BankUserDscData> listForUser(@PathParam("userId") final Long userId) {
return readService.listForUser(userId);
}
@GET
@Path("/{dscId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Single DSC mapping (full serial). Returns 404 if not owned by the caller.")
public BankUserDscData getById(@PathParam("dscId") final Long dscId) {
return readService.getById(dscId);
}
@DELETE
@Path("/{dscId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Soft-revokes a DSC mapping. Sets cert_status=REVOKED and is_deleted=true.")
public Response revoke(@PathParam("dscId") final Long dscId, @QueryParam("reason") final String reason) {
readService.revoke(dscId, reason);
return Response.noContent().build();
}
@GET
@Path("/helper.jar")
@Produces("application/java-archive")
@Operation(summary = "Streams the (self-)signed Java helper JAR referenced by the JNLP. "
+ "Public — Java Web Start fetches without an auth header. The JAR file itself is "
+ "code-signed; that's what the JVM enforces, not Fineract auth.")
public Response helperJar() {
final java.nio.file.Path jar = java.nio.file.Path.of(helperJarPath);
if (!java.nio.file.Files.isRegularFile(jar) || !java.nio.file.Files.isReadable(jar)) {
throw new NotFoundException("DSC helper JAR not deployed at " + helperJarPath);
}
final byte[] bytes;
try {
bytes = java.nio.file.Files.readAllBytes(jar);
} catch (final IOException e) {
throw new RuntimeException("Failed to read DSC helper JAR", e);
}
return Response.ok(bytes, "application/java-archive")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-helper.jar\"")
.header("Cache-Control", "no-store")
.build();
}
/**
* Builds the public base URL the JNLP / helper should call back into. Honours
* {@code X-Forwarded-Proto} / {@code X-Forwarded-Host} so reverse-proxied deployments resolve
* the external hostname instead of the internal one. Includes the servlet context path because
* Fineract is typically mounted at {@code /fineract-provider}.
*/
private static String deriveCodebase(final HttpServletRequest request) {
final String fwdProto = request.getHeader("X-Forwarded-Proto");
final String fwdHost = request.getHeader("X-Forwarded-Host");
final String scheme = fwdProto != null && !fwdProto.isBlank() ? fwdProto : request.getScheme();
final String host;
final int port;
if (fwdHost != null && !fwdHost.isBlank()) {
final int colon = fwdHost.indexOf(':');
host = colon < 0 ? fwdHost : fwdHost.substring(0, colon);
port = colon < 0 ? -1 : Integer.parseInt(fwdHost.substring(colon + 1));
} else {
host = request.getServerName();
port = request.getServerPort();
}
final StringBuilder sb = new StringBuilder(scheme).append("://").append(host);
final boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80) || port < 0;
if (!defaultPort) {
sb.append(':').append(port);
}
sb.append(request.getContextPath());
return sb.toString();
}
}
@@ -0,0 +1,140 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
import org.apache.fineract.useradministration.dsc.service.DscLoginChallengeService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* DSC-based login factor. After primary basic-auth succeeds, a user who has an enrolled DSC may
* pick the DSC option instead of SMS/EMAIL. The flow:
*
* <ul>
* <li>POST /v1/login/dsc/challenge — create challenge (requires basic auth, NOT TFA)</li>
* <li>GET /v1/login/dsc/{rn}.jnlp — JNLP for the helper (nonce-gated)</li>
* <li>POST /v1/login/dsc/{rn}/verify — helper POSTs signature (nonce-gated)</li>
* <li>GET /v1/login/dsc/{rn}/status — UI polls (nonce-gated)</li>
* </ul>
*
* <p>On successful verify, the response includes a {@code tfaToken} the UI immediately puts into
* the {@code Fineract-Platform-TFA-Token} header. Status polling never echoes the token back —
* the verify response is its only emission.
*/
@Path("/v1/login/dsc")
@Component
@ConditionalOnProperty("fineract.security.2fa.enabled")
@Tag(name = "Login DSC", description = "DSC-based two-factor authentication")
@RequiredArgsConstructor
public class LoginDscApiResource {
private final DscLoginChallengeService loginChallengeService;
@POST
@Path("/challenge")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a DSC login challenge for the basic-authenticated user. Returns "
+ "randomNo + jnlpUrl + statusUrl. Caller does NOT need TWOFACTOR_AUTHENTICATED.")
public DscLoginChallengeStartResponse challenge(@Context final HttpServletRequest request) {
return loginChallengeService.start(deriveCodebase(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
}
@GET
@Path("/{randomNo}.jnlp")
@Produces("application/x-java-jnlp-file")
@Operation(summary = "Streams the JNLP that launches the helper in SIGN_CHALLENGE mode.")
public Response jnlp(@PathParam("randomNo") final String randomNo, @Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String body = loginChallengeService.renderJnlp(randomNo, codebase);
return Response.ok(body, "application/x-java-jnlp-file")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-login.jnlp\"")
.header("Cache-Control", "no-store")
.build();
}
@POST
@Path("/{randomNo}/verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Helper POSTs cert serial + signature. Server verifies, then mints a TFA "
+ "access token returned in the response.")
public DscLoginStatusResponse verify(@PathParam("randomNo") final String randomNo, final DscLoginVerifyRequest req) {
return loginChallengeService.verify(randomNo, req);
}
@GET
@Path("/{randomNo}/status")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "UI polls this. Returns PENDING/VERIFIED/FAILED/EXPIRED. Never echoes the "
+ "TFA token — the browser fetches it via /claim once status flips to VERIFIED.")
public DscLoginStatusResponse status(@PathParam("randomNo") final String randomNo) {
return loginChallengeService.getStatus(randomNo);
}
@POST
@Path("/{randomNo}/claim")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Browser claims the minted TFA token after seeing status=VERIFIED. "
+ "Requires basic auth (caller must own the challenge). Single-use — the stored token "
+ "is cleared after this returns.")
public DscLoginStatusResponse claim(@PathParam("randomNo") final String randomNo) {
return loginChallengeService.claim(randomNo);
}
/** Same codebase resolution as {@link DscApiResource} — honours reverse-proxy headers. */
private static String deriveCodebase(final HttpServletRequest request) {
final String fwdProto = request.getHeader("X-Forwarded-Proto");
final String fwdHost = request.getHeader("X-Forwarded-Host");
final String scheme = fwdProto != null && !fwdProto.isBlank() ? fwdProto : request.getScheme();
final String host;
final int port;
if (fwdHost != null && !fwdHost.isBlank()) {
final int colon = fwdHost.indexOf(':');
host = colon < 0 ? fwdHost : fwdHost.substring(0, colon);
port = colon < 0 ? -1 : Integer.parseInt(fwdHost.substring(colon + 1));
} else {
host = request.getServerName();
port = request.getServerPort();
}
final StringBuilder sb = new StringBuilder(scheme).append("://").append(host);
final boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80) || port < 0;
if (!defaultPort) {
sb.append(':').append(port);
}
sb.append(request.getContextPath());
return sb.toString();
}
}
@@ -0,0 +1,98 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
/**
* Wire representation of a row in {@code m_bank_user_dsc}. Public key is intentionally omitted
* from the default view — only the cert metadata the UI needs to display. Serial is sent masked
* (last 6 chars visible) so list views don't leak full serials.
*/
@Getter
@Builder
public class BankUserDscData {
private final Long id;
private final Long appUserId;
private final String username;
private final String dscName;
private final String dscSerialNoMasked;
private final String dscSerialNoFull;
private final String dscType;
private final String commonName;
private final String issuedTo;
private final String issuedBy;
private final LocalDate dscExpiryDate;
private final OffsetDateTime dateOfSubmission;
private final String certStatus;
private final OffsetDateTime revokedOn;
private final String revokeReason;
/** {@code maskSerial = true} for list views, false for the single-row detail endpoint. */
public static BankUserDscData from(final BankUserDsc d, final boolean maskSerial) {
return BankUserDscData.builder()
.id(d.getId())
.appUserId(d.getAppUser() != null ? d.getAppUser().getId() : null)
.username(d.getAppUser() != null ? d.getAppUser().getUsername() : null)
.dscName(d.getDscName())
.dscSerialNoMasked(mask(d.getDscSerialNo()))
.dscSerialNoFull(maskSerial ? null : d.getDscSerialNo())
.dscType(d.getDscType() != null ? d.getDscType().name() : null)
.commonName(d.getCommonName())
.issuedTo(d.getIssuedTo())
.issuedBy(d.getIssuedBy())
.dscExpiryDate(d.getDscExpiryDate())
.dateOfSubmission(d.getDateOfSubmission())
.certStatus(effectiveStatus(d))
.revokedOn(d.getRevokedOn())
.revokeReason(d.getRevokeReason())
.build();
}
/**
* Stored {@code cert_status} is authoritative for REVOKED, but ACTIVE rows whose
* {@code dscExpiryDate} has passed get reported as EXPIRED on the wire. The DB row stays
* ACTIVE until a maintenance sweep flips it, but the UI never sees a stale ACTIVE for an
* expired cert.
*/
private static String effectiveStatus(final BankUserDsc d) {
final CertStatus stored = d.getCertStatus();
if (stored == null) {
return null;
}
if (stored == CertStatus.ACTIVE && d.getDscExpiryDate() != null
&& d.getDscExpiryDate().isBefore(LocalDate.now())) {
return CertStatus.EXPIRED.name();
}
return stored.name();
}
private static String mask(final String s) {
if (s == null || s.length() <= 6) {
return s;
}
return "•".repeat(Math.min(s.length() - 6, 12)) + s.substring(s.length() - 6);
}
}
@@ -0,0 +1,36 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* UI polls for this. {@code status} is {@code PENDING} until the helper completes, then flips to
* {@code VERIFIED} (with {@code dsc} populated on enrollment success) or {@code FAILED}/{@code EXPIRED}.
*/
@Getter
@Builder
public class DscChallengeStatusResponse {
private final String randomNo;
private final String status;
private final String errorMessage;
private final BankUserDscData dsc;
}
@@ -0,0 +1,45 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Body the Java helper POSTs back to {@code /v1/dsc/enroll/{randomNo}/callback}.
*
* <p>{@link #certB64} is the base64-encoded X.509 DER cert — server parses it as the source of
* truth for serial, issuer, subject, notAfter, and the public key. {@link #dscName} is an
* optional user-provided label.
*/
@Getter
@Setter
@NoArgsConstructor
public class DscEnrollCallbackRequest {
/** Base64-encoded X.509 DER bytes. */
private String certB64;
/** Optional user-facing label, e.g. "Aditya's Primary Token". */
private String dscName;
/** Optional — SIGNING or ENCRYPTION. Defaults to SIGNING when null. */
private String dscType;
}
@@ -0,0 +1,52 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* Returned to the UI when an enrollment cycle is created. The UI uses {@code jnlpUrl} to trigger
* the browser download that launches the helper; it then polls {@code statusUrl} for completion.
*/
@Getter
@Builder
public class DscEnrollStartResponse {
private final String randomNo;
private final String jnlpUrl;
private final String statusUrl;
private final int expiresInSeconds;
/**
* Pre-stringified JSON the helper expects in its single launch argument. The UI embeds this
* verbatim into a locally-generated JNLP file's {@code <argument>} element, so JWS never has
* to fetch a JNLP from the server.
*
* <p>Includes the absolute {@code callbackUrl} the helper POSTs back to + the
* {@code tenantIdentifier} the helper uses as a request header.
*/
private final String helperArgsJson;
/**
* Relative URL the UI XHRs to fetch the helper JAR bytes. The interceptor adds the auth
* + tenant headers so the request goes through normal channels — JWS is never involved.
*/
private final String helperJarUrl;
}
@@ -0,0 +1,38 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class DscLoginChallengeStartResponse {
private final String randomNo;
private final String jnlpUrl;
private final String statusUrl;
private final int expiresInSeconds;
/** See {@link DscEnrollStartResponse#getHelperArgsJson()}. Same shape for the login flow. */
private final String helperArgsJson;
/** See {@link DscEnrollStartResponse#getHelperJarUrl()}. */
private final String helperJarUrl;
}
@@ -0,0 +1,38 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* Polled by the login UI. On {@code VERIFIED}, {@link #tfaToken} carries the {@code TFAccessToken}
* the UI puts in the {@code Fineract-Platform-TFA-Token} header for subsequent calls — same shape
* as what {@code /v1/twofactor/validate} returns for OTP-based flows.
*/
@Getter
@Builder
public class DscLoginStatusResponse {
private final String randomNo;
private final String status;
private final String errorMessage;
private final String tfaToken;
private final Long tfaTokenLiveSeconds;
}
@@ -0,0 +1,39 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Body the Java helper POSTs to {@code /v1/login/dsc/{randomNo}/verify}. Helper-driven, no Fineract
* auth header — the {@link #certSerial} + signature pair authenticates the user.
*/
@Getter
@Setter
@NoArgsConstructor
public class DscLoginVerifyRequest {
/** Hex serial of the DSC the user signed with — must match the user's enrolled mapping. */
private String certSerial;
/** Base64-encoded RSA-SHA256 signature over the challenge bytes. */
private String signatureB64;
}
@@ -0,0 +1,32 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a DSC challenge row is in a non-PENDING state — already verified, failed, or
* expired — and a callback tries to use it again. Single-shot enforcement.
*/
public class DscChallengeInvalidException extends AbstractPlatformDomainRuleException {
public DscChallengeInvalidException(final String reason) {
super("error.msg.dsc.challenge.invalid", reason, reason);
}
}
@@ -0,0 +1,28 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class DscChallengeNotFoundException extends AbstractPlatformResourceNotFoundException {
public DscChallengeNotFoundException(final String randomNo) {
super("error.msg.dsc.challenge.not.found", "DSC challenge " + randomNo + " not found", randomNo);
}
}
@@ -0,0 +1,28 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class DscNotFoundException extends AbstractPlatformResourceNotFoundException {
public DscNotFoundException(final Long dscId) {
super("error.msg.dsc.not.found", "DSC mapping " + dscId + " not found", dscId);
}
}
@@ -0,0 +1,34 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* The DSC serial is globally unique — one cert in the world has one serial. If a user tries to
* enroll a cert already bound to a different appuser, we reject without revealing which user owns
* it.
*/
public class DscSerialAlreadyEnrolledException extends AbstractPlatformDomainRuleException {
public DscSerialAlreadyEnrolledException(final String serial) {
super("error.msg.dsc.serial.already.enrolled",
"This Digital Signature Certificate is already enrolled in the system", serial);
}
}
@@ -0,0 +1,34 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
public interface DscEnrollmentWriteService {
DscEnrollStartResponse start(String codebase, String ipAddress, String userAgent);
String renderJnlp(String randomNo, String codebase);
DscChallengeStatusResponse callback(String randomNo, DscEnrollCallbackRequest request);
DscChallengeStatusResponse getStatus(String randomNo);
}
@@ -0,0 +1,255 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
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.domain.DscChallenge;
import org.apache.fineract.useradministration.dsc.domain.DscChallengePurpose;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeStatus;
import org.apache.fineract.useradministration.dsc.domain.DscType;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeInvalidException;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService {
private static final Logger LOG = LoggerFactory.getLogger(DscEnrollmentWriteServiceImpl.class);
private final PlatformSecurityContext securityContext;
private final DscChallengeRepository challengeRepository;
private final BankUserDscRepository dscRepository;
private final X509CertParser certParser;
private final DscJnlpGenerator jnlpGenerator;
@Value("${fineract.dsc.challenge.ttl-seconds:300}")
private int challengeTtlSeconds;
@Override
@Transactional
public DscEnrollStartResponse start(final String codebase, final String ipAddress, final String userAgent) {
final AppUser user = securityContext.authenticatedUser();
final String randomNo = UUID.randomUUID().toString();
final DscChallenge ch = new DscChallenge();
ch.setRandomNo(randomNo);
ch.setAppUser(user);
ch.setPurpose(DscChallengePurpose.ENROLL);
// ENROLL flow doesn't sign anything — challenge column is just a placeholder marker.
ch.setChallenge("ENROLL");
ch.setStatus(DscChallengeStatus.PENDING);
ch.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(challengeTtlSeconds));
ch.setIpAddress(ipAddress);
ch.setUserAgent(userAgent);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enroll started — user={}, randomNo={}, ttl={}s", user.getUsername(), randomNo, challengeTtlSeconds);
// Args the helper expects in its launch argument. The UI embeds this into a JNLP it
// generates locally — JWS never fetches a JNLP from the server, only reads it from disk.
final java.util.Map<String, Object> helperArgs = new java.util.LinkedHashMap<>();
helperArgs.put("randomNo", randomNo);
helperArgs.put("mode", "ENROLL");
helperArgs.put("callbackUrl", codebase + "/api/v1/dsc/enroll/" + randomNo + "/callback");
helperArgs.put("title", "Map Digital Signature Certificate");
final String tenant = currentTenantIdentifier();
if (tenant != null) {
helperArgs.put("tenantIdentifier", tenant);
}
final String helperArgsJson = new com.google.gson.Gson().toJson(helperArgs);
return DscEnrollStartResponse.builder()
.randomNo(randomNo)
.jnlpUrl("/v1/dsc/enroll/" + randomNo + ".jnlp")
.statusUrl("/v1/dsc/enroll/" + randomNo + "/status")
.expiresInSeconds(challengeTtlSeconds)
.helperArgsJson(helperArgsJson)
.helperJarUrl("/v1/dsc/helper.jar")
.build();
}
@Override
@Transactional(readOnly = true)
public String renderJnlp(final String randomNo, final String codebase) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING || ch.isExpired()) {
throw new DscChallengeInvalidException("Challenge is no longer accepting helper launches");
}
final DscJnlpGenerator.JnlpContext ctx = new DscJnlpGenerator.JnlpContext();
ctx.randomNo = randomNo;
ctx.mode = "ENROLL";
ctx.callbackUrl = codebase + "/api/v1/dsc/enroll/" + randomNo + "/callback";
ctx.codebase = codebase;
ctx.tenantIdentifier = currentTenantIdentifier();
ctx.title = "Map Digital Signature Certificate";
ctx.description = "Reads your DSC and maps it to your CBSnexora user account.";
return jnlpGenerator.generate(ctx);
}
/**
* Tenant identifier the request is running under — resolved by {@code TenantAwareBasicAuthentication
* Filter} earlier in the chain and stashed in the thread-local context. We embed this in the
* JNLP so JWS's raw GET for the helper JAR can resolve the tenant via {@code ?tenantIdentifier=}
* (the filter already accepts that as a fallback to the header) and so the helper itself can
* set the {@code Fineract-Platform-TenantId} header on its callback POST.
*/
private String currentTenantIdentifier() {
try {
return org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil.getTenant().getTenantIdentifier();
} catch (final Exception e) {
LOG.warn("Could not resolve current tenant for JNLP — JWS / helper may fail without ?tenantIdentifier", e);
return null;
}
}
@Override
@Transactional
public DscChallengeStatusResponse callback(final String randomNo, final DscEnrollCallbackRequest req) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING) {
throw new DscChallengeInvalidException("Challenge already terminal: " + ch.getStatus());
}
if (ch.isExpired()) {
ch.setStatus(DscChallengeStatus.EXPIRED);
challengeRepository.saveAndFlush(ch);
throw new DscChallengeInvalidException("Challenge expired");
}
if (req == null || req.getCertB64() == null || req.getCertB64().isBlank()) {
failChallenge(ch, "certB64 missing");
throw new DscChallengeInvalidException("certB64 is required");
}
final X509CertParser.ParsedCert parsed;
try {
parsed = certParser.parse(req.getCertB64());
} catch (final IllegalArgumentException e) {
failChallenge(ch, "cert parse error: " + e.getMessage());
throw new DscChallengeInvalidException(e.getMessage());
}
// Globally unique — if this serial is already bound to a different user, refuse without
// revealing who owns it.
final Optional<BankUserDsc> existing = dscRepository.findByDscSerialNo(parsed.getSerial());
final AppUser owner = ch.getAppUser();
if (existing.isPresent() && !existing.get().getAppUser().getId().equals(owner.getId())) {
failChallenge(ch, "serial already enrolled");
throw new DscSerialAlreadyEnrolledException(parsed.getSerial());
}
// Upsert: re-enrolling the same serial for the same user refreshes name/pubkey/expiry and
// resurrects a soft-revoked row.
final BankUserDsc dsc = existing.orElseGet(BankUserDsc::new);
dsc.setAppUser(owner);
dsc.setDscUserId(owner.getUsername());
dsc.setDscSerialNo(parsed.getSerial());
dsc.setDscName(req.getDscName() != null && !req.getDscName().isBlank() ? req.getDscName() : parsed.getCommonName());
dsc.setDscPublicKey(parsed.getPublicKeyB64());
dsc.setDscExpiryDate(parsed.getNotAfter());
dsc.setDateOfSubmission(OffsetDateTime.now(ZoneOffset.UTC));
dsc.setDscType(parseTypeOrSigning(req.getDscType()));
dsc.setCommonName(parsed.getCommonName());
dsc.setIssuedTo(parsed.getSubjectDn());
dsc.setIssuedBy(parsed.getIssuerDn());
dsc.setCertStatus(CertStatus.ACTIVE);
dsc.setActive(true);
dsc.setDeleted(false);
dsc.setRevokedOn(null);
dsc.setRevokedBy(null);
dsc.setRevokeReason(null);
if (dsc.getCreatedBy() == null) {
dsc.setCreatedBy(owner.getId());
} else {
dsc.setModifiedBy(owner.getId());
dsc.setModifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
}
final BankUserDsc saved = dscRepository.saveAndFlush(dsc);
ch.setStatus(DscChallengeStatus.VERIFIED);
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
ch.setCertSerialReturned(parsed.getSerial());
ch.setDscId(saved.getId());
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enrolled — user={}, dscId={}, serial={}", owner.getUsername(), saved.getId(), parsed.getSerial());
return DscChallengeStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.dsc(BankUserDscData.from(saved, false))
.build();
}
@Override
@Transactional(readOnly = true)
public DscChallengeStatusResponse getStatus(final String randomNo) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
BankUserDscData dscData = null;
if (ch.getStatus() == DscChallengeStatus.VERIFIED && ch.getDscId() != null) {
dscData = dscRepository.findById(ch.getDscId())
.map(d -> BankUserDscData.from(d, false))
.orElse(null);
}
return DscChallengeStatusResponse.builder()
.randomNo(randomNo)
.status(ch.getStatus().name())
.errorMessage(ch.getErrorMessage())
.dsc(dscData)
.build();
}
private void failChallenge(final DscChallenge ch, final String reason) {
ch.setStatus(DscChallengeStatus.FAILED);
ch.setErrorMessage(reason);
ch.setAttempts(ch.getAttempts() + 1);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enroll callback failed — randomNo={}, reason={}", ch.getRandomNo(), reason);
}
private static DscType parseTypeOrSigning(final String s) {
final DscType t = DscType.fromString(s);
return t != null ? t : DscType.SIGNING;
}
}
@@ -0,0 +1,125 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Builds the JNLP XML the browser hands to Java Web Start / OpenWebStart. The helper JAR is
* downloaded fresh on every launch (no caching) so a server-side helper upgrade rolls out
* immediately.
*
* <p>Argument shape passed to {@code CbsnexoraDscHelper.main()} is a single JSON object — the
* helper parses it with Gson and dispatches on {@code mode}.
*/
@Component
public class DscJnlpGenerator {
private static final Gson GSON = new Gson();
@Value("${fineract.dsc.helper.jar.url:/api/v1/dsc/helper.jar}")
private String helperJarUrl;
@Value("${fineract.dsc.helper.main-class:com.cbsnexora.dsc.CbsnexoraDscHelper}")
private String mainClass;
@Value("${fineract.dsc.helper.vendor:CBSnexora}")
private String vendor;
public String generate(final JnlpContext ctx) {
final Map<String, Object> args = new LinkedHashMap<>();
args.put("randomNo", ctx.randomNo);
args.put("mode", ctx.mode);
args.put("callbackUrl", ctx.callbackUrl);
if (ctx.challenge != null) {
args.put("challenge", ctx.challenge);
}
if (ctx.expectedSerial != null) {
args.put("expectedSerial", ctx.expectedSerial);
}
if (ctx.tenantIdentifier != null) {
// Helper reads this and adds `Fineract-Platform-TenantId: <value>` to its callback POST,
// because the helper isn't routed through the UI's auth interceptor — it makes raw
// HttpURLConnection calls.
args.put("tenantIdentifier", ctx.tenantIdentifier);
}
args.put("title", ctx.title);
final String argJson = escapeForXml(GSON.toJson(args));
// JWS will GET the JAR with no headers (it's a fresh process, not the browser). Embedding
// `?tenantIdentifier=` so Fineract's tenant filter (which already falls back to the query
// param when the header is absent) can resolve the tenant for this raw request.
final String jarUrlWithTenant = ctx.tenantIdentifier != null
? appendQueryParam(helperJarUrl, "tenantIdentifier", ctx.tenantIdentifier)
: helperJarUrl;
final String absoluteJarUrl = jarUrlWithTenant.startsWith("http")
? jarUrlWithTenant
: ctx.codebase + jarUrlWithTenant;
return "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+ "<jnlp spec=\"1.0+\" codebase=\"" + ctx.codebase + "\">\n"
+ " <information>\n"
+ " <vendor>" + escapeForXml(vendor) + "</vendor>\n"
+ " <title>" + escapeForXml(ctx.title) + "</title>\n"
+ " <description>" + escapeForXml(ctx.description) + "</description>\n"
+ " </information>\n"
+ " <security>\n"
+ " <all-permissions/>\n"
+ " </security>\n"
+ " <resources>\n"
+ " <j2se version=\"1.8+\"/>\n"
+ " <jar href=\"" + absoluteJarUrl + "\"/>\n"
+ " </resources>\n"
+ " <application-desc main-class=\"" + mainClass + "\">\n"
+ " <argument>" + argJson + "</argument>\n"
+ " </application-desc>\n"
+ "</jnlp>";
}
private static String escapeForXml(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&apos;");
}
private static String appendQueryParam(final String url, final String name, final String value) {
if (url == null || name == null || value == null) {
return url;
}
final String sep = url.indexOf('?') >= 0 ? "&" : "?";
return url + sep + name + "=" + java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
public static class JnlpContext {
public String randomNo;
public String mode; // ENROLL | SIGN_CHALLENGE
public String callbackUrl;
public String challenge; // only for SIGN_CHALLENGE
public String expectedSerial; // only for SIGN_CHALLENGE — helper enforces user picks the right cert
public String codebase; // e.g. https://cbs.example.com/fineract-provider
public String tenantIdentifier; // embedded in helper.jar URL + args; helper uses it for callback POST header
public String title;
public String description;
}
}
@@ -0,0 +1,41 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
public interface DscLoginChallengeService {
DscLoginChallengeStartResponse start(String codebase, String ipAddress, String userAgent);
String renderJnlp(String randomNo, String codebase);
DscLoginStatusResponse verify(String randomNo, DscLoginVerifyRequest request);
DscLoginStatusResponse getStatus(String randomNo);
/**
* Browser-side, basic-authed claim of the minted TFA token. Single-use — once claimed, the
* stored token is cleared. Caller must own the challenge (i.e. be the same user the helper
* verified for); otherwise treated as not-found to prevent enumeration.
*/
DscLoginStatusResponse claim(String randomNo);
}
@@ -0,0 +1,282 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
import org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository;
import org.apache.fineract.infrastructure.security.service.AccessTokenGenerationService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.TwoFactorConfigurationService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallenge;
import org.apache.fineract.useradministration.dsc.domain.DscChallengePurpose;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeStatus;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeInvalidException;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeNotFoundException;
import org.apache.fineract.useradministration.dsc.util.DscSignatureVerifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Drives the DSC-based login factor:
*
* <ol>
* <li>{@link #start} — partially-authenticated user (basic auth, no TFA yet) creates a challenge
* row containing a random hex string. UI renders the JNLP.</li>
* <li>{@link #verify} — helper POSTs signature; server checks the user has the cert serial
* enrolled and that RSA-SHA256({challenge}) matches the stored public key. On success a
* {@link TFAccessToken} is minted — same shape as OTP-based 2FA — and returned via status.</li>
* <li>{@link #getStatus} — UI polls; on VERIFIED the response carries the TFA token.</li>
* </ol>
*/
@Service
@RequiredArgsConstructor
public class DscLoginChallengeServiceImpl implements DscLoginChallengeService {
private static final Logger LOG = LoggerFactory.getLogger(DscLoginChallengeServiceImpl.class);
private static final int CHALLENGE_BYTES = 32; // 256 bits → 64 hex chars
private final SecureRandom secureRandom = new SecureRandom();
private final PlatformSecurityContext securityContext;
private final DscChallengeRepository challengeRepository;
private final BankUserDscRepository dscRepository;
private final DscSignatureVerifier signatureVerifier;
private final DscJnlpGenerator jnlpGenerator;
private final AccessTokenGenerationService accessTokenGenerationService;
private final TFAccessTokenRepository tfAccessTokenRepository;
private final TwoFactorConfigurationService twoFactorConfigurationService;
@Value("${fineract.dsc.challenge.ttl-seconds:300}")
private int challengeTtlSeconds;
@Override
@Transactional
public DscLoginChallengeStartResponse start(final String codebase, final String ipAddress, final String userAgent) {
final AppUser user = securityContext.authenticatedUser();
final String randomNo = UUID.randomUUID().toString();
final String challenge = newChallengeHex();
final DscChallenge ch = new DscChallenge();
ch.setRandomNo(randomNo);
ch.setAppUser(user);
ch.setPurpose(DscChallengePurpose.LOGIN_2FA);
ch.setChallenge(challenge);
ch.setStatus(DscChallengeStatus.PENDING);
ch.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(challengeTtlSeconds));
ch.setIpAddress(ipAddress);
ch.setUserAgent(userAgent);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login challenge started — user={}, randomNo={}", user.getUsername(), randomNo);
// Helper args for the UI to embed into a locally-built JNLP. The helper POSTs to the
// absolute callbackUrl and sets the tenant header from `tenantIdentifier`.
final java.util.Map<String, Object> helperArgs = new java.util.LinkedHashMap<>();
helperArgs.put("randomNo", randomNo);
helperArgs.put("mode", "SIGN_CHALLENGE");
helperArgs.put("callbackUrl", codebase + "/api/v1/login/dsc/" + randomNo + "/verify");
helperArgs.put("challenge", challenge);
helperArgs.put("title", "Verify Digital Signature");
try {
final String tenant = org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil
.getTenant().getTenantIdentifier();
if (tenant != null) {
helperArgs.put("tenantIdentifier", tenant);
}
} catch (final Exception ignored) {
// single-tenant deploys without tenant context — leave out
}
final String helperArgsJson = new com.google.gson.Gson().toJson(helperArgs);
return DscLoginChallengeStartResponse.builder()
.randomNo(randomNo)
.jnlpUrl("/v1/login/dsc/" + randomNo + ".jnlp")
.statusUrl("/v1/login/dsc/" + randomNo + "/status")
.expiresInSeconds(challengeTtlSeconds)
.helperArgsJson(helperArgsJson)
.helperJarUrl("/v1/dsc/helper.jar")
.build();
}
@Override
@Transactional(readOnly = true)
public String renderJnlp(final String randomNo, final String codebase) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING || ch.isExpired()) {
throw new DscChallengeInvalidException("Challenge is no longer accepting helper launches");
}
final DscJnlpGenerator.JnlpContext ctx = new DscJnlpGenerator.JnlpContext();
ctx.randomNo = randomNo;
ctx.mode = "SIGN_CHALLENGE";
ctx.challenge = ch.getChallenge();
ctx.callbackUrl = codebase + "/api/v1/login/dsc/" + randomNo + "/verify";
ctx.codebase = codebase;
// Embed tenant so JWS's headerless GET for helper.jar resolves via the filter's
// ?tenantIdentifier= fallback, and so the helper can put the same value into the
// Fineract-Platform-TenantId header when it POSTs /verify.
try {
ctx.tenantIdentifier = org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil
.getTenant().getTenantIdentifier();
} catch (final Exception ignored) {
// single-tenant deploys may not have a tenant in context — leave null
}
ctx.title = "Verify Digital Signature";
ctx.description = "Signs a login challenge with your enrolled DSC to complete two-factor authentication.";
return jnlpGenerator.generate(ctx);
}
@Override
@Transactional
public DscLoginStatusResponse verify(final String randomNo, final DscLoginVerifyRequest req) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING) {
throw new DscChallengeInvalidException("Challenge already terminal: " + ch.getStatus());
}
if (ch.isExpired()) {
ch.setStatus(DscChallengeStatus.EXPIRED);
challengeRepository.saveAndFlush(ch);
throw new DscChallengeInvalidException("Challenge expired");
}
if (req == null || req.getCertSerial() == null || req.getSignatureB64() == null) {
failChallenge(ch, "certSerial or signatureB64 missing");
throw new DscChallengeInvalidException("certSerial and signatureB64 are required");
}
// The user we challenged must own the cert serial they signed with. Don't lift the user
// out of the request — read it off the challenge row to prevent serial-substitution attacks.
final AppUser owner = ch.getAppUser();
final BankUserDsc dsc = dscRepository.findByAppUser_IdAndDscSerialNo(owner.getId(), req.getCertSerial()).orElse(null);
if (dsc == null || !dsc.isUsable()) {
failChallenge(ch, "cert serial not enrolled for this user");
return DscLoginStatusResponse.builder().randomNo(randomNo).status(DscChallengeStatus.FAILED.name())
.errorMessage("Selected DSC is not enrolled for this user").build();
}
final boolean ok = signatureVerifier.verify(ch.getChallenge(), req.getSignatureB64(), dsc.getDscPublicKey());
if (!ok) {
failChallenge(ch, "signature verification failed");
return DscLoginStatusResponse.builder().randomNo(randomNo).status(DscChallengeStatus.FAILED.name())
.errorMessage("Signature does not match the enrolled DSC").build();
}
// Mint a TFA access token — same shape MfaFactorBackedTwoFactorServiceImpl creates after a
// successful OTP verify. Default (non-extended) live time.
final int liveSeconds = twoFactorConfigurationService.getAccessTokenLiveTime();
final String token = accessTokenGenerationService.generateRandomToken();
final TFAccessToken accessToken = TFAccessToken.create(token, owner, liveSeconds);
tfAccessTokenRepository.save(accessToken);
ch.setStatus(DscChallengeStatus.VERIFIED);
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
ch.setCertSerialReturned(req.getCertSerial());
ch.setSignatureB64(req.getSignatureB64());
ch.setDscId(dsc.getId());
// Stash the token on the challenge row — the browser fetches it via /claim. The verify
// response is consumed by the helper, which we don't want carrying a Fineract bearer.
ch.setTfaToken(token);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login verified — user={}, dscId={}, randomNo={}", owner.getUsername(), dsc.getId(), randomNo);
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.build();
}
@Override
@Transactional
public DscLoginStatusResponse claim(final String randomNo) {
final AppUser caller = securityContext.authenticatedUser();
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
// Owner check — treat non-owner attempts the same as not-found so we don't leak
// randomNos belonging to other users.
if (ch.getAppUser() == null || !ch.getAppUser().getId().equals(caller.getId())) {
throw new DscChallengeNotFoundException(randomNo);
}
if (ch.getStatus() != DscChallengeStatus.VERIFIED) {
throw new DscChallengeInvalidException("Challenge is not in VERIFIED state");
}
if (ch.getTfaToken() == null || ch.getTfaTokenClaimedAt() != null) {
// Already claimed — treat as not-found to keep the endpoint single-shot.
throw new DscChallengeInvalidException("Token already claimed");
}
final String token = ch.getTfaToken();
ch.setTfaToken(null);
ch.setTfaTokenClaimedAt(OffsetDateTime.now(ZoneOffset.UTC));
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login token claimed — user={}, randomNo={}", caller.getUsername(), randomNo);
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.tfaToken(token)
.tfaTokenLiveSeconds((long) twoFactorConfigurationService.getAccessTokenLiveTime())
.build();
}
@Override
@Transactional(readOnly = true)
public DscLoginStatusResponse getStatus(final String randomNo) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
// For polling we deliberately do NOT echo the TFA token after the fact — that prevents a
// status URL with the randomNo from being reused to fetch the bearer. The UI gets the token
// exactly once, in the verify response. After that, /status reflects state only.
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(ch.getStatus().name())
.errorMessage(ch.getErrorMessage())
.build();
}
private void failChallenge(final DscChallenge ch, final String reason) {
ch.setStatus(DscChallengeStatus.FAILED);
ch.setErrorMessage(reason);
ch.setAttempts(ch.getAttempts() + 1);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login verify failed — randomNo={}, reason={}", ch.getRandomNo(), reason);
}
private String newChallengeHex() {
final byte[] buf = new byte[CHALLENGE_BYTES];
secureRandom.nextBytes(buf);
return HexFormat.of().formatHex(buf);
}
}
@@ -0,0 +1,37 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import java.util.List;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
public interface DscReadPlatformService {
/** All non-deleted rows for the currently-authenticated user. */
List<BankUserDscData> listForCurrentUser();
/** Admin view — non-deleted rows for an arbitrary user. */
List<BankUserDscData> listForUser(Long userId);
/** Single-row detail. The caller must already be authorised (self or admin). */
BankUserDscData getById(Long dscId);
/** Soft-revokes a DSC. Both the caller's own DSCs and (with admin perm) any user's. */
void revoke(Long dscId, String reason);
}
@@ -0,0 +1,97 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class DscReadPlatformServiceImpl implements DscReadPlatformService {
private static final Logger LOG = LoggerFactory.getLogger(DscReadPlatformServiceImpl.class);
private final PlatformSecurityContext securityContext;
private final BankUserDscRepository dscRepository;
@Override
@Transactional(readOnly = true)
public List<BankUserDscData> listForCurrentUser() {
final AppUser user = securityContext.authenticatedUser();
return dscRepository.findByAppUser_IdAndDeletedFalseOrderByIdDesc(user.getId()).stream()
.map(d -> BankUserDscData.from(d, true))
.toList();
}
@Override
@Transactional(readOnly = true)
public List<BankUserDscData> listForUser(final Long userId) {
return dscRepository.findByAppUser_IdAndDeletedFalseOrderByIdDesc(userId).stream()
.map(d -> BankUserDscData.from(d, true))
.toList();
}
@Override
@Transactional(readOnly = true)
public BankUserDscData getById(final Long dscId) {
final BankUserDsc d = dscRepository.findById(dscId).orElseThrow(() -> new DscNotFoundException(dscId));
// Non-admins can only see their own DSC. Don't reveal that the id exists for someone else —
// throw not-found instead.
final AppUser caller = securityContext.authenticatedUser();
if (!d.getAppUser().getId().equals(caller.getId())) {
// TODO: relax this once a dedicated 'READ_DSC_ALL' admin permission is wired in.
throw new DscNotFoundException(dscId);
}
return BankUserDscData.from(d, false);
}
@Override
@Transactional
public void revoke(final Long dscId, final String reason) {
final AppUser caller = securityContext.authenticatedUser();
final BankUserDsc d = dscRepository.findById(dscId).orElseThrow(() -> new DscNotFoundException(dscId));
if (!d.getAppUser().getId().equals(caller.getId())) {
// Same not-found masking as getById — don't leak existence to non-owners.
throw new DscNotFoundException(dscId);
}
d.setCertStatus(CertStatus.REVOKED);
d.setActive(false);
d.setDeleted(true);
d.setRevokedOn(OffsetDateTime.now(ZoneOffset.UTC));
d.setRevokedBy(caller.getId());
d.setRevokeReason(reason);
d.setDeletedBy(caller.getId());
d.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
dscRepository.saveAndFlush(d);
LOG.info("DSC revoked — dscId={}, user={}, reason={}", dscId, caller.getUsername(), reason);
}
}
@@ -0,0 +1,63 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.util;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import org.springframework.stereotype.Component;
/**
* Verifies an RSA-SHA256 signature produced by the helper against the public key stored in
* {@code m_bank_user_dsc.dsc_public_key} (base64 of {@code SubjectPublicKeyInfo}).
*
* <p>The challenge bytes are the UTF-8 bytes of {@code DscChallenge.challenge}. The helper signs
* exactly those bytes — server signs the same — so any mismatch in encoding/casing fails verify.
*/
@Component
public class DscSignatureVerifier {
/**
* @param challenge the original challenge string (server-side authoritative value)
* @param signatureB64 base64-encoded RSA-SHA256 signature returned by the helper
* @param publicKeyB64 base64 SubjectPublicKeyInfo of the user's enrolled cert
* @return true if the signature is valid for the challenge under the given key; false otherwise
* (including any decode/parse failure — we don't distinguish "wrong key" from "bad sig"
* to a caller because both are auth failures)
*/
public boolean verify(final String challenge, final String signatureB64, final String publicKeyB64) {
if (challenge == null || signatureB64 == null || publicKeyB64 == null) {
return false;
}
try {
final byte[] keyBytes = Base64.getDecoder().decode(publicKeyB64);
final PublicKey pubKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
final byte[] sigBytes = Base64.getDecoder().decode(signatureB64);
final Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(pubKey);
sig.update(challenge.getBytes(StandardCharsets.UTF_8));
return sig.verify(sigBytes);
} catch (final Exception e) {
return false;
}
}
}
@@ -0,0 +1,124 @@
/**
* 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
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.dsc.util;
import java.io.ByteArrayInputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Base64;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import lombok.Builder;
import lombok.Getter;
import org.springframework.stereotype.Component;
/**
* Parses a base64-encoded X.509 DER certificate (sent by the helper) into the metadata fields the
* server persists. Server-side parsing is authoritative — we do not trust whatever the helper
* claims about itself.
*
* <p>Validation done here is minimal: well-formedness + notAfter > now. Chain validation, CRL/OCSP
* checks, and KeyUsage enforcement are intentionally out of scope for v1 and will be layered in
* Phase 4 hardening.
*/
@Component
public class X509CertParser {
public ParsedCert parse(final String certB64) {
if (certB64 == null || certB64.isBlank()) {
throw new IllegalArgumentException("certB64 is required");
}
final byte[] der;
try {
// Tolerate PEM headers/whitespace if the helper sends them — strip non-base64.
final String normalized = certB64.replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s+", "");
der = Base64.getDecoder().decode(normalized);
} catch (final IllegalArgumentException e) {
throw new IllegalArgumentException("certB64 is not valid base64");
}
final X509Certificate cert;
try {
final CertificateFactory cf = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(der));
} catch (final CertificateException e) {
throw new IllegalArgumentException("certB64 is not a valid X.509 certificate", e);
}
if (cert.getNotAfter().toInstant().isBefore(java.time.Instant.now())) {
throw new IllegalArgumentException("Certificate has already expired (notAfter=" + cert.getNotAfter() + ")");
}
// KeyUsage extension is the standard X.509 way to declare what a cert may be used for.
// bit 0 = digitalSignature, bit 1 = nonRepudiation. We require at least one of these so a
// pure-encryption cert (e.g. dataEncipherment bit only) can't be enrolled as a signing DSC.
// KeyUsage is optional in X.509 — null array means no constraint and we allow it.
final boolean[] keyUsage = cert.getKeyUsage();
if (keyUsage != null && keyUsage.length > 1
&& !keyUsage[0] /* digitalSignature */
&& !keyUsage[1] /* nonRepudiation */) {
throw new IllegalArgumentException("Certificate KeyUsage does not permit signing — pick a Signing DSC, not an Encryption one");
}
final String subjectDn = cert.getSubjectX500Principal().getName();
final String issuerDn = cert.getIssuerX500Principal().getName();
final String commonName = extractCn(subjectDn);
final String serial = cert.getSerialNumber().toString(16).toUpperCase();
final LocalDate notAfter = cert.getNotAfter().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
final String publicKeyB64 = Base64.getEncoder().encodeToString(cert.getPublicKey().getEncoded());
return ParsedCert.builder()
.serial(serial)
.subjectDn(subjectDn)
.issuerDn(issuerDn)
.commonName(commonName)
.notAfter(notAfter)
.publicKeyB64(publicKeyB64)
.build();
}
private static String extractCn(final String dn) {
try {
final LdapName name = new LdapName(dn);
for (final Rdn rdn : name.getRdns()) {
if ("CN".equalsIgnoreCase(rdn.getType())) {
return String.valueOf(rdn.getValue());
}
}
} catch (final InvalidNameException ignored) {
// Fall through — return null if DN is malformed.
}
return null;
}
@Getter
@Builder
public static class ParsedCert {
private final String serial;
private final String subjectDn;
private final String issuerDn;
private final String commonName;
private final LocalDate notAfter;
private final String publicKeyB64;
}
}
@@ -94,6 +94,15 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
this.fromApiJsonDeserializer.validateForCreate(command.json()); this.fromApiJsonDeserializer.validateForCreate(command.json());
// Pre-flight unique-checks for email + mobile so the UI gets a clean field-scoped
// error instead of the generic data-integrity error that fires after the DB UNIQUE
// constraint trips. The DB constraint is still the source of truth for race-condition
// safety — see handleDataIntegrityIssues() for that backstop.
assertEmailAndMobileUnique(
command.stringValueOfParameterNamed("email"),
command.stringValueOfParameterNamed("mobileNo"),
null);
final String officeIdParamName = "officeId"; final String officeIdParamName = "officeId";
final Long officeId = command.longValueOfParameterNamed(officeIdParamName); final Long officeId = command.longValueOfParameterNamed(officeIdParamName);
@@ -199,6 +208,13 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
this.fromApiJsonDeserializer.validateForUpdate(command.json(), currentUser); this.fromApiJsonDeserializer.validateForUpdate(command.json(), currentUser);
// Same pre-flight as createUser, excluding the user being edited so they're not told
// their own email/mobile is taken by themselves.
assertEmailAndMobileUnique(
command.stringValueOfParameterNamed("email"),
command.stringValueOfParameterNamed("mobileNo"),
userId);
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId)); final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command); final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
@@ -342,6 +358,19 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
final String defaultMessage = "User with username " + username + " already exists."; final String defaultMessage = "User with username " + username + " already exists.";
return new PlatformDataIntegrityException("error.msg.user.duplicate.username", defaultMessage, "username", username); return new PlatformDataIntegrityException("error.msg.user.duplicate.username", defaultMessage, "username", username);
} }
// Email / mobile race-condition backstop. The pre-flight check in createUser/updateUser
// catches almost all cases cleanly, but two parallel inserts can still race past it and
// collide at the DB unique constraint — translate that into the same field-scoped error.
if (realCause.getMessage().contains("uq_m_appuser_email")) {
final String email = command.stringValueOfParameterNamed("email");
return new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
if (realCause.getMessage().contains("uq_m_appuser_mobile_no")) {
final String mobile = command.stringValueOfParameterNamed("mobileNo");
return new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobile + " already exists.", "mobileNo", mobile);
}
// TODO: this needs to be fixed. The error condition should be independent from the underlying message and // TODO: this needs to be fixed. The error condition should be independent from the underlying message and
// naming of the constraint // naming of the constraint
if (realCause.getMessage().contains("unique_self_client")) { if (realCause.getMessage().contains("unique_self_client")) {
@@ -352,4 +381,37 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
log.error("handleDataIntegrityIssues: Neither duplicate username nor existing user; unknown error occured", dve); log.error("handleDataIntegrityIssues: Neither duplicate username nor existing user; unknown error occured", dve);
return ErrorHandler.getMappable(dve, "error.msg.unknown.data.integrity.issue", "Unknown data integrity issue with resource."); return ErrorHandler.getMappable(dve, "error.msg.unknown.data.integrity.issue", "Unknown data integrity issue with resource.");
} }
/**
* Pre-flight uniqueness check for {@code email} and {@code mobileNo}. Runs before the DB
* write so the UI gets a clean field-scoped error instead of the generic
* "data integrity issue" the constraint trap would produce.
*
* <p>On create, pass {@code excludeUserId = null}. On update, pass the id of the user being
* edited so they aren't told their own values collide with themselves.
*
* <p>Blank / null email is left to the existing {@link UserDataValidator#notBlank()} check
* (email is NOT NULL in {@code m_appuser}). Blank / null mobile is skipped — it's optional
* and multiple users with no mobile are valid.
*/
private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) {
if (email != null && !email.isBlank()) {
final java.util.Optional<AppUser> emailOwner = excludeUserId == null
? this.appUserRepository.findByEmailIgnoreCase(email)
: this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId);
if (emailOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
}
if (mobileNo != null && !mobileNo.isBlank()) {
final java.util.Optional<AppUser> mobileOwner = excludeUserId == null
? this.appUserRepository.findByMobileNo(mobileNo)
: this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId);
if (mobileOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo);
}
}
}
} }
@@ -664,8 +664,6 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# ── DIP / MNRL Mobile Number Lookup ────────────────────────────────────────── # ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080} fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
# ── Login OTP (2FA) — uses LincPay credentials above ───────────────────────── # ── 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.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp} fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
@@ -686,3 +684,4 @@ spring.mail.properties.mail.transport.protocol=smtp
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com} fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora} fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com} fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
@@ -308,4 +308,8 @@
<include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/> <include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/> <include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/>
<include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/> <include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0292_create_m_bank_user_dsc.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,168 @@
<?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">
<!--
Stores Digital Signature Certificate (DSC) mappings per AppUser. A user may have multiple
rows; queries filter on (is_active = true, is_deleted = false, cert_status = 'ACTIVE') to
find usable mappings.
cert serial is globally unique — one cert in the world has one serial — so it's the natural
lookup key at login-verify time.
-->
<changeSet id="0292_create_m_bank_user_dsc" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_bank_user_dsc"/></not></preConditions>
<createTable tableName="m_bank_user_dsc">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Parity with external 'bank_user_dsc' reference schema -->
<column name="dsc_user_id" type="VARCHAR(64)"/>
<column name="dsc_serial_no" type="VARCHAR(128)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_bank_user_dsc_serial"/>
</column>
<column name="dsc_name" type="VARCHAR(255)"/>
<column name="dsc_public_key" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="dsc_expiry_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="date_of_submission" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="dsc_type" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="common_name" type="VARCHAR(255)"/>
<!-- Cert metadata for display + future chain validation -->
<column name="issued_to" type="VARCHAR(512)"/>
<column name="issued_by" type="VARCHAR(512)"/>
<!-- Lifecycle: ACTIVE / REVOKED / EXPIRED -->
<column name="cert_status" type="VARCHAR(16)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_on" type="datetime"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoke_reason" type="VARCHAR(255)"/>
<!-- Standard Fineract soft-delete + audit columns -->
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="modified_by" type="BIGINT"/>
<column name="modified_on" type="datetime"/>
<column name="deleted_by" type="BIGINT"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="appuser_id"
constraintName="fk_bank_user_dsc_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="revoked_by"
constraintName="fk_bank_user_dsc_revoked_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="created_by"
constraintName="fk_bank_user_dsc_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="modified_by"
constraintName="fk_bank_user_dsc_modified_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="deleted_by"
constraintName="fk_bank_user_dsc_deleted_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<!-- Common query: list active DSCs for a user -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_user_status">
<column name="appuser_id"/>
<column name="cert_status"/>
<column name="is_deleted"/>
</createIndex>
<!-- Scheduled job: flip ACTIVE → EXPIRED when notAfter passes -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_expiry">
<column name="dsc_expiry_date"/>
<column name="cert_status"/>
</createIndex>
</changeSet>
<!-- Permissions: read / create / delete (delete = soft revoke) -->
<changeSet id="0292_seed_dsc_permissions" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_DSC'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="READ_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="CREATE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="DELETE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Self Service User gets the trio so end-users can enroll/revoke their own DSC. -->
<changeSet id="0292_grant_dsc_permissions_to_self_service_role" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Self Service User'
AND p.code IN ('READ_DSC', 'CREATE_DSC', 'DELETE_DSC')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?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">
<!--
One-shot nonce for a DSC enroll or login-2FA cycle. Rows are short-lived (~2 min TTL) and
transition PENDING → VERIFIED / FAILED / EXPIRED exactly once. Kept after terminal state for
audit; cleanup is the job of a scheduled task.
random_no is the public handle embedded in the JNLP and in the helper's callback URL —
UUID v4 length, sufficient entropy that guessing a pending nonce is infeasible.
-->
<changeSet id="0293_create_m_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_dsc_challenge"/></not></preConditions>
<createTable tableName="m_dsc_challenge">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="random_no" type="VARCHAR(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_dsc_challenge_random_no"/>
</column>
<column name="appuser_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="challenge" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(16)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="cert_serial_returned" type="VARCHAR(128)"/>
<column name="signature_b64" type="TEXT"/>
<column name="dsc_id" type="BIGINT"/>
<column name="error_message" type="VARCHAR(512)"/>
<column name="attempts" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="expires_at" type="datetime">
<constraints nullable="false"/>
</column>
<column name="verified_at" type="datetime"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(64)"/>
<column name="user_agent" type="VARCHAR(512)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="appuser_id"
constraintName="fk_dsc_challenge_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="dsc_id"
constraintName="fk_dsc_challenge_dsc"
referencedTableName="m_bank_user_dsc" referencedColumnNames="id"/>
<!-- "Open challenges for this user" — drives the status-poll endpoint -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_user_status">
<column name="appuser_id"/>
<column name="status"/>
</createIndex>
<!-- Cleanup job filter -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_expires_at">
<column name="expires_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?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">
<!--
DSC login flow stores the minted TFA access token on the challenge row instead of returning
it to the helper. The browser then calls POST /v1/login/dsc/{rn}/claim (basic-auth required)
to retrieve the token exactly once.
This stops the helper from carrying a bearer credential back into a browser process and
keeps the token's emission scoped to a fresh authenticated request.
-->
<changeSet id="0294_add_tfa_token_to_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_dsc_challenge"/>
<not><columnExists tableName="m_dsc_challenge" columnName="tfa_token"/></not>
</and>
</preConditions>
<addColumn tableName="m_dsc_challenge">
<column name="tfa_token" type="VARCHAR(255)"/>
<column name="tfa_token_claimed_at" type="datetime"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?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">
<!--
Enforce uniqueness on m_appuser.email and m_appuser.mobile_no.
Username is already unique (constraint name 'username_org' from the upstream schema).
Email is NOT NULL in the entity; mobile_no IS nullable. Both PostgreSQL and MySQL allow
multiple NULLs in a UNIQUE constraint, so a plain UNIQUE works for mobile_no — multiple
users with no mobile remain valid.
Note: if your existing data already contains duplicate emails or mobiles, this migration
WILL fail. Dedupe first:
SELECT LOWER(email), COUNT(*) FROM m_appuser GROUP BY LOWER(email) HAVING COUNT(*) > 1;
SELECT mobile_no, COUNT(*) FROM m_appuser WHERE mobile_no IS NOT NULL GROUP BY mobile_no HAVING COUNT(*) > 1;
-->
<changeSet id="0295_add_unique_email_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_email"/>
</not>
</preConditions>
<addUniqueConstraint tableName="m_appuser" columnNames="email"
constraintName="uq_m_appuser_email"/>
</changeSet>
<changeSet id="0295_add_unique_mobile_no_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_mobile_no"/>
</not>
</preConditions>
<!-- Drop the plain non-unique index added in 0283 first — the unique constraint replaces it. -->
<dropIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_no"/>
<addUniqueConstraint tableName="m_appuser" columnNames="mobile_no"
constraintName="uq_m_appuser_mobile_no"/>
</changeSet>
</databaseChangeLog>
@@ -39,6 +39,7 @@ import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.Set; import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
@@ -65,6 +66,7 @@ import org.springframework.stereotype.Component;
@Path("/v1/authentication") @Path("/v1/authentication")
@Tag(name = "Authentication HTTP Basic", description = "An API capability that allows client applications to verify authentication details using HTTP Basic Authentication.") @Tag(name = "Authentication HTTP Basic", description = "An API capability that allows client applications to verify authentication details using HTTP Basic Authentication.")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class AuthenticationApiResource { public class AuthenticationApiResource {
@Value("${fineract.security.2fa.enabled}") @Value("${fineract.security.2fa.enabled}")
@@ -83,6 +85,7 @@ public class AuthenticationApiResource {
private final ClientReadPlatformService clientReadPlatformService; private final ClientReadPlatformService clientReadPlatformService;
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository; private final UserMfaFactorRepository userMfaFactorRepository;
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
@POST @POST
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -145,8 +148,26 @@ public class AuthenticationApiResource {
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant // safe rollout (the per-user gate naturally lets unenrolled users in) and instant
// rollback (flip the c_configuration row off and everyone is unchallenged). // rollback (flip the c_configuration row off and everyone is unchallenged).
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled(); final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
// DSC factor check is wrapped so a misconfigured/missing m_bank_user_dsc table can't
// 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());
}
}
final boolean userHasFactors = enforcementEnabled final boolean userHasFactors = enforcementEnabled
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId()); && (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId())
|| hasActiveDsc);
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION); && !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
Long userId = principal.getId(); Long userId = principal.getId();
@@ -19,11 +19,9 @@
package org.apache.fineract.infrastructure.security.domain; package org.apache.fineract.infrastructure.security.domain;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@ConditionalOnProperty("fineract.security.2fa.enabled")
public interface TFAccessTokenRepository extends JpaRepository<TFAccessToken, Long>, JpaSpecificationExecutor<TFAccessToken> { public interface TFAccessTokenRepository extends JpaRepository<TFAccessToken, Long>, JpaSpecificationExecutor<TFAccessToken> {
TFAccessToken findByUserAndToken(AppUser user, String token); TFAccessToken findByUserAndToken(AppUser user, String token);
@@ -110,6 +110,45 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
// in different origin (domain name) // in different origin (domain name)
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} else { } else {
// Fully exempt paths: skip tenant resolution AND make Spring Security's
// BasicAuthenticationFilter ignore this request.
//
// Why this is non-trivial: Fineract has TWO basic-auth filters in the chain —
// this one (TenantAwareBasicAuthenticationFilter, extends BasicAuthenticationFilter)
// AND Spring's built-in BasicAuthenticationFilter that .httpBasic(…) adds on the
// SecurityFilterChain. Skipping this filter via filterChain.doFilter(…) hands the
// request to the built-in filter, which still sees the UI interceptor's
// Authorization header, tries to authenticate it (without tenant context →
// DB query fails), and returns 401 + WWW-Authenticate → the user gets the native
// browser Basic-auth dialog.
//
// Fix: wrap the request to strip the Authorization header for exempt paths so
// the built-in BasicAuthenticationFilter has nothing to act on. The controller
// is permitAll'd anyway, so missing auth is fine — the JAR endpoint just streams
// a file from disk.
if (isTenantExemptPath(request)) {
final jakarta.servlet.http.HttpServletRequestWrapper stripped =
new jakarta.servlet.http.HttpServletRequestWrapper(request) {
@Override
public String getHeader(final String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return null;
}
return super.getHeader(name);
}
@Override
public java.util.Enumeration<String> getHeaders(final String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return java.util.Collections.emptyEnumeration();
}
return super.getHeaders(name);
}
};
filterChain.doFilter(stripped, response);
return;
}
if (requestMatcher.matches(request)) { if (requestMatcher.matches(request)) {
String tenantIdentifier = request.getHeader(TENANT_ID_REQUEST_HEADER); String tenantIdentifier = request.getHeader(TENANT_ID_REQUEST_HEADER);
@@ -167,6 +206,33 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
} }
} }
/**
* Paths that don't need a tenant context to serve a response.
*
* <p>Only {@code /api/v*\/dsc/helper.jar} qualifies: Java Web Start fetches the helper JAR
* with a raw GET (no headers, no UI interceptor), and the endpoint just streams a file from
* disk — no DB queries, no tenant-scoped state.
*
* <p>The JNLP endpoint deliberately does NOT qualify: it queries {@code m_dsc_challenge}
* which requires tenant context for Fineract's connection routing. JNLP fetches always come
* from the SPA's XHR (which sends the tenant header via the auth interceptor), so the normal
* tenant resolution path works for them.
*
* <p>Without this exemption, JWS receives a 400 + {@code WWW-Authenticate: Basic} response
* and pops up the native "Authentication Required" dialog.
*/
private boolean isTenantExemptPath(final HttpServletRequest request) {
final String uri = request.getRequestURI();
if (uri == null) {
return false;
}
final boolean exempt = uri.contains("/dsc/helper.jar");
if (exempt) {
log.debug("Tenant filter: exempting JWS-bound path '{}' from tenant requirement", uri);
}
return exempt;
}
@Override @Override
protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult) protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult)
throws IOException { throws IOException {
@@ -28,6 +28,7 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants; import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken; import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken;
@@ -56,11 +57,13 @@ import org.springframework.web.filter.GenericFilterBean;
* authority regardless of the value of the 'Fineract-Platform-TFA-Token' header. * authority regardless of the value of the 'Fineract-Platform-TFA-Token' header.
*/ */
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class TwoFactorAuthenticationFilter extends GenericFilterBean { public class TwoFactorAuthenticationFilter extends GenericFilterBean {
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository; private final UserMfaFactorRepository userMfaFactorRepository;
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
@Override @Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
@@ -90,8 +93,24 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
// has no enrolled factors (or the global kill-switch is off). // has no enrolled factors (or the global kill-switch is off).
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION); final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled(); final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
// DSC factor check wrapped: a misconfigured/missing m_bank_user_dsc must not bring
// 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());
}
}
final boolean userHasFactors = enforcementOn final boolean userHasFactors = enforcementOn
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId()); && (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId())
|| hasActiveDsc);
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors; final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
if (shouldChallenge) { if (shouldChallenge) {