Add DSC-based MFA: enrollment, login factor, JWS helper, tenant-scoped serving
Backend:
- m_bank_user_dsc + m_dsc_challenge tables (Liquibase 0292/0293/0294)
- DSC enrollment flow: start → JNLP → JWS helper → callback → upsert
- DSC login factor: challenge → sign → verify → claim minted TFA token
- 2FA gate now also accepts an active DSC mapping (AuthenticationApiResource
+ TwoFactorAuthenticationFilter), defensively wrapped so a missing DSC table
doesn't 500 the auth path
- TenantAwareBasicAuthenticationFilter exempts /api/v*/dsc/helper.jar and
strips Authorization header for that path so JWS's raw GET works without
the user seeing a browser Basic-auth prompt
- DSC helper JAR served at /api/v*/dsc/helper.jar from a configurable
filesystem path (default ~/.cbsnexora/dsc-helper.jar)
Java helper JAR (dsc-helper/):
- Standalone Gradle subproject, Java 8 target, fat jar with Gson shaded in
- Cross-platform token picker (Windows Cert Store / ePass2003 / eToken /
ProxKey / Other) — matches the JNLP arg contract emitted by the backend
- Sets Fineract-Platform-TenantId on its callback POST from the JNLP args
- JNLP age check now reads cbsnexora-dsc.jnlp (current name) AND
SignTender.jnlp (legacy) — picks the youngest, never blocks if none exist
Inline KYC upsert in updateClient (mirror of the create-flow path):
- ClientKycWritePlatformService.upsertKycInline() — creates or merges KYC
fields, bumps status to IN_PROGRESS on real changes; resets PAN verification
on PAN edit of a verified record
- ClientWritePlatformServiceJpaRepositoryImpl.updateClient() handles the
kyc:{} sub-object on PUT /clients/{id}, matching createClient
User uniqueness:
- m_appuser.email + m_appuser.mobile_no UNIQUE (Liquibase 0295)
- Pre-flight assertEmailAndMobileUnique() in create/update with self-exclusion
- handleDataIntegrityIssues maps the two new constraints to clean
field-scoped error messages (race-condition safety net)
Email OTP subject line now: "Your OTP for Login - <AppName> (dd-MM-yy-HH:mm)"
where AppName comes from fineract.email.from-name (default "CBSnexora").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5e3212dc54
commit
8284851f67
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user