merege code feature/Backend-DSC-MFA branch
This commit is contained in:
@@ -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'
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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 >= 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);
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+133
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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
+28
-1
@@ -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/email/generate")).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()
|
||||
// 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("/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/**"))
|
||||
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
|
||||
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
|
||||
@@ -402,7 +424,12 @@ public class SecurityConfig {
|
||||
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
||||
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
||||
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
|
||||
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() {
|
||||
|
||||
+6
-1
@@ -291,7 +291,12 @@ public class AuthorizationServerConfig {
|
||||
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
|
||||
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
|
||||
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
|
||||
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() {
|
||||
|
||||
+8
-1
@@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||
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_EXPIRED = "EXPIRED";
|
||||
|
||||
private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm");
|
||||
|
||||
@Value("${fineract.security.otp.ttl-seconds:300}")
|
||||
private int ttlSeconds;
|
||||
|
||||
@Value("${fineract.security.otp.max-attempts:5}")
|
||||
private int maxAttempts;
|
||||
|
||||
@Value("${fineract.email.from-name:CBSnexora}")
|
||||
private String appName;
|
||||
|
||||
private final OtpLogRepository otpLogRepository;
|
||||
private final AppUserRepository appUserRepository;
|
||||
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.
|
||||
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);
|
||||
if (sent) {
|
||||
|
||||
+12
@@ -34,6 +34,18 @@ public interface ClientKycWritePlatformService {
|
||||
*/
|
||||
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. */
|
||||
CommandProcessingResult updateKyc(Long clientId, Long kycId, JsonCommand command);
|
||||
|
||||
|
||||
+49
@@ -937,6 +937,55 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
|
||||
|
||||
+15
@@ -1142,6 +1142,21 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
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() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityExternalId(clientForUpdate.getExternalId()) //
|
||||
|
||||
+23
@@ -31,4 +31,27 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long>, JpaSpec
|
||||
AppUser findAppUserByName(@Param("username") String username);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+213
@@ -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();
|
||||
}
|
||||
}
|
||||
+140
@@ -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();
|
||||
}
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+255
@@ -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;
|
||||
}
|
||||
}
|
||||
+125
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
+282
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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;
|
||||
}
|
||||
}
|
||||
+62
@@ -94,6 +94,15 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
||||
|
||||
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 Long officeId = command.longValueOfParameterNamed(officeIdParamName);
|
||||
|
||||
@@ -199,6 +208,13 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
||||
|
||||
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 AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
|
||||
@@ -342,6 +358,19 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
||||
final String defaultMessage = "User with username " + username + " already exists.";
|
||||
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
|
||||
// naming of the constraint
|
||||
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);
|
||||
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 ──────────────────────────────────────────
|
||||
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
|
||||
|
||||
|
||||
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
||||
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
||||
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
|
||||
@@ -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-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||
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/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/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>
|
||||
|
||||
+168
@@ -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>
|
||||
+83
@@ -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>
|
||||
+39
@@ -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>
|
||||
+54
@@ -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>
|
||||
+22
-1
@@ -39,6 +39,7 @@ import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
|
||||
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
|
||||
@@ -65,6 +66,7 @@ import org.springframework.stereotype.Component;
|
||||
@Path("/v1/authentication")
|
||||
@Tag(name = "Authentication HTTP Basic", description = "An API capability that allows client applications to verify authentication details using HTTP Basic Authentication.")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthenticationApiResource {
|
||||
|
||||
@Value("${fineract.security.2fa.enabled}")
|
||||
@@ -83,6 +85,7 @@ public class AuthenticationApiResource {
|
||||
private final ClientReadPlatformService clientReadPlatformService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -145,8 +148,26 @@ public class AuthenticationApiResource {
|
||||
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant
|
||||
// rollback (flip the c_configuration row off and everyone is unchallenged).
|
||||
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
|
||||
// DSC factor check is wrapped so a misconfigured/missing m_bank_user_dsc table can't
|
||||
// bring down the entire login path. If the lookup throws (table missing, columns
|
||||
// renamed, JDBC down), we log and fall back to "no DSC factor enrolled" — the user
|
||||
// can still authenticate via their OTP factors (or no 2FA if none enrolled).
|
||||
boolean hasActiveDsc = false;
|
||||
if (enforcementEnabled) {
|
||||
try {
|
||||
hasActiveDsc = bankUserDscRepository
|
||||
.existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
|
||||
principal.getId(),
|
||||
org.apache.fineract.useradministration.dsc.domain.CertStatus.ACTIVE,
|
||||
java.time.LocalDate.now());
|
||||
} catch (final Exception e) {
|
||||
log.warn("DSC factor lookup failed for user {} during login — falling back to non-DSC: {}",
|
||||
principal.getUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
final boolean userHasFactors = enforcementEnabled
|
||||
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
|
||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId())
|
||||
|| hasActiveDsc);
|
||||
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
|
||||
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
||||
Long userId = principal.getId();
|
||||
|
||||
-2
@@ -19,11 +19,9 @@
|
||||
package org.apache.fineract.infrastructure.security.domain;
|
||||
|
||||
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.JpaSpecificationExecutor;
|
||||
|
||||
@ConditionalOnProperty("fineract.security.2fa.enabled")
|
||||
public interface TFAccessTokenRepository extends JpaRepository<TFAccessToken, Long>, JpaSpecificationExecutor<TFAccessToken> {
|
||||
|
||||
TFAccessToken findByUserAndToken(AppUser user, String token);
|
||||
|
||||
+66
@@ -110,6 +110,45 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
|
||||
// in different origin (domain name)
|
||||
filterChain.doFilter(request, response);
|
||||
} 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)) {
|
||||
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
|
||||
protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult)
|
||||
throws IOException {
|
||||
|
||||
+20
-1
@@ -28,6 +28,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
|
||||
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.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TwoFactorAuthenticationFilter extends GenericFilterBean {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||
private final org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
|
||||
@@ -90,8 +93,24 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
|
||||
// has no enrolled factors (or the global kill-switch is off).
|
||||
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
|
||||
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
|
||||
// DSC factor check wrapped: a misconfigured/missing m_bank_user_dsc must not bring
|
||||
// down every protected request. Fall back to "no DSC factor" if the lookup throws.
|
||||
boolean hasActiveDsc = false;
|
||||
if (enforcementOn) {
|
||||
try {
|
||||
hasActiveDsc = bankUserDscRepository
|
||||
.existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
|
||||
user.getId(),
|
||||
org.apache.fineract.useradministration.dsc.domain.CertStatus.ACTIVE,
|
||||
java.time.LocalDate.now());
|
||||
} catch (final Exception e) {
|
||||
log.warn("DSC factor lookup failed in 2FA filter for user {} — ignoring: {}",
|
||||
user.getUsername(), e.getMessage());
|
||||
}
|
||||
}
|
||||
final boolean userHasFactors = enforcementOn
|
||||
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
|
||||
&& (userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId())
|
||||
|| hasActiveDsc);
|
||||
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
|
||||
|
||||
if (shouldChallenge) {
|
||||
|
||||
Reference in New Issue
Block a user