[sshd] Better user feedback on authentication failure

When authentication fails, JGit produces an exception with an error
message telling the user that it could not log in (including the host
name). The causal chain has an SshException from Apache MINA sshd with
message "No more authentication methods available".

This is not very helpful. The user was left without any indication why
authentication failed.

Include in the exception message a log of all attempted authentications.
That way, the user can see which keys were tried, in which order and
with which signature algorithms. The log also reports authentication
attempts for gssapi-with-mic or password authentication. For
keyboard-interactive Apache MINA sshd is lacking a callback interface.

The way Apache MINA sshd loads keys from files, the file names are lost
in higher layers. Add a mechanism to record on the session for each
key fingerprint the file it was loaded from, if any. That way the
exception message can refer to keys by file name, which is easier to
understand by users than the rather cryptic key fingerprints.

Bug: 571390
Change-Id: Ic4b6ce6b99f307d5e798fcc91b16b9ffd995d224
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
This commit is contained in:
Thomas Wolf 2022-04-01 16:56:05 +02:00
parent 8b8999dca8
commit 4dd9a94ec5
12 changed files with 586 additions and 61 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -789,4 +789,76 @@ public void testConnectOnlyRsaSha1() throws Exception {
session.disconnect();
}
}
private void verifyAuthLog(String message, String first) {
assertTrue(message.contains(System.lineSeparator()));
String[] lines = message.split(System.lineSeparator());
int pubkeyIndex = -1;
int passwordIndex = -1;
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (i == 0) {
assertTrue(line.contains(first));
}
if (line.contains("publickey:")) {
if (pubkeyIndex < 0) {
pubkeyIndex = i;
assertTrue(line.contains("/userkey"));
}
} else if (line.contains("password:")) {
if (passwordIndex < 0) {
passwordIndex = i;
assertTrue(line.contains("attempt 1"));
}
}
}
assertTrue(pubkeyIndex > 0 && passwordIndex > 0);
assertTrue(pubkeyIndex < passwordIndex);
}
@Test
public void testAuthFailureMessageCancel() throws Exception {
File userKey = new File(getTemporaryDirectory(), "userkey");
copyTestResource("id_ed25519", userKey);
File publicKey = new File(getTemporaryDirectory(), "userkey.pub");
copyTestResource("id_ed25519.pub", publicKey);
// Don't set this as the user's key; we do want to try with a wrong key.
server.enablePasswordAuthentication();
TestCredentialsProvider provider = new TestCredentialsProvider(
"wrongpass");
TransportException e = assertThrows(TransportException.class,
() -> cloneWith("ssh://git/doesntmatter", defaultCloneDir,
provider, //
"Host git", //
"HostName localhost", //
"Port " + testPort, //
"User " + TEST_USER, //
"IdentityFile " + userKey.getAbsolutePath(), //
"PreferredAuthentications publickey,password"));
verifyAuthLog(e.getMessage(), "canceled");
}
@Test
public void testAuthFailureMessage() throws Exception {
File userKey = new File(getTemporaryDirectory(), "userkey");
copyTestResource("id_ed25519", userKey);
File publicKey = new File(getTemporaryDirectory(), "userkey.pub");
copyTestResource("id_ed25519.pub", publicKey);
// Don't set this as the user's key; we do want to try with a wrong key.
server.enablePasswordAuthentication();
// Enough passwords not to cancel authentication
TestCredentialsProvider provider = new TestCredentialsProvider(
"wrongpass", "wrongpass", "wrongpass");
TransportException e = assertThrows(TransportException.class,
() -> cloneWith("ssh://git/doesntmatter", defaultCloneDir,
provider, //
"Host git", //
"HostName localhost", //
"Port " + testPort, //
"User " + TEST_USER, //
"IdentityFile " + userKey.getAbsolutePath(), //
"PreferredAuthentications publickey,password"));
verifyAuthLog(e.getMessage(), "log in");
}
}

View File

@ -1,5 +1,22 @@
authenticationCanceled=SSH authentication canceled: no password given
authenticationOnClosedSession=Authentication canceled: session is already closing or closed
authGssApiAttempt={0}: trying mechanism OID {1}
authGssApiExhausted={0}: no more mechanisms to try
authGssApiFailure={0}: server refused authentication; mechanism {1}
authGssApiNotTried={0}: not tried
authGssApiPartialSuccess={0}: partial success with mechanism OID {1}, continue with authentication methods {2}
authPasswordAttempt={0}: attempt {1}
authPasswordChangeAttempt={0}: attempt {1} with password change
authPasswordExhausted={0}: no more attempts
authPasswordFailure={0}: server refused (wrong password)
authPasswordNotTried={0}: not tried
authPasswordPartialSuccess={0}: partial success, continue with authentication methods {1}
authPubkeyAttempt={0}: trying {1} key {2} with signature type {3}
authPubkeyAttemptAgent={0}: trying {1} key {2} from SSH agent with signature type {3}
authPubkeyExhausted={0}: no more keys to try
authPubkeyFailure={0}: server refused {1} key {2}
authPubkeyNoKeys={0}: no keys to try
authPubkeyPartialSuccess={0}: partial success for {1} key {2}, continue with authentication methods {3}
cannotReadPublicKey=Cannot read public key from file {0}
closeListenerFailed=Ssh session close listener failed
configInvalidPath=Invalid path in ssh config key {0}: {1}

View File

@ -0,0 +1,238 @@
/*
* Copyright (C) 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.internal.transport.sshd;
import static org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider.getKeyId;
import java.security.KeyPair;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.apache.sshd.client.auth.password.PasswordAuthenticationReporter;
import org.apache.sshd.client.auth.password.UserAuthPassword;
import org.apache.sshd.client.auth.pubkey.PublicKeyAuthenticationReporter;
import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.config.keys.KeyUtils;
/**
* Provides a log of authentication attempts for a {@link ClientSession}.
*/
public class AuthenticationLogger {
private final List<String> messages = new ArrayList<>();
// We're interested in this log only in the failure case, so we don't need
// to log authentication success.
private final PublicKeyAuthenticationReporter pubkeyLogger = new PublicKeyAuthenticationReporter() {
private boolean hasAttempts;
@Override
public void signalAuthenticationAttempt(ClientSession session,
String service, KeyPair identity, String signature)
throws Exception {
hasAttempts = true;
String message;
if (identity.getPrivate() == null) {
// SSH agent key
message = MessageFormat.format(
SshdText.get().authPubkeyAttemptAgent,
UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
getKeyId(session, identity), signature);
} else {
message = MessageFormat.format(
SshdText.get().authPubkeyAttempt,
UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
getKeyId(session, identity), signature);
}
messages.add(message);
}
@Override
public void signalAuthenticationExhausted(ClientSession session,
String service) throws Exception {
String message;
if (hasAttempts) {
message = MessageFormat.format(
SshdText.get().authPubkeyExhausted,
UserAuthPublicKey.NAME);
} else {
message = MessageFormat.format(SshdText.get().authPubkeyNoKeys,
UserAuthPublicKey.NAME);
}
messages.add(message);
hasAttempts = false;
}
@Override
public void signalAuthenticationFailure(ClientSession session,
String service, KeyPair identity, boolean partial,
List<String> serverMethods) throws Exception {
String message;
if (partial) {
message = MessageFormat.format(
SshdText.get().authPubkeyPartialSuccess,
UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
getKeyId(session, identity), serverMethods);
} else {
message = MessageFormat.format(
SshdText.get().authPubkeyFailure,
UserAuthPublicKey.NAME, KeyUtils.getKeyType(identity),
getKeyId(session, identity));
}
messages.add(message);
}
};
private final PasswordAuthenticationReporter passwordLogger = new PasswordAuthenticationReporter() {
private int attempts;
@Override
public void signalAuthenticationAttempt(ClientSession session,
String service, String oldPassword, boolean modified,
String newPassword) throws Exception {
attempts++;
String message;
if (modified) {
message = MessageFormat.format(
SshdText.get().authPasswordChangeAttempt,
UserAuthPassword.NAME, Integer.valueOf(attempts));
} else {
message = MessageFormat.format(
SshdText.get().authPasswordAttempt,
UserAuthPassword.NAME, Integer.valueOf(attempts));
}
messages.add(message);
}
@Override
public void signalAuthenticationExhausted(ClientSession session,
String service) throws Exception {
String message;
if (attempts > 0) {
message = MessageFormat.format(
SshdText.get().authPasswordExhausted,
UserAuthPassword.NAME);
} else {
message = MessageFormat.format(
SshdText.get().authPasswordNotTried,
UserAuthPassword.NAME);
}
messages.add(message);
attempts = 0;
}
@Override
public void signalAuthenticationFailure(ClientSession session,
String service, String password, boolean partial,
List<String> serverMethods) throws Exception {
String message;
if (partial) {
message = MessageFormat.format(
SshdText.get().authPasswordPartialSuccess,
UserAuthPassword.NAME, serverMethods);
} else {
message = MessageFormat.format(
SshdText.get().authPasswordFailure,
UserAuthPassword.NAME);
}
messages.add(message);
}
};
private final GssApiWithMicAuthenticationReporter gssLogger = new GssApiWithMicAuthenticationReporter() {
private boolean hasAttempts;
@Override
public void signalAuthenticationAttempt(ClientSession session,
String service, String mechanism) {
hasAttempts = true;
String message = MessageFormat.format(
SshdText.get().authGssApiAttempt,
GssApiWithMicAuthFactory.NAME, mechanism);
messages.add(message);
}
@Override
public void signalAuthenticationExhausted(ClientSession session,
String service) {
String message;
if (hasAttempts) {
message = MessageFormat.format(
SshdText.get().authGssApiExhausted,
GssApiWithMicAuthFactory.NAME);
} else {
message = MessageFormat.format(
SshdText.get().authGssApiNotTried,
GssApiWithMicAuthFactory.NAME);
}
messages.add(message);
hasAttempts = false;
}
@Override
public void signalAuthenticationFailure(ClientSession session,
String service, String mechanism, boolean partial,
List<String> serverMethods) {
String message;
if (partial) {
message = MessageFormat.format(
SshdText.get().authGssApiPartialSuccess,
GssApiWithMicAuthFactory.NAME, mechanism,
serverMethods);
} else {
message = MessageFormat.format(
SshdText.get().authGssApiFailure,
GssApiWithMicAuthFactory.NAME, mechanism);
}
messages.add(message);
}
};
/**
* Creates a new {@link AuthenticationLogger} and configures the
* {@link ClientSession} to report authentication attempts through this
* instance.
*
* @param session
* to configure
*/
public AuthenticationLogger(ClientSession session) {
session.setPublicKeyAuthenticationReporter(pubkeyLogger);
session.setPasswordAuthenticationReporter(passwordLogger);
session.setAttribute(
GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER,
gssLogger);
// TODO: keyboard-interactive? sshd 2.8.0 has no callback
// interface for it.
}
/**
* Retrieves the log messages for the authentication attempts.
*
* @return the messages as an unmodifiable list
*/
public List<String> getLog() {
return Collections.unmodifiableList(messages);
}
/**
* Drops all previously recorded log messages.
*/
public void clear() {
messages.clear();
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -11,6 +11,7 @@
import static java.text.MessageFormat.format;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
@ -19,18 +20,24 @@
import java.security.InvalidKeyException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.CancellationException;
import javax.security.auth.DestroyFailedException;
import org.apache.sshd.common.AttributeRepository.AttributeKey;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.config.keys.FilePasswordProvider;
import org.apache.sshd.common.config.keys.KeyUtils;
import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.common.util.io.resource.IoResource;
@ -43,6 +50,14 @@
public class CachingKeyPairProvider extends FileKeyPairProvider
implements Iterable<KeyPair> {
/**
* An attribute set on the {@link SessionContext} recording loaded keys by
* fingerprint. This enables us to provide nicer output by showing key
* paths, if possible. Users can identify key identities used easier by
* filename than by fingerprint.
*/
public static final AttributeKey<Map<String, Path>> KEY_PATHS_BY_FINGERPRINT = new AttributeKey<>();
private final KeyCache cache;
/**
@ -78,6 +93,33 @@ public Iterable<KeyPair> loadKeys(SessionContext session) {
return () -> iterator(session);
}
static String getKeyId(ClientSession session, KeyPair identity) {
String fingerprint = KeyUtils.getFingerPrint(identity.getPublic());
Map<String, Path> registered = session
.getAttribute(KEY_PATHS_BY_FINGERPRINT);
if (registered != null) {
Path path = registered.get(fingerprint);
if (path != null) {
Path home = session
.resolveAttribute(JGitSshClient.HOME_DIRECTORY);
if (home != null && path.startsWith(home)) {
try {
path = home.relativize(path);
String pathString = path.toString();
if (!pathString.isEmpty()) {
return "~" + File.separator + pathString; //$NON-NLS-1$
}
} catch (IllegalArgumentException e) {
// Cannot be relativized. Ignore, and work with the
// original path
}
}
return path.toString();
}
}
return fingerprint;
}
private KeyPair loadKey(SessionContext session, Path path)
throws IOException, GeneralSecurityException {
if (!Files.exists(path)) {
@ -123,13 +165,23 @@ private KeyPair loadKey(SessionContext session, NamedResource resource,
SshdText.get().identityFileUnsupportedFormat, path));
}
KeyPair result = keys.next();
PublicKey pk = result.getPublic();
if (pk != null) {
Map<String, Path> registered = session
.getAttribute(KEY_PATHS_BY_FINGERPRINT);
if (registered == null) {
registered = new HashMap<>();
session.setAttribute(KEY_PATHS_BY_FINGERPRINT, registered);
}
registered.put(KeyUtils.getFingerPrint(pk), path);
}
if (keys.hasNext()) {
log.warn(format(SshdText.get().identityFileMultipleKeys, path));
keys.forEachRemaining(k -> {
PrivateKey pk = k.getPrivate();
if (pk != null) {
PrivateKey priv = k.getPrivate();
if (priv != null) {
try {
pk.destroy();
priv.destroy();
} catch (DestroyFailedException e) {
// Ignore
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -18,6 +18,7 @@
import java.net.UnknownHostException;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import org.apache.sshd.client.auth.AbstractUserAuth;
import org.apache.sshd.client.session.ClientSession;
@ -71,7 +72,10 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
if (context != null) {
close(false);
}
GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
if (!nextMechanism.hasNext()) {
reporter.signalAuthenticationExhausted(session, service);
return false;
}
state = ProtocolState.STARTED;
@ -79,6 +83,7 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
// RFC 4462 states that SPNEGO must not be used with ssh
while (GssApiMechanisms.SPNEGO.equals(currentMechanism)) {
if (!nextMechanism.hasNext()) {
reporter.signalAuthenticationExhausted(session, service);
return false;
}
currentMechanism = nextMechanism.next();
@ -102,6 +107,10 @@ protected boolean sendAuthDataRequest(ClientSession session, String service)
state = ProtocolState.FAILED;
return false;
}
if (reporter != null) {
reporter.signalAuthenticationAttempt(session, service,
currentMechanism.toString());
}
Buffer buffer = session
.createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST);
buffer.putString(session.getUsername());
@ -246,4 +255,26 @@ private boolean unexpectedMessage(int command) {
return false;
}
@Override
public void signalAuthMethodSuccess(ClientSession session, String service,
Buffer buffer) throws Exception {
GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
if (reporter != null) {
reporter.signalAuthenticationSuccess(session, service,
currentMechanism.toString());
}
}
@Override
public void signalAuthMethodFailure(ClientSession session, String service,
boolean partial, List<String> serverMethods, Buffer buffer)
throws Exception {
GssApiWithMicAuthenticationReporter reporter = session.getAttribute(
GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER);
if (reporter != null) {
reporter.signalAuthenticationFailure(session, service,
currentMechanism.toString(), partial, serverMethods);
}
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.internal.transport.sshd;
import java.util.List;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.AttributeRepository.AttributeKey;
/**
* Callback interface for recording authentication state in
* {@link GssApiWithMicAuthentication}.
*/
public interface GssApiWithMicAuthenticationReporter {
/**
* An {@link AttributeKey} for a {@link ClientSession} holding the
* {@link GssApiWithMicAuthenticationReporter}.
*/
static final AttributeKey<GssApiWithMicAuthenticationReporter> GSS_AUTHENTICATION_REPORTER = new AttributeKey<>();
/**
* Called when a new authentication attempt is made.
*
* @param session
* the {@link ClientSession}
* @param service
* the name of the requesting SSH service name
* @param mechanism
* the OID of the mechanism used
*/
default void signalAuthenticationAttempt(ClientSession session,
String service, String mechanism) {
// nothing
}
/**
* Called when there are no more mechanisms to try.
*
* @param session
* the {@link ClientSession}
* @param service
* the name of the requesting SSH service name
*/
default void signalAuthenticationExhausted(ClientSession session,
String service) {
// nothing
}
/**
* Called when authentication was succeessful.
*
* @param session
* the {@link ClientSession}
* @param service
* the name of the requesting SSH service name
* @param mechanism
* the OID of the mechanism used
*/
default void signalAuthenticationSuccess(ClientSession session,
String service, String mechanism) {
// nothing
}
/**
* Called when the authentication was not successful.
*
* @param session
* the {@link ClientSession}
* @param service
* the name of the requesting SSH service name
* @param mechanism
* the OID of the mechanism used
* @param partial
* {@code true} if authentication was partially successful,
* meaning one continues with additional authentication methods
* given by {@code serverMethods}
* @param serverMethods
* the {@link List} of authentication methods that can continue
*/
default void signalAuthenticationFailure(ClientSession session,
String service, String mechanism, boolean partial,
List<String> serverMethods) {
// nothing
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -11,13 +11,11 @@
import static org.apache.sshd.core.CoreModuleProperties.PASSWORD_PROMPTS;
import org.apache.sshd.client.auth.keyboard.UserInteraction;
import org.apache.sshd.client.auth.password.UserAuthPassword;
import org.apache.sshd.client.session.ClientSession;
/**
* A password authentication handler that uses the {@link JGitUserInteraction}
* to ask the user for the password. It also respects the
* A password authentication handler that respects the
* {@code NumberOfPasswordPrompts} ssh config.
*/
public class JGitPasswordAuthentication extends UserAuthPassword {
@ -35,30 +33,11 @@ public void init(ClientSession session, String service) throws Exception {
}
@Override
protected boolean sendAuthDataRequest(ClientSession session, String service)
throws Exception {
protected String resolveAttemptedPassword(ClientSession session,
String service) throws Exception {
if (++attempts > maxAttempts) {
return false;
return null;
}
UserInteraction interaction = session.getUserInteraction();
if (!interaction.isInteractionAllowed(session)) {
return false;
}
String password = getPassword(session, interaction);
if (password == null) {
throw new AuthenticationCanceledException();
}
// sendPassword takes a buffer as first argument, but actually doesn't
// use it and creates its own buffer...
sendPassword(null, session, password, password);
return true;
}
private String getPassword(ClientSession session,
UserInteraction interaction) {
String[] results = interaction.interactive(session, null, null, "", //$NON-NLS-1$
new String[] { SshdText.get().passwordPrompt },
new boolean[] { false });
return (results == null || results.length == 0) ? null : results[0];
return super.resolveAttemptedPassword(session, service);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -86,6 +86,11 @@ public class JGitSshClient extends SshClient {
*/
public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
/**
* An attribute key for the home directory.
*/
public static final AttributeKey<Path> HOME_DIRECTORY = new AttributeKey<>();
/**
* An attribute key for storing an alternate local address to connect to if
* a local forward from a ProxyJump ssh config is present. If set,

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -120,15 +120,16 @@ public String[] interactive(ClientSession session, String name,
return null;
}).filter(s -> s != null).toArray(String[]::new);
}
// TODO What to throw to abort the connection/authentication process?
// In UserAuthKeyboardInteractive.getUserResponses() it's clear that
// returning null is valid and signifies "an error"; we'll try the
// next authentication method. But if the user explicitly canceled,
// then we don't want to try the next methods...
//
// Probably not a serious issue with the typical order of public-key,
// keyboard-interactive, password.
return null;
throw new AuthenticationCanceledException();
}
@Override
public String resolveAuthPasswordAttempt(ClientSession session)
throws Exception {
String[] results = interactive(session, null, null, "", //$NON-NLS-1$
new String[] { SshdText.get().passwordPrompt },
new boolean[] { false });
return (results == null || results.length == 0) ? null : results[0];
}
@Override

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -29,6 +29,23 @@ public static SshdText get() {
// @formatter:off
/***/ public String authenticationCanceled;
/***/ public String authenticationOnClosedSession;
/***/ public String authGssApiAttempt;
/***/ public String authGssApiExhausted;
/***/ public String authGssApiFailure;
/***/ public String authGssApiNotTried;
/***/ public String authGssApiPartialSuccess;
/***/ public String authPasswordAttempt;
/***/ public String authPasswordChangeAttempt;
/***/ public String authPasswordExhausted;
/***/ public String authPasswordFailure;
/***/ public String authPasswordNotTried;
/***/ public String authPasswordPartialSuccess;
/***/ public String authPubkeyAttempt;
/***/ public String authPubkeyAttemptAgent;
/***/ public String authPubkeyExhausted;
/***/ public String authPubkeyFailure;
/***/ public String authPubkeyNoKeys;
/***/ public String authPubkeyPartialSuccess;
/***/ public String closeListenerFailed;
/***/ public String cannotReadPublicKey;
/***/ public String configInvalidPath;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -52,6 +52,8 @@
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
import org.eclipse.jgit.internal.transport.sshd.AuthenticationLogger;
import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
import org.eclipse.jgit.internal.transport.sshd.SshdText;
import org.eclipse.jgit.transport.FtpChannel;
@ -119,6 +121,7 @@ private ClientSession connect(URIish target, List<URIish> jumps,
ClientSession resultSession = null;
ClientSession proxySession = null;
PortForwardingTracker portForward = null;
AuthenticationLogger authLog = null;
try {
if (!hops.isEmpty()) {
URIish hop = hops.remove(0);
@ -165,6 +168,7 @@ private ClientSession connect(URIish target, List<URIish> jumps,
resultSession.addCloseFutureListener(listener);
}
// Authentication timeout is by default 2 minutes.
authLog = new AuthenticationLogger(resultSession);
resultSession.auth().verify(resultSession.getAuthTimeout());
return resultSession;
} catch (IOException e) {
@ -173,17 +177,34 @@ private ClientSession connect(URIish target, List<URIish> jumps,
close(resultSession, e);
if (e instanceof SshException && ((SshException) e)
.getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) {
// Ensure the user gets to know on which URI the authentication
// was denied.
String message = format(SshdText.get().loginDenied, host,
Integer.toString(port));
throw new TransportException(target,
format(SshdText.get().loginDenied, host,
Integer.toString(port)),
e);
withAuthLog(message, authLog), e);
} else if (e instanceof SshException && e
.getCause() instanceof AuthenticationCanceledException) {
String message = e.getCause().getMessage();
throw new TransportException(target,
withAuthLog(message, authLog), e.getCause());
}
throw e;
} finally {
if (authLog != null) {
authLog.clear();
}
}
}
private String withAuthLog(String message, AuthenticationLogger authLog) {
if (authLog != null) {
String log = String.join(System.lineSeparator(), authLog.getLog());
if (!log.isEmpty()) {
return message + System.lineSeparator() + log;
}
}
return message;
}
private ClientSession connect(HostConfigEntry config,
AttributeRepository context, Duration timeout)
throws IOException {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2018, 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
* Copyright (C) 2018, 2022 Thomas Wolf <thomas.wolf@paranor.ch> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
@ -13,6 +13,7 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.security.KeyPair;
import java.time.Duration;
@ -34,7 +35,6 @@
import org.apache.sshd.client.auth.keyboard.UserAuthKeyboardInteractiveFactory;
import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.compression.BuiltinCompressions;
import org.apache.sshd.common.config.keys.FilePasswordProvider;
import org.apache.sshd.common.config.keys.loader.openssh.kdf.BCryptKdfOptions;
@ -44,7 +44,6 @@
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile;
import org.eclipse.jgit.internal.transport.sshd.AuthenticationCanceledException;
import org.eclipse.jgit.internal.transport.sshd.CachingKeyPairProvider;
import org.eclipse.jgit.internal.transport.sshd.GssApiWithMicAuthFactory;
import org.eclipse.jgit.internal.transport.sshd.JGitPasswordAuthFactory;
@ -243,6 +242,12 @@ public SshdSession getSession(URIish uri,
JGitSshClient.PREFERRED_AUTHENTICATIONS,
defaultAuths);
}
try {
jgitClient.setAttribute(JGitSshClient.HOME_DIRECTORY,
home.getAbsoluteFile().toPath());
} catch (SecurityException | InvalidPathException e) {
// Ignore
}
// Other things?
return client;
});
@ -255,13 +260,7 @@ public SshdSession getSession(URIish uri,
if (e instanceof TransportException) {
throw (TransportException) e;
}
Throwable cause = e;
if (e instanceof SshException && e
.getCause() instanceof AuthenticationCanceledException) {
// Results in a nicer error message
cause = e.getCause();
}
throw new TransportException(uri, cause.getMessage(), cause);
throw new TransportException(uri, e.getMessage(), e);
}
}