From 4dd9a94ec532aa2512dc220ab9d2f4929264c741 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Fri, 1 Apr 2022 16:56:05 +0200 Subject: [PATCH] [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 --- .../jgit/transport/sshd/ApacheSshTest.java | 74 +++++- .../transport/sshd/SshdText.properties | 17 ++ .../transport/sshd/AuthenticationLogger.java | 238 ++++++++++++++++++ .../sshd/CachingKeyPairProvider.java | 60 ++++- .../sshd/GssApiWithMicAuthentication.java | 33 ++- .../GssApiWithMicAuthenticationReporter.java | 93 +++++++ .../sshd/JGitPasswordAuthentication.java | 33 +-- .../transport/sshd/JGitSshClient.java | 7 +- .../transport/sshd/JGitUserInteraction.java | 21 +- .../internal/transport/sshd/SshdText.java | 19 +- .../jgit/transport/sshd/SshdSession.java | 33 ++- .../transport/sshd/SshdSessionFactory.java | 19 +- 12 files changed, 586 insertions(+), 61 deletions(-) create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java create mode 100644 org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java index 3d7c7651c..a8fcca7b8 100644 --- a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java +++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2020 Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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"); + } + } diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties index 4f735bab3..c67622180 100644 --- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties +++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties @@ -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} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java new file mode 100644 index 000000000..add79b35c --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/AuthenticationLogger.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2022 Thomas Wolf 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 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 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 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 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 getLog() { + return Collections.unmodifiableList(messages); + } + + /** + * Drops all previously recorded log messages. + */ + public void clear() { + messages.clear(); + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java index 79b3637ca..cbd6a6414 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/CachingKeyPairProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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 { + /** + * 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> KEY_PATHS_BY_FINGERPRINT = new AttributeKey<>(); + private final KeyCache cache; /** @@ -78,6 +93,33 @@ public Iterable loadKeys(SessionContext session) { return () -> iterator(session); } + static String getKeyId(ClientSession session, KeyPair identity) { + String fingerprint = KeyUtils.getFingerPrint(identity.getPublic()); + Map 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 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 } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java index c3cac0c1d..df01db316 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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 serverMethods, Buffer buffer) + throws Exception { + GssApiWithMicAuthenticationReporter reporter = session.getAttribute( + GssApiWithMicAuthenticationReporter.GSS_AUTHENTICATION_REPORTER); + if (reporter != null) { + reporter.signalAuthenticationFailure(session, service, + currentMechanism.toString(), partial, serverMethods); + } + } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java new file mode 100644 index 000000000..201a13165 --- /dev/null +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/GssApiWithMicAuthenticationReporter.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2022 Thomas Wolf 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 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 serverMethods) { + // nothing + } +} diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java index ff8caaacc..33c3c608f 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitPasswordAuthentication.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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); } } diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java index 71e8e6158..72f0bdb6e 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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 PREFERRED_AUTHENTICATIONS = new AttributeKey<>(); + /** + * An attribute key for the home directory. + */ + public static final AttributeKey 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, diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java index c51a75bc6..2a725ea16 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitUserInteraction.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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 diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java index 19ad85c83..39332d9fc 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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; diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java index b742f5ea4..b94ccc6d4 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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 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 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 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 { diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java index 58cf8e1dd..c792c1889 100644 --- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java +++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2021 Thomas Wolf and others + * Copyright (C) 2018, 2022 Thomas Wolf 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); } }