diff --git a/pom.xml b/pom.xml index 170fbac..53b984a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.jenkins-ci.plugins plugin - 4.86 + 4.82 @@ -112,11 +112,26 @@ io.jenkins.plugins gson-api - + io.jenkins.plugins eddsa-api 0.3.0-4.v84c6f0f4969e - + + + org.jenkins-ci.plugins + variant + + + org.jenkins-ci.plugins + ssh-credentials + true + + + + io.jenkins.plugins.mina-sshd-api + mina-sshd-api-core + test + diff --git a/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticator.java b/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticator.java new file mode 100644 index 0000000..5b1420f --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticator.java @@ -0,0 +1,197 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2012, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.plugins.trileadapi; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticatorFactory; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import com.trilead.ssh2.Connection; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import org.jenkinsci.plugins.variant.OptionalExtension; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; + +/** + * Does password auth with a {@link Connection}. + */ +public class TrileadSSHPasswordAuthenticator extends SSHAuthenticator { + + /** + * Our logger + */ + private static final Logger LOGGER = Logger.getLogger(TrileadSSHPasswordAuthenticator.class.getName()); + private static final String PASSWORD = "password"; + private static final String KEYBOARD_INTERACTIVE = "keyboard-interactive"; + + /** + * Constructor. + * + * @param connection the connection we will be authenticating. + * @deprecated + */ + @Deprecated + public TrileadSSHPasswordAuthenticator(Connection connection, StandardUsernamePasswordCredentials user) { + this(connection, user, null); + } + + /** + * Constructor. + * + * @param connection the connection we will be authenticating. + * @since 1.4 + */ + public TrileadSSHPasswordAuthenticator(@NonNull Connection connection, + @NonNull StandardUsernamePasswordCredentials user, + @CheckForNull String username) { + super(connection, user, username); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canAuthenticate() { + try { + for (String authMethod : getConnection().getRemainingAuthMethods(getUsername())) { + if (PASSWORD.equals(authMethod)) { + // prefer password + return true; + } + if (KEYBOARD_INTERACTIVE.equals(authMethod)) { + return true; + } + } + } catch (IOException e) { + e.printStackTrace(getListener().error("Failed to authenticate")); + } + return false; + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean doAuthenticate() { + final StandardUsernamePasswordCredentials user = getUser(); + final String username = getUsername(); + + try { + final Connection connection = getConnection(); + final String password = user.getPassword().getPlainText(); + boolean tried = false; + + List availableMethods = Arrays.asList(connection.getRemainingAuthMethods(username)); + if (availableMethods.contains(PASSWORD)) { + // prefer password + if (connection.authenticateWithPassword(username, password)) { + LOGGER.fine("Authentication with 'password' succeeded."); + return true; + } + getListener().error("Failed to authenticate as %s. Wrong password. (credentialId:%s/method:password)", + username, user.getId()); + tried = true; + } + if (availableMethods.contains(KEYBOARD_INTERACTIVE)) { + if (connection.authenticateWithKeyboardInteractive(username, (name, instruction, numPrompts, prompt, echo) -> { + // most SSH servers just use keyboard interactive to prompt for the password + // match "assword" is safer than "password"... you don't *want* to know why! + return prompt != null && prompt.length > 0 && prompt[0].toLowerCase(Locale.ENGLISH) + .contains("assword") + ? new String[]{password} + : new String[0]; + })) { + LOGGER.fine("Authentication with 'keyboard-interactive' succeeded."); + return true; + } + getListener() + .error("Failed to authenticate as %s. Wrong password. " + + "(credentialId:%s/method:keyboard-interactive)", + username, user.getId()); + tried = true; + } + + if (!tried) { + getListener().error("The server does not allow password authentication. Available options are %s", + availableMethods); + } + } catch (IOException e) { + e.printStackTrace(getListener() + .error("Unexpected error while trying to authenticate as %s with credential=%s", username, + user.getId())); + } + return false; + } + + /** + * {@inheritDoc} + */ + @OptionalExtension(requirePlugins = {"ssh-credentials"}) + public static class Factory extends SSHAuthenticatorFactory { + + /** + * {@inheritDoc} + */ + @Override + protected SSHAuthenticator newInstance(@NonNull C connection, + @NonNull U user) { + return newInstance(connection, user, null); + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + @SuppressWarnings("unchecked") + protected SSHAuthenticator newInstance(@NonNull C connection, + @NonNull U user, + @CheckForNull String + username) { + if (supports(connection.getClass(), user.getClass())) { + return (SSHAuthenticator) new TrileadSSHPasswordAuthenticator((Connection) connection, + (StandardUsernamePasswordCredentials) user, username); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean supports(@NonNull Class connectionClass, + @NonNull Class userClass) { + return Connection.class.isAssignableFrom(connectionClass) + && StandardUsernamePasswordCredentials.class.isAssignableFrom(userClass); + } + + private static final long serialVersionUID = 1L; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticator.java b/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticator.java new file mode 100644 index 0000000..89fd6ef --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticator.java @@ -0,0 +1,184 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2012, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.plugins.trileadapi; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticatorFactory; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.trilead.ssh2.Connection; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.util.Secret; +import org.jenkinsci.plugins.variant.OptionalExtension; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.logging.Logger; + +/** + * Does public key auth with a {@link Connection}. + */ +public class TrileadSSHPublicKeyAuthenticator extends SSHAuthenticator { + + /** + * Our logger. + */ + private static final Logger LOGGER = Logger.getLogger(TrileadSSHPublicKeyAuthenticator.class.getName()); + private static final String PUBLICKEY = "publickey"; + + /** + * Constructor. + * + * @param connection the connection we will be authenticating. + */ + public TrileadSSHPublicKeyAuthenticator(Connection connection, SSHUserPrivateKey user) { + this(connection, user, null); + } + + /** + * Constructor. + * + * @param connection the connection we will be authenticating. + */ + public TrileadSSHPublicKeyAuthenticator(@NonNull Connection connection, + @NonNull SSHUserPrivateKey user, + @CheckForNull String username) { + super(connection, user, username); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean canAuthenticate() { + try { + return getRemainingAuthMethods().contains(PUBLICKEY); + } catch (IOException e) { + e.printStackTrace(getListener().error("Failed to authenticate")); + return false; + } + } + + private List getRemainingAuthMethods() throws IOException { + return Arrays.asList(getConnection().getRemainingAuthMethods(getUsername())); + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean doAuthenticate() { + final SSHUserPrivateKey user = getUser(); + final String username = getUsername(); + try { + final Connection connection = getConnection(); + final Secret userPassphrase = user.getPassphrase(); + final String passphrase = userPassphrase == null ? null : userPassphrase.getPlainText(); + + Collection availableMethods = getRemainingAuthMethods(); + if (availableMethods.contains(PUBLICKEY)) { + int count = 0; + List ioe = new ArrayList<>(); + for (String privateKey : getPrivateKeys(user)) { + try { + if (connection.authenticateWithPublicKey(username, privateKey.toCharArray(), passphrase)) { + LOGGER.fine("Authentication with 'publickey' succeeded."); + return true; + } + } catch (IOException e) { + ioe.add(e); + } + count++; + getListener() + .error("Server rejected the %d private key(s) for %s (credentialId:%s/method:publickey)", + count, username, user.getId()); + } + for (IOException e : ioe) { + e.printStackTrace(getListener() + .error("Failed to authenticate as %s with credential=%s", username, getUser().getId())); + } + return false; + } else { + getListener().error("The server does not allow public key authentication. Available options are %s", + availableMethods); + return false; + } + } catch (IOException e) { + e.printStackTrace(getListener() + .error("Failed to authenticate as %s with credential=%s", username, getUser().getId())); + return false; + } + } + + /** + * {@inheritDoc} + */ + @OptionalExtension(requirePlugins = {"ssh-credentials"}) + public static class Factory extends SSHAuthenticatorFactory { + + /** + * {@inheritDoc} + */ + @Override + @SuppressWarnings("unchecked") + protected SSHAuthenticator newInstance(@NonNull C connection, + @NonNull U user) { + return newInstance(connection, user, null); + } + + /** + * {@inheritDoc} + */ + @Nullable + @Override + @SuppressWarnings("unchecked") + protected SSHAuthenticator newInstance(@NonNull C connection, + @NonNull U user, + @CheckForNull String + username) { + if (supports(connection.getClass(), user.getClass())) { + return (SSHAuthenticator) new TrileadSSHPublicKeyAuthenticator((Connection) connection, + (SSHUserPrivateKey) user, username); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean supports(@NonNull Class connectionClass, + @NonNull Class userClass) { + return Connection.class.isAssignableFrom(connectionClass) + && SSHUserPrivateKey.class.isAssignableFrom(userClass); + } + + private static final long serialVersionUID = 1L; + } +} diff --git a/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticatorTest.java b/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticatorTest.java new file mode 100644 index 0000000..61805ad --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPasswordAuthenticatorTest.java @@ -0,0 +1,296 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2012, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.plugins.trileadapi; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticatorFactory; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPassword; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import com.trilead.ssh2.Connection; +import com.trilead.ssh2.ServerHostKeyVerifier; +import hudson.model.Computer; +import hudson.model.Items; +import hudson.model.TaskListener; +import hudson.remoting.VirtualChannel; +import hudson.slaves.DumbSlave; +import jenkins.security.MasterToSlaveCallable; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertNotNull; +import static org.hamcrest.MatcherAssert.assertThat; + +public class TrileadSSHPasswordAuthenticatorTest { + + private Connection connection; + private StandardUsernamePasswordCredentials user; + private Object sshd; + + @Rule public JenkinsRule r = new JenkinsRule(); + + @After + public void tearDown() { + if (connection != null) { + connection.close(); + connection = null; + } + if (sshd!=null) { + try { + invoke(sshd, "stop", new Class[] {Boolean.TYPE}, new Object[] {true}); + } catch (Throwable t) { + Logger.getLogger(getClass().getName()).log(Level.WARNING, "Problems shutting down ssh server", t); + } + } + } + + // disabled as Apache MINA sshd does not provide easy mech for giving a Keyboard Interactive authenticator + // so this test relies on having a local sshd which is keyboard interactive only + public void dontTestKeyboardInteractive() throws Exception { + connection = new Connection("localhost"); + connection.connect(new NoVerifier()); + TrileadSSHPasswordAuthenticator instance = + new TrileadSSHPasswordAuthenticator(connection, new BasicSSHUserPassword(CredentialsScope.SYSTEM, + null, "....", // <---- put your username here + "....", // <---- put your password here + null)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } + + @Before + public void setUp() { + user =(StandardUsernamePasswordCredentials) Items.XSTREAM.fromXML(Items.XSTREAM.toXML(new BasicSSHUserPassword(CredentialsScope.SYSTEM, null, "foobar", "foomanchu", null))); + } + + @Test + public void testPassword() throws Exception { + sshd = createPasswordAuthenticatedSshServer(); + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect(new NoVerifier()); + TrileadSSHPasswordAuthenticator instance = + new TrileadSSHPasswordAuthenticator(connection, user); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } + + private Object createPasswordAuthenticatedSshServer() throws InvocationTargetException, NoSuchMethodException, ClassNotFoundException, InstantiationException, IllegalAccessException { + return createPasswordAuthenticatedSshServer(null); + } + + private Object createPasswordAuthenticatedSshServer(final String username) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, InstantiationException { + Object sshd = newDefaultSshServer(); + Class keyPairProviderClass = newKeyPairProviderClass(); + Object provider = newProvider(); + Class authenticatorClass = newAuthenticatorClass(); + Object authenticator = newAuthenticator(authenticatorClass, username); + Object factory = newFactory(); + + invoke(sshd, "setPort", new Class[] {Integer.TYPE}, new Object[] {0}); + invoke(sshd, "setKeyPairProvider", new Class[] {keyPairProviderClass}, new Object[] {provider}); + invoke(sshd, "setPasswordAuthenticator", new Class[] {authenticatorClass}, new Object[] {authenticator}); + invoke(sshd, "setUserAuthFactories", new Class[] {List.class}, new Object[] {Collections.singletonList(factory)}); + + return sshd; + } + + @Test + public void testFactory() throws Exception { + sshd = createPasswordAuthenticatedSshServer(); + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect(new NoVerifier()); + SSHAuthenticator instance = SSHAuthenticator.newInstance(connection, user); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } + + @Test + public void testFactoryAltUsername() throws Exception { + sshd = createPasswordAuthenticatedSshServer("bill"); + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect(new NoVerifier()); + SSHAuthenticator instance = SSHAuthenticator.newInstance(connection, user, null); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(false)); + assertThat(instance.isAuthenticated(), is(false)); + connection = new Connection("localhost", port); + connection.connect(new NoVerifier()); + instance = SSHAuthenticator.newInstance(connection, user, "bill"); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } + + /** + * Brings the {@link SSHAuthenticatorFactory} to a slave. + */ + @Test + public void testSlave() throws Exception { + Object sshd = createPasswordAuthenticatedSshServer(); + invoke(sshd, "start", null, null); + + DumbSlave s = r.createSlave(); + Computer c = s.toComputer(); + assertNotNull(c); + c.connect(false).get(); + + final int port = (Integer)invoke(sshd, "getPort", null, null); + + TaskListener l = r.createTaskListener(); + VirtualChannel channel = c.getChannel(); + assertNotNull(channel); + channel.call(new RemoteConnectionTest(port, user)); + } + + private static class NoVerifier implements ServerHostKeyVerifier { + public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, + byte[] serverHostKey) { + return true; + } + } + + private static final class RemoteConnectionTest extends MasterToSlaveCallable { + private final int port; + private final StandardUsernamePasswordCredentials user; + + public RemoteConnectionTest(int port, StandardUsernamePasswordCredentials user) { + this.port = port; + this.user = user; + } + + public Void call() throws Exception { + Connection connection = new Connection("localhost", port); + connection.connect(new NoVerifier()); + SSHAuthenticator instance = SSHAuthenticator.newInstance(connection,user); + + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + instance.authenticateOrFail(); + assertThat(instance.isAuthenticated(), is(true)); + connection.close(); + return null; + } + + private static final long serialVersionUID = 1L; + } + + private Object invoke(Object target, String methodName, Class[] parameterTypes, Object[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + return target.getClass().getMethod(methodName, parameterTypes).invoke(target, args); + } + + private Object newDefaultSshServer() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Object server = null; + Class serverClass; + try { + serverClass = Class.forName("org.apache.sshd.SshServer"); + } catch (ClassNotFoundException e) { + serverClass = Class.forName("org.apache.sshd.server.SshServer"); + } + + server = serverClass.getDeclaredMethod("setUpDefaultServer").invoke(null); + assertNotNull(server); + + return server; + } + + private Class newKeyPairProviderClass() throws ClassNotFoundException { + Class keyPairProviderClass; + try { + keyPairProviderClass = Class.forName("org.apache.sshd.common.KeyPairProvider"); + } catch (ClassNotFoundException e) { + keyPairProviderClass = Class.forName("org.apache.sshd.common.keyprovider.KeyPairProvider"); + } + + return keyPairProviderClass; + } + + private Object newProvider() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + Class providerClass = Class.forName("org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider"); + Object provider = providerClass.getConstructor().newInstance(); + assertNotNull(provider); + + return provider; + } + + private Class newAuthenticatorClass() throws ClassNotFoundException { + Class authenticatorClass; + try { + authenticatorClass = Class.forName("org.apache.sshd.server.auth.password.PasswordAuthenticator"); + } catch(ClassNotFoundException e) { + authenticatorClass = Class.forName("org.apache.sshd.server.PasswordAuthenticator"); + } + + return authenticatorClass; + } + + private Object newAuthenticator(Class authenticatorClass, final String userName) throws IllegalArgumentException { + Object authenticator = Proxy.newProxyInstance( + authenticatorClass.getClassLoader(), new Class[]{authenticatorClass}, (proxy, method, args) -> + method.getName().equals("authenticate") ? + (userName == null || userName.equals(args[0])) && "foomanchu".equals(args[1]) : + null); + assertNotNull(authenticator); + return authenticator; + } + + private Object newFactory() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + Object factory = null; + Class factoryClass; + try { + factoryClass = Class.forName("org.apache.sshd.server.auth.UserAuthPassword$Factory"); + } catch (ClassNotFoundException e) { + factoryClass = Class.forName("org.apache.sshd.server.auth.password.UserAuthPasswordFactory"); + } + + factory = factoryClass.getConstructor().newInstance(); + + assertNotNull(factory); + return factory; + } +} diff --git a/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticatorTest.java b/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticatorTest.java new file mode 100644 index 0000000..f407105 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/trileadapi/TrileadSSHPublicKeyAuthenticatorTest.java @@ -0,0 +1,308 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2012, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.plugins.trileadapi; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsDescriptor; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.trilead.ssh2.Connection; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.util.Secret; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import java.lang.reflect.InvocationTargetException; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.lang.reflect.Proxy.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotNull; + +public class TrileadSSHPublicKeyAuthenticatorTest { + + private Connection connection; + private SSHUserPrivateKey user; + + @Rule public JenkinsRule r = new JenkinsRule(); + + @After + public void tearDown() { + if (connection != null) { + connection.close(); + connection = null; + } + } + + @Before + public void setUp() { + user = new SSHUserPrivateKey() { + + @NonNull + public String getUsername() { + return "foobar"; + } + + @NonNull + public String getDescription() { + return ""; + } + + @NonNull + public String getId() { + return ""; + } + + public CredentialsScope getScope() { + return CredentialsScope.SYSTEM; + } + + @NonNull + public CredentialsDescriptor getDescriptor() { + return new CredentialsDescriptor() { + @NonNull + @Override + public String getDisplayName() { + return ""; + } + }; + } + + @CheckForNull + public Secret getPassphrase() { + return null; + } + + @NonNull + public List getPrivateKeys() { + // just want a valid key... I generated this and have thrown it away (other than here) + // do not use other than in this test + return List.of("-----BEGIN RSA PRIVATE KEY-----\n" + + "MIICWQIBAAKBgQDADDwooNPJNQB4N4bJPiBgq/rkWKMABApX0w4trSkkX5q+l+CL\n" + + "CuddGGAsAu6XPari8v49ipbBmHqRLP9+X3ARGWKU2gDvGTBr99/ReUl2YgVjCwy+\n" + + "KMrGCN7SNTgRo6StwVaPhh6pUpNTQciDe/kOwUnQFWSM6/lwkOD1Uod45wIBIwKB\n" + + "gHi3O8HELVnmzRhdaqphkLHLL/0/B18Ye4epPBy1/JqFPLJQ1kjFBnUIAe/HVCSN\n" + + "KZX30wIcmUZ9GdeYoJiTwsfTy9t2KwHjqrapTfiekVZAW+3iDBqRZMxQ5MoK7b6g\n" + + "w5HrrtrtPfYuAsBnYjIS6qsKAVT3vdolJ5eai/RlPO4LAkEA76YuUozC/dW7Ox+R\n" + + "1Njd6cWJsRVXGemkSYY/rSh0SbfHAebqL/bDg8xXim9UiuD9Hc6md3glHQj6iKvl\n" + + "BxWq4QJBAM0moKiM16WFSFJP1wVDj0Bnx6DkJYSpf5u+C0ghBVoqIYKq6/P/gRE2\n" + + "+ColsLu6AYftaEJVpAgxeTU/IsGoJMcCQHRmqMkCipiMYkFJ2R49cxnGWNJa0ojt\n" + + "03QrQ3/9tNNZQ2dS5sbW8UAEKoURgNW9vMVVvpHMpE/uaw8u65W6ESsCQDTAyjn4\n" + + "VLWIrDJsTTveLCaBFhNt3cMHA45ysnGiF1GzD+5mdzAdITBP9qvAjIgLQjjlRrH4\n" + + "w8eXsXQXjJgyjR0CQHfvhiMPG5pWwmXpsEOFo6GKSvOC/5sNEcnddenuO/2T7WWi\n" + + "o1LQh9naeuX8gti0vNR8+KtMEaIcJJeWnk56AVY=\n" + + "-----END RSA PRIVATE KEY-----\n"); + } + }; + } + + @Test + public void testAuthenticate() throws Exception { + Object sshd = newDefaultSshServer(); + Class keyPairProviderClass = newKeyPairProviderClass(); + Object provider = newProvider(); + Class authenticatorClass = newAuthenticatorClass(); + Object authenticator = newAuthenticator(authenticatorClass, "foobar"); + Object factory = newFactory(); + assertNotNull(factory); + + invoke(sshd, "setPort", new Class[] {Integer.TYPE}, new Object[] {0}); + invoke(sshd, "setKeyPairProvider", new Class[] {keyPairProviderClass}, new Object[] {provider}); + invoke(sshd, "setPublickeyAuthenticator", new Class[] {authenticatorClass}, new Object[] {authenticator}); + invoke(sshd, "setUserAuthFactories", new Class[] {List.class}, new Object[] {Collections.singletonList(factory)}); + + try { + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect((hostname, port1, serverHostKeyAlgorithm, serverHostKey) -> true); + TrileadSSHPublicKeyAuthenticator instance = + new TrileadSSHPublicKeyAuthenticator(connection, user); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } finally { + try { + invoke(sshd, "stop", new Class[] {Boolean.TYPE}, new Object[] {true}); + } catch (Throwable t) { + Logger.getLogger(getClass().getName()).log(Level.WARNING, "Problems shutting down ssh server", t); + } + } + } + + @Test + public void testFactory() throws Exception { + Object sshd = newDefaultSshServer(); + Class keyPairProviderClass = newKeyPairProviderClass(); + Object provider = newProvider(); + Class authenticatorClass = newAuthenticatorClass(); + Object authenticator = newAuthenticator(authenticatorClass, "foobar"); + Object factory = newFactory(); + assertNotNull(factory); + + invoke(sshd, "setPort", new Class[] {Integer.TYPE}, new Object[] {0}); + invoke(sshd, "setKeyPairProvider", new Class[] {keyPairProviderClass}, new Object[] {provider}); + invoke(sshd, "setPublickeyAuthenticator", new Class[] {authenticatorClass}, new Object[] {authenticator}); + invoke(sshd, "setUserAuthFactories", new Class[] {List.class}, new Object[] {Collections.singletonList(factory)}); + try { + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect((hostname, port1, serverHostKeyAlgorithm, serverHostKey) -> true); + SSHAuthenticator instance = SSHAuthenticator.newInstance(connection, user); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } finally { + try { + invoke(sshd, "stop", new Class[] {Boolean.TYPE}, new Object[] {true}); + } catch (Throwable t) { + Logger.getLogger(getClass().getName()).log(Level.WARNING, "Problems shutting down ssh server", t); + } + } + } + + @Test + public void testAltUsername() throws Exception { + Object sshd = newDefaultSshServer(); + Class keyPairProviderClass = newKeyPairProviderClass(); + Object provider = newProvider(); + Class authenticatorClass = newAuthenticatorClass(); + Object authenticator = newAuthenticator(authenticatorClass, "bill"); + Object factory = newFactory(); + + invoke(sshd, "setPort", new Class[] {Integer.TYPE}, new Object[] {0}); + invoke(sshd, "setKeyPairProvider", new Class[] {keyPairProviderClass}, new Object[] {provider}); + invoke(sshd, "setPublickeyAuthenticator", new Class[] {authenticatorClass}, new Object[] {authenticator}); + invoke(sshd, "setUserAuthFactories", new Class[] {List.class}, new Object[] {Collections.singletonList(factory)}); + try { + invoke(sshd, "start", null, null); + int port = (Integer)invoke(sshd, "getPort", null, null); + connection = new Connection("localhost", port); + connection.connect((hostname, port12, serverHostKeyAlgorithm, serverHostKey) -> true); + SSHAuthenticator instance = SSHAuthenticator.newInstance(connection, user, null); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(false)); + assertThat(instance.isAuthenticated(), is(false)); + connection = new Connection("localhost", port); + connection.connect((hostname, port1, serverHostKeyAlgorithm, serverHostKey) -> true); + instance = SSHAuthenticator.newInstance(connection, user, "bill"); + assertThat(instance.getAuthenticationMode(), is(SSHAuthenticator.Mode.AFTER_CONNECT)); + assertThat(instance.canAuthenticate(), is(true)); + assertThat(instance.authenticate(), is(true)); + assertThat(instance.isAuthenticated(), is(true)); + } finally { + try { + invoke(sshd, "stop", new Class[] {Boolean.TYPE}, new Object[] {true}); + } catch (Throwable t) { + Logger.getLogger(getClass().getName()).log(Level.WARNING, "Problems shutting down ssh server", t); + } + } + } + + private Object invoke(Object target, String methodName, Class[] parameterTypes, Object[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + return target.getClass().getMethod(methodName, parameterTypes).invoke(target, args); + } + + private Object newDefaultSshServer() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Object sshd = null; + Class sshdClass; + try { + sshdClass = Class.forName("org.apache.sshd.SshServer"); + } catch (ClassNotFoundException e) { + sshdClass = Class.forName("org.apache.sshd.server.SshServer"); + } + + sshd = sshdClass.getDeclaredMethod("setUpDefaultServer").invoke(null); + assertNotNull(sshd); + + return sshd; + } + + private Class newKeyPairProviderClass() throws ClassNotFoundException { + Class keyPairProviderClass; + try { + keyPairProviderClass = Class.forName("org.apache.sshd.common.KeyPairProvider"); + } catch (ClassNotFoundException e) { + keyPairProviderClass = Class.forName("org.apache.sshd.common.keyprovider.KeyPairProvider"); + } + + return keyPairProviderClass; + } + + private Object newProvider() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + Class providerClass = Class.forName("org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider"); + Object provider = providerClass.getConstructor().newInstance(); + assertNotNull(provider); + + return provider; + } + + private Class newAuthenticatorClass() throws ClassNotFoundException { + Class authenticatorClass; + try { + authenticatorClass = Class.forName("org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator"); + } catch(ClassNotFoundException e) { + authenticatorClass = Class.forName("org.apache.sshd.server.PublickeyAuthenticator"); + } + + return authenticatorClass; + } + + private Object newAuthenticator(Class authenticatorClass, final String userName) throws IllegalArgumentException { + Object authenticator = newProxyInstance( + authenticatorClass.getClassLoader(), new Class[]{authenticatorClass}, + (proxy, method, args) -> method.getName().equals("authenticate") ? userName.equals(args[0]) : null); + assertNotNull(authenticator); + return authenticator; + } + + private Object newFactory() throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + Object factory = null; + Class factoryClass; + try { + factoryClass = Class.forName("org.apache.sshd.server.auth.UserAuthPublicKey$Factory"); + } catch (ClassNotFoundException e) { + factoryClass = Class.forName("org.apache.sshd.server.auth.pubkey.UserAuthPublicKeyFactory"); + } + + factory = factoryClass.getConstructor().newInstance(); + + assertNotNull(factory); + return factory; + } +}