fix ssh auth and transfer modal state
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.sshmanager.controller;
|
||||
|
||||
import com.jcraft.jsch.JSchException;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.sshmanager.dto.SftpFileInfo;
|
||||
import com.sshmanager.entity.Connection;
|
||||
@@ -7,6 +8,7 @@ import com.sshmanager.entity.User;
|
||||
import com.sshmanager.repository.UserRepository;
|
||||
import com.sshmanager.service.ConnectionService;
|
||||
import com.sshmanager.service.SftpService;
|
||||
import com.sshmanager.util.JschUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -150,16 +152,19 @@ public class SftpController {
|
||||
}
|
||||
|
||||
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
||||
return e.getMessage();
|
||||
}
|
||||
Throwable cur = e;
|
||||
for (int i = 0; i < 10 && cur != null; i++) {
|
||||
// SFTP protocol errors → human-readable format
|
||||
if (cur instanceof SftpException) {
|
||||
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||
}
|
||||
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
||||
return cur.getMessage();
|
||||
String msg = cur.getMessage();
|
||||
if (msg != null && !msg.trim().isEmpty()) {
|
||||
// Auth failure → translated message
|
||||
if (cur instanceof JSchException || msg.contains("Auth fail") || msg.contains("authentication")) {
|
||||
return JschUtil.formatAuthErrorMessage(msg);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
@@ -247,9 +252,10 @@ public class SftpController {
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
||||
String errorMsg = toSftpErrorMessage(e, null, "pwd");
|
||||
log.warn("SFTP pwd failed: connectionId={}, error={}", connectionId, errorMsg, e);
|
||||
Map<String, String> err = new HashMap<>();
|
||||
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
||||
err.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(err);
|
||||
}
|
||||
}
|
||||
@@ -461,8 +467,9 @@ public class SftpController {
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, null, "delete");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
error.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
@@ -491,8 +498,9 @@ public class SftpController {
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, null, "mkdir");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
error.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
@@ -522,8 +530,9 @@ public class SftpController {
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, null, "rename");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
error.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
@@ -548,8 +557,9 @@ public class SftpController {
|
||||
result.put("message", "Transferred");
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (Exception e) {
|
||||
String errorMsg = toSftpErrorMessage(e, null, "transfer");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
|
||||
error.put("error", errorMsg);
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import com.jcraft.jsch.SftpATTRS;
|
||||
import com.jcraft.jsch.SftpException;
|
||||
import com.jcraft.jsch.SftpProgressMonitor;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.util.JschUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
@@ -33,29 +33,7 @@ public class SftpService {
|
||||
|
||||
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
throw new IllegalArgumentException("Password is required for password authentication");
|
||||
}
|
||||
session.setConfig("PreferredAuthentications", "password");
|
||||
session.setPassword(password);
|
||||
} else {
|
||||
session.setConfig("PreferredAuthentications", "publickey");
|
||||
}
|
||||
session.connect(10000);
|
||||
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||
|
||||
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
||||
channel.connect(5000);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelExec;
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelExec;
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import com.sshmanager.util.JschUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -13,63 +13,39 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
|
||||
@Service
|
||||
|
||||
@Service
|
||||
public class SshService {
|
||||
|
||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
throw new IllegalArgumentException("Password is required for password authentication");
|
||||
}
|
||||
session.setConfig("PreferredAuthentications", "password");
|
||||
session.setPassword(password);
|
||||
} else {
|
||||
session.setConfig("PreferredAuthentications", "publickey");
|
||||
}
|
||||
|
||||
session.connect(10000);
|
||||
|
||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPtyType("xterm");
|
||||
channel.connect(5000);
|
||||
|
||||
PipedInputStream pipedIn = new PipedInputStream();
|
||||
PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn);
|
||||
OutputStream channelIn = channel.getOutputStream();
|
||||
InputStream channelOut = channel.getInputStream();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = pipedIn.read(buf)) > 0) {
|
||||
channelIn.write(buf, 0, n);
|
||||
channelIn.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Channel closed
|
||||
}
|
||||
}).start();
|
||||
|
||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||
|
||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPtyType("xterm");
|
||||
channel.connect(5000);
|
||||
|
||||
PipedInputStream pipedIn = new PipedInputStream();
|
||||
PipedOutputStream pipeToChannel = new PipedOutputStream(pipedIn);
|
||||
OutputStream channelIn = channel.getOutputStream();
|
||||
InputStream channelOut = channel.getInputStream();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = pipedIn.read(buf)) > 0) {
|
||||
channelIn.write(buf, 0, n);
|
||||
channelIn.flush();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Channel closed
|
||||
}
|
||||
}).start();
|
||||
|
||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||
}
|
||||
|
||||
// 执行单次命令并返回输出
|
||||
@@ -83,25 +59,7 @@ public class SshService {
|
||||
String privateKey,
|
||||
String passphrase,
|
||||
String command) throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
session.setConfig("PreferredAuthentications", conn.getAuthType() == Connection.AuthType.PASSWORD ? "password" : "publickey");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||
session.setPassword(password);
|
||||
}
|
||||
|
||||
session.connect(8000);
|
||||
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||
|
||||
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
||||
channel.setCommand(command);
|
||||
@@ -137,22 +95,22 @@ public class SshService {
|
||||
}
|
||||
|
||||
public static class SshSession {
|
||||
private final Session session;
|
||||
private final ChannelShell channel;
|
||||
private final InputStream outputStream;
|
||||
private final OutputStream inputStream;
|
||||
|
||||
public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
this.outputStream = outputStream;
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
public InputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
private final Session session;
|
||||
private final ChannelShell channel;
|
||||
private final InputStream outputStream;
|
||||
private final OutputStream inputStream;
|
||||
|
||||
public SshSession(Session session, ChannelShell channel, InputStream outputStream, OutputStream inputStream) {
|
||||
this.session = session;
|
||||
this.channel = channel;
|
||||
this.outputStream = outputStream;
|
||||
this.inputStream = inputStream;
|
||||
}
|
||||
|
||||
public InputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
public OutputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
@@ -169,16 +127,16 @@ public class SshService {
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return channel != null && channel.isConnected();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.sshmanager.util;
|
||||
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.jcraft.jsch.UIKeyboardInteractive;
|
||||
import com.jcraft.jsch.UserInfo;
|
||||
import com.sshmanager.entity.Connection;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Shared utility for creating JSch SSH sessions with consistent configuration.
|
||||
*
|
||||
* Supports two authentication modes:
|
||||
* <ul>
|
||||
* <li>PASSWORD — sets PreferredAuthentications=password,keyboard-interactive,
|
||||
* provides UserInfo + UIKeyboardInteractive so keyboard-interactive
|
||||
* challenges are answered with the same password.</li>
|
||||
* <li>PRIVATE_KEY — sets PreferredAuthentications=publickey only.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class JschUtil {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JschUtil.class);
|
||||
private static final int CONNECT_TIMEOUT = 10000;
|
||||
|
||||
private JschUtil() {}
|
||||
|
||||
/**
|
||||
* Create and connect an SSH session for the given connection configuration.
|
||||
*
|
||||
* @param conn the connection entity (host, port, username, authType)
|
||||
* @param password decrypted password (may be null for private-key auth)
|
||||
* @param privateKey decrypted private key PEM (may be null for password auth)
|
||||
* @param passphrase decrypted passphrase for the private key (may be null)
|
||||
* @return connected Session
|
||||
* @throws IllegalArgumentException if required credentials are missing or empty
|
||||
* @throws Exception if JSch fails to connect (auth failure, timeout, etc.)
|
||||
*/
|
||||
public static Session createSession(Connection conn, String password, String privateKey, String passphrase)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY) {
|
||||
if (privateKey == null || privateKey.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"SSH authentication failed: private key is empty. Please check the connection key configuration.");
|
||||
}
|
||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||
}
|
||||
|
||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||
session.setConfig("kex",
|
||||
"diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,"
|
||||
+ "diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||
|
||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"SSH authentication failed: password is empty. Please check the connection password configuration.");
|
||||
}
|
||||
session.setConfig("PreferredAuthentications", "password,keyboard-interactive");
|
||||
session.setPassword(password);
|
||||
session.setUserInfo(new PasswordAuth(password));
|
||||
} else {
|
||||
session.setConfig("PreferredAuthentications", "publickey");
|
||||
}
|
||||
|
||||
try {
|
||||
session.connect(CONNECT_TIMEOUT);
|
||||
} catch (Exception e) {
|
||||
// Sanitized log: connection metadata only, no secrets
|
||||
log.warn("SSH connection failed: connectionId={}, host={}, port={}, username={}, authType={}, error={}",
|
||||
conn.getId(), conn.getHost(), conn.getPort(), conn.getUsername(), conn.getAuthType(),
|
||||
formatAuthErrorMessage(e.getMessage()));
|
||||
throw e;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate common JSch exception messages into human-readable text.
|
||||
* Returns the original message if no pattern matches.
|
||||
*/
|
||||
public static String formatAuthErrorMessage(String rawMessage) {
|
||||
if (rawMessage == null) {
|
||||
return "SSH connection failed (unknown error)";
|
||||
}
|
||||
String msg = rawMessage.trim();
|
||||
|
||||
// Too many authentication failures — check before generic auth fail
|
||||
// because "Auth fail for methods ..." may appear in the same chain
|
||||
// but "too many" is the actionable root cause.
|
||||
if (msg.toLowerCase().contains("too many authentication failures")) {
|
||||
return "SSH authentication failed: too many failed attempts, server rejected the connection. "
|
||||
+ "Please check the password or authentication method, or try again later.";
|
||||
}
|
||||
|
||||
// Auth fail for methods 'publickey,password'
|
||||
// (may be prefixed with e.g. "com.jcraft.jsch.JSchException: Auth fail ...")
|
||||
int authFailIdx = msg.indexOf("Auth fail for methods ");
|
||||
if (authFailIdx >= 0) {
|
||||
String afterPrefix = msg.substring(authFailIdx + "Auth fail for methods ".length());
|
||||
String methods = afterPrefix.contains("'") ? afterPrefix.substring(0, afterPrefix.lastIndexOf('\'') + 1) : afterPrefix;
|
||||
methods = methods.replace("'", "");
|
||||
return "SSH authentication failed: please check username and password / private key. "
|
||||
+ "Server supports authentication methods: " + methods;
|
||||
}
|
||||
|
||||
// Auth cancel
|
||||
if (msg.toLowerCase().contains("auth") && msg.toLowerCase().contains("cancel")) {
|
||||
return "SSH authentication was cancelled by the server. Please check your credentials.";
|
||||
}
|
||||
|
||||
// Connection refused
|
||||
if (msg.toLowerCase().contains("connection refused")) {
|
||||
return "SSH connection refused. Please check that the host address and port are correct and the SSH service is running.";
|
||||
}
|
||||
|
||||
// Connection timeout
|
||||
if (msg.toLowerCase().contains("timeout") || msg.toLowerCase().contains("timed out")) {
|
||||
return "SSH connection timed out. Please check network connectivity and host reachability.";
|
||||
}
|
||||
|
||||
// Host key verification (should not happen with StrictHostKeyChecking=no, but just in case)
|
||||
if (msg.toLowerCase().contains("hostkey") || msg.toLowerCase().contains("host key")) {
|
||||
return "SSH host key verification failed. The server may have changed its host key.";
|
||||
}
|
||||
|
||||
// Unknown host
|
||||
if (msg.toLowerCase().contains("unknown host") || msg.toLowerCase().contains("unable to resolve")) {
|
||||
return "Unable to resolve host address. Please check the hostname.";
|
||||
}
|
||||
|
||||
// Socket / IO errors
|
||||
if (msg.toLowerCase().contains("socket") || msg.toLowerCase().contains("i/o error")) {
|
||||
return "Network I/O error during SSH connection. Please check network connectivity.";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines UserInfo and UIKeyboardInteractive so that password-based auth
|
||||
* also works when the server uses keyboard-interactive (e.g. some OpenSSH
|
||||
* configurations that prompt for password via keyboard-interactive).
|
||||
*
|
||||
* After one password attempt, subsequent prompts return false / empty
|
||||
* to prevent the same wrong password from being retried until the
|
||||
* server closes the connection with "Too many authentication failures".
|
||||
*/
|
||||
static class PasswordAuth implements UserInfo, UIKeyboardInteractive {
|
||||
private final String password;
|
||||
private boolean passwordAttempted = false;
|
||||
|
||||
PasswordAuth(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptYesNo(String message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassphrase() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptPassphrase(String message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean promptPassword(String message) {
|
||||
// Allow exactly one password prompt; reject retries with the same wrong credential
|
||||
if (passwordAttempted) return false;
|
||||
passwordAttempted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showMessage(String message) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] promptKeyboardInteractive(String destination, String name,
|
||||
String instruction, String[] prompt, boolean[] echo) {
|
||||
String[] result = new String[prompt.length];
|
||||
for (int i = 0; i < prompt.length; i++) {
|
||||
if (echo[i]) {
|
||||
result[i] = "";
|
||||
} else if (!passwordAttempted) {
|
||||
result[i] = password;
|
||||
} else {
|
||||
result[i] = "";
|
||||
}
|
||||
}
|
||||
passwordAttempted = true;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.sshmanager.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JschUtilTest {
|
||||
|
||||
// ── formatAuthErrorMessage ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void formatTooManyAuthFailures() {
|
||||
String raw = "Too many authentication failures";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
assertTrue(result.contains("too many failed attempts"), "Should detect too many authentication failures");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatTooManyAuthFailuresPrefixed() {
|
||||
String raw = "com.jcraft.jsch.JSchException: Too many authentication failures";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
assertTrue(result.contains("too many failed attempts"), "Should detect prefixed variant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatConnectionResetWithTooManyAuth() {
|
||||
// Connection reset wrapping the real cause — the formatter sees the raw message
|
||||
// (toSftpErrorMessage in the controller unwraps the cause chain separately)
|
||||
String raw = "Connection reset";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
// "Connection reset" by itself does NOT match "too many auth failures" in
|
||||
// formatAuthErrorMessage alone — the controller's toSftpErrorMessage
|
||||
// walks the cause chain to find the root JSchException first.
|
||||
// This test verifies the formatter doesn't crash on unrelated messages.
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatAuthFailPrefixed() {
|
||||
String raw = "com.jcraft.jsch.JSchException: Auth fail for methods 'publickey,password'";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
assertTrue(result.contains("authentication failed"), "Should detect auth failure");
|
||||
assertTrue(result.contains("publickey,password"), "Should extract supported methods");
|
||||
assertFalse(result.contains("JSchException"), "Should not contain raw exception class name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatAuthFailPlain() {
|
||||
String raw = "Auth fail for methods 'password,keyboard-interactive'";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
assertTrue(result.contains("authentication failed"), "Should detect auth failure");
|
||||
assertTrue(result.contains("keyboard-interactive"), "Should extract methods");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatConnectionRefused() {
|
||||
String result = JschUtil.formatAuthErrorMessage("Connection refused: connect");
|
||||
assertTrue(result.contains("connection refused"), "Should detect connection refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatTimeout() {
|
||||
String result = JschUtil.formatAuthErrorMessage("connect timed out");
|
||||
assertTrue(result.contains("timed out"), "Should detect timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatUnknownHost() {
|
||||
String result = JschUtil.formatAuthErrorMessage("unknown host: example.invalid");
|
||||
assertTrue(result.contains("resolve host"), "Should detect unknown host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatUnrecognizedReturnsOriginal() {
|
||||
String raw = "Some random error";
|
||||
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||
assertEquals(raw, result, "Unrecognized messages should pass through unchanged");
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatNullReturnsDefault() {
|
||||
String result = JschUtil.formatAuthErrorMessage(null);
|
||||
assertTrue(result.contains("unknown error"), "Null input should return a fallback message");
|
||||
}
|
||||
|
||||
// ── PasswordAuth behavior ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void passwordAuthFirstPromptPasswordReturnsTrue() {
|
||||
// Use reflection-like approach via the public API: instantiation is package-private
|
||||
// so we test the behavior indirectly through known contracts
|
||||
String password = "test-password";
|
||||
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||
|
||||
assertTrue(auth.promptPassword("Password:"), "First promptPassword should return true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void passwordAuthSecondPromptPasswordReturnsFalse() {
|
||||
String password = "test-password";
|
||||
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||
|
||||
auth.promptPassword("Password:"); // first call
|
||||
assertFalse(auth.promptPassword("Password:"), "Second promptPassword should return false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void passwordAuthKeyboardInteractiveFillsPasswordOnce() {
|
||||
String password = "test-password";
|
||||
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||
|
||||
String[] prompt = {"Password:", "OTP:"};
|
||||
boolean[] echo = {false, false};
|
||||
|
||||
// First call — fill non-echo prompts with password
|
||||
String[] result1 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||
assertEquals(password, result1[0], "First keyboard-interactive should return password");
|
||||
assertEquals(password, result1[1], "Should fill all non-echo prompts");
|
||||
|
||||
// Second call — no more password
|
||||
String[] result2 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||
assertEquals("", result2[0], "Second keyboard-interactive should return empty");
|
||||
assertEquals("", result2[1], "Second call should not fill password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void passwordAuthGetPasswordReturnsOriginal() {
|
||||
String password = "test-password";
|
||||
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||
assertEquals(password, auth.getPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
void passwordAuthEchoPromptsAreLeftEmpty() {
|
||||
String password = "test-password";
|
||||
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||
|
||||
String[] prompt = {"Username:", "Password:"};
|
||||
boolean[] echo = {true, false}; // username is echo, password is not
|
||||
|
||||
String[] result = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||
assertEquals("", result[0], "Echo prompt should be empty");
|
||||
assertEquals(password, result[1], "Non-echo prompt should get password");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user