org.eclipse.jgit.transport.JschConfigSessionFactory.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.jgit.transport.JschConfigSessionFactory.java

Source

/*
 * Copyright (C) 2018, Sasa Zivkov <sasa.zivkov@sap.com>
 * Copyright (C) 2016, Mark Ingram <markdingram@gmail.com>
 * Copyright (C) 2009, Constantine Plotnikov <constantine.plotnikov@gmail.com>
 * Copyright (C) 2008-2009, Google Inc.
 * Copyright (C) 2009, Google, Inc.
 * Copyright (C) 2009, JetBrains s.r.o.
 * Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
 * and other copyright owners as documented in the project's IP log.
 *
 * This program and the accompanying materials are made available
 * under the terms of the Eclipse Distribution License v1.0 which
 * accompanies this distribution, is reproduced below, and is
 * available at http://www.eclipse.org/org/documents/edl-v10.php
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or
 * without modification, are permitted provided that the following
 * conditions are met:
 *
 * - Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above
 *   copyright notice, this list of conditions and the following
 *   disclaimer in the documentation and/or other materials provided
 *   with the distribution.
 *
 * - Neither the name of the Eclipse Foundation, Inc. nor the
 *   names of its contributors may be used to endorse or promote
 *   products derived from this software without specific prior
 *   written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package org.eclipse.jgit.transport;

import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.ConnectException;
import java.net.UnknownHostException;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.util.FS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.jcraft.jsch.ConfigRepository;
import com.jcraft.jsch.ConfigRepository.Config;
import com.jcraft.jsch.HostKey;
import com.jcraft.jsch.HostKeyRepository;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

/**
 * The base session factory that loads known hosts and private keys from
 * <code>$HOME/.ssh</code>.
 * <p>
 * This is the default implementation used by JGit and provides most of the
 * compatibility necessary to match OpenSSH, a popular implementation of SSH
 * used by C Git.
 * <p>
 * The factory does not provide UI behavior. Override the method
 * {@link #configure(org.eclipse.jgit.transport.OpenSshConfig.Host, Session)} to
 * supply appropriate {@link com.jcraft.jsch.UserInfo} to the session.
 */
public abstract class JschConfigSessionFactory extends SshSessionFactory {

    private static final Logger LOG = LoggerFactory.getLogger(JschConfigSessionFactory.class);

    /**
     * We use different Jsch instances for hosts that have an IdentityFile
     * configured in ~/.ssh/config. Jsch by default would cache decrypted keys
     * only per session, which results in repeated password prompts. Using
     * different Jsch instances, we can cache the keys on these instances so
     * that they will be re-used for successive sessions, and thus the user is
     * prompted for a key password only once while Eclipse runs.
     */
    private final Map<String, JSch> byIdentityFile = new HashMap<>();

    private JSch defaultJSch;

    private OpenSshConfig config;

    /** {@inheritDoc} */
    @Override
    public synchronized RemoteSession getSession(URIish uri, CredentialsProvider credentialsProvider, FS fs,
            int tms) throws TransportException {

        String user = uri.getUser();
        final String pass = uri.getPass();
        String host = uri.getHost();
        int port = uri.getPort();

        try {
            if (config == null)
                config = OpenSshConfig.get(fs);

            final OpenSshConfig.Host hc = config.lookup(host);
            if (port <= 0)
                port = hc.getPort();
            if (user == null)
                user = hc.getUser();

            Session session = createSession(credentialsProvider, fs, user, pass, host, port, hc);

            int retries = 0;
            while (!session.isConnected()) {
                try {
                    retries++;
                    session.connect(tms);
                } catch (JSchException e) {
                    session.disconnect();
                    session = null;
                    // Make sure our known_hosts is not outdated
                    knownHosts(getJSch(hc, fs), fs);

                    if (isAuthenticationCanceled(e)) {
                        throw e;
                    } else if (isAuthenticationFailed(e) && credentialsProvider != null) {
                        // if authentication failed maybe credentials changed at
                        // the remote end therefore reset credentials and retry
                        if (retries < 3) {
                            credentialsProvider.reset(uri);
                            session = createSession(credentialsProvider, fs, user, pass, host, port, hc);
                        } else
                            throw e;
                    } else if (retries >= hc.getConnectionAttempts()) {
                        throw e;
                    } else {
                        try {
                            Thread.sleep(1000);
                            session = createSession(credentialsProvider, fs, user, pass, host, port, hc);
                        } catch (InterruptedException e1) {
                            throw new TransportException(JGitText.get().transportSSHRetryInterrupt, e1);
                        }
                    }
                }
            }

            return new JschSession(session, uri);

        } catch (JSchException je) {
            final Throwable c = je.getCause();
            if (c instanceof UnknownHostException) {
                throw new TransportException(uri, JGitText.get().unknownHost, je);
            }
            if (c instanceof ConnectException) {
                throw new TransportException(uri, c.getMessage(), je);
            }
            throw new TransportException(uri, je.getMessage(), je);
        }

    }

    private static boolean isAuthenticationFailed(JSchException e) {
        return e.getCause() == null && e.getMessage().equals("Auth fail"); //$NON-NLS-1$
    }

    private static boolean isAuthenticationCanceled(JSchException e) {
        return e.getCause() == null && e.getMessage().equals("Auth cancel"); //$NON-NLS-1$
    }

    // Package visibility for tests
    Session createSession(CredentialsProvider credentialsProvider, FS fs, String user, final String pass,
            String host, int port, final OpenSshConfig.Host hc) throws JSchException {
        final Session session = createSession(hc, user, host, port, fs);
        // Jsch will have overridden the explicit user by the one from the SSH
        // config file...
        setUserName(session, user);
        // Jsch will also have overridden the port.
        if (port > 0 && port != session.getPort()) {
            session.setPort(port);
        }
        // We retry already in getSession() method. JSch must not retry
        // on its own.
        session.setConfig("MaxAuthTries", "1"); //$NON-NLS-1$ //$NON-NLS-2$
        if (pass != null)
            session.setPassword(pass);
        final String strictHostKeyCheckingPolicy = hc.getStrictHostKeyChecking();
        if (strictHostKeyCheckingPolicy != null)
            session.setConfig("StrictHostKeyChecking", //$NON-NLS-1$
                    strictHostKeyCheckingPolicy);
        final String pauth = hc.getPreferredAuthentications();
        if (pauth != null)
            session.setConfig("PreferredAuthentications", pauth); //$NON-NLS-1$
        if (credentialsProvider != null && (!hc.isBatchMode() || !credentialsProvider.isInteractive())) {
            session.setUserInfo(new CredentialsProviderUserInfo(session, credentialsProvider));
        }
        safeConfig(session, hc.getConfig());
        if (hc.getConfig().getValue("HostKeyAlgorithms") == null) { //$NON-NLS-1$
            setPreferredKeyTypesOrder(session);
        }
        configure(hc, session);
        return session;
    }

    private void safeConfig(Session session, Config cfg) {
        // Ensure that Jsch checks all configured algorithms, not just its
        // built-in ones. Otherwise it may propose an algorithm for which it
        // doesn't have an implementation, and then run into an NPE if that
        // algorithm ends up being chosen.
        copyConfigValueToSession(session, cfg, "Ciphers", "CheckCiphers"); //$NON-NLS-1$ //$NON-NLS-2$
        copyConfigValueToSession(session, cfg, "KexAlgorithms", "CheckKexes"); //$NON-NLS-1$ //$NON-NLS-2$
        copyConfigValueToSession(session, cfg, "HostKeyAlgorithms", //$NON-NLS-1$
                "CheckSignatures"); //$NON-NLS-1$
    }

    private static void setPreferredKeyTypesOrder(Session session) {
        HostKeyRepository hkr = session.getHostKeyRepository();
        List<String> known = Stream.of(hkr.getHostKey(hostName(session), null)).map(HostKey::getType)
                .collect(toList());

        if (!known.isEmpty()) {
            String serverHostKey = "server_host_key"; //$NON-NLS-1$
            String current = session.getConfig(serverHostKey);
            if (current == null) {
                session.setConfig(serverHostKey, String.join(",", known)); //$NON-NLS-1$
                return;
            }

            String knownFirst = Stream.concat(known.stream(), Stream.of(current.split(",")) //$NON-NLS-1$
                    .filter(s -> !known.contains(s))).collect(joining(",")); //$NON-NLS-1$
            session.setConfig(serverHostKey, knownFirst);
        }
    }

    private static String hostName(Session s) {
        if (s.getPort() == SshConstants.SSH_DEFAULT_PORT) {
            return s.getHost();
        }
        return String.format("[%s]:%d", s.getHost(), //$NON-NLS-1$
                Integer.valueOf(s.getPort()));
    }

    private void copyConfigValueToSession(Session session, Config cfg, String from, String to) {
        String value = cfg.getValue(from);
        if (value != null) {
            session.setConfig(to, value);
        }
    }

    private void setUserName(Session session, String userName) {
        // Jsch 0.1.54 picks up the user name from the ssh config, even if an
        // explicit user name was given! We must correct that if ~/.ssh/config
        // has a different user name.
        if (userName == null || userName.isEmpty() || userName.equals(session.getUserName())) {
            return;
        }
        try {
            Class<?>[] parameterTypes = { String.class };
            Method method = Session.class.getDeclaredMethod("setUserName", //$NON-NLS-1$
                    parameterTypes);
            method.setAccessible(true);
            method.invoke(session, userName);
        } catch (NullPointerException | IllegalAccessException | IllegalArgumentException
                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
            LOG.error(MessageFormat.format(JGitText.get().sshUserNameError, userName, session.getUserName()), e);
        }
    }

    /**
     * Create a new remote session for the requested address.
     *
     * @param hc
     *            host configuration
     * @param user
     *            login to authenticate as.
     * @param host
     *            server name to connect to.
     * @param port
     *            port number of the SSH daemon (typically 22).
     * @param fs
     *            the file system abstraction which will be necessary to
     *            perform certain file system operations.
     * @return new session instance, but otherwise unconfigured.
     * @throws com.jcraft.jsch.JSchException
     *             the session could not be created.
     */
    protected Session createSession(final OpenSshConfig.Host hc, final String user, final String host,
            final int port, FS fs) throws JSchException {
        return getJSch(hc, fs).getSession(user, host, port);
    }

    /**
     * Provide additional configuration for the JSch instance. This method could
     * be overridden to supply a preferred
     * {@link com.jcraft.jsch.IdentityRepository}.
     *
     * @param jsch
     *            jsch instance
     * @since 4.5
     */
    protected void configureJSch(JSch jsch) {
        // No additional configuration required.
    }

    /**
     * Provide additional configuration for the session based on the host
     * information. This method could be used to supply
     * {@link com.jcraft.jsch.UserInfo}.
     *
     * @param hc
     *            host configuration
     * @param session
     *            session to configure
     */
    protected abstract void configure(OpenSshConfig.Host hc, Session session);

    /**
     * Obtain the JSch used to create new sessions.
     *
     * @param hc
     *            host configuration
     * @param fs
     *            the file system abstraction which will be necessary to
     *            perform certain file system operations.
     * @return the JSch instance to use.
     * @throws com.jcraft.jsch.JSchException
     *             the user configuration could not be created.
     */
    protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException {
        if (defaultJSch == null) {
            defaultJSch = createDefaultJSch(fs);
            if (defaultJSch.getConfigRepository() == null) {
                defaultJSch.setConfigRepository(new JschBugFixingConfigRepository(config));
            }
            for (Object name : defaultJSch.getIdentityNames())
                byIdentityFile.put((String) name, defaultJSch);
        }

        final File identityFile = hc.getIdentityFile();
        if (identityFile == null)
            return defaultJSch;

        final String identityKey = identityFile.getAbsolutePath();
        JSch jsch = byIdentityFile.get(identityKey);
        if (jsch == null) {
            jsch = new JSch();
            configureJSch(jsch);
            if (jsch.getConfigRepository() == null) {
                jsch.setConfigRepository(defaultJSch.getConfigRepository());
            }
            jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository());
            jsch.addIdentity(identityKey);
            byIdentityFile.put(identityKey, jsch);
        }
        return jsch;
    }

    /**
     * Create default instance of jsch
     *
     * @param fs
     *            the file system abstraction which will be necessary to perform
     *            certain file system operations.
     * @return the new default JSch implementation.
     * @throws com.jcraft.jsch.JSchException
     *             known host keys cannot be loaded.
     */
    protected JSch createDefaultJSch(FS fs) throws JSchException {
        final JSch jsch = new JSch();
        JSch.setConfig("ssh-rsa", JSch.getConfig("signature.rsa")); //$NON-NLS-1$ //$NON-NLS-2$
        JSch.setConfig("ssh-dss", JSch.getConfig("signature.dss")); //$NON-NLS-1$ //$NON-NLS-2$
        configureJSch(jsch);
        knownHosts(jsch, fs);
        identities(jsch, fs);
        return jsch;
    }

    private static void knownHosts(JSch sch, FS fs) throws JSchException {
        final File home = fs.userHome();
        if (home == null)
            return;
        final File known_hosts = new File(new File(home, ".ssh"), "known_hosts"); //$NON-NLS-1$ //$NON-NLS-2$
        try (FileInputStream in = new FileInputStream(known_hosts)) {
            sch.setKnownHosts(in);
        } catch (FileNotFoundException none) {
            // Oh well. They don't have a known hosts in home.
        } catch (IOException err) {
            // Oh well. They don't have a known hosts in home.
        }
    }

    private static void identities(JSch sch, FS fs) {
        final File home = fs.userHome();
        if (home == null)
            return;
        final File sshdir = new File(home, ".ssh"); //$NON-NLS-1$
        if (sshdir.isDirectory()) {
            loadIdentity(sch, new File(sshdir, "identity")); //$NON-NLS-1$
            loadIdentity(sch, new File(sshdir, "id_rsa")); //$NON-NLS-1$
            loadIdentity(sch, new File(sshdir, "id_dsa")); //$NON-NLS-1$
        }
    }

    private static void loadIdentity(JSch sch, File priv) {
        if (priv.isFile()) {
            try {
                sch.addIdentity(priv.getAbsolutePath());
            } catch (JSchException e) {
                // Instead, pretend the key doesn't exist.
            }
        }
    }

    private static class JschBugFixingConfigRepository implements ConfigRepository {

        private final ConfigRepository base;

        public JschBugFixingConfigRepository(ConfigRepository base) {
            this.base = base;
        }

        @Override
        public Config getConfig(String host) {
            return new JschBugFixingConfig(base.getConfig(host));
        }

        /**
         * A {@link com.jcraft.jsch.ConfigRepository.Config} that transforms
         * some values from the config file into the format Jsch 0.1.54 expects.
         * This is a work-around for bugs in Jsch.
         * <p>
         * Additionally, this config hides the IdentityFile config entries from
         * Jsch; we manage those ourselves. Otherwise Jsch would cache passwords
         * (or rather, decrypted keys) only for a single session, resulting in
         * multiple password prompts for user operations that use several Jsch
         * sessions.
         */
        private static class JschBugFixingConfig implements Config {

            private static final String[] NO_IDENTITIES = {};

            private final Config real;

            public JschBugFixingConfig(Config delegate) {
                real = delegate;
            }

            @Override
            public String getHostname() {
                return real.getHostname();
            }

            @Override
            public String getUser() {
                return real.getUser();
            }

            @Override
            public int getPort() {
                return real.getPort();
            }

            @Override
            public String getValue(String key) {
                String k = key.toUpperCase(Locale.ROOT);
                if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
                    return null;
                }
                String result = real.getValue(key);
                if (result != null) {
                    if ("SERVERALIVEINTERVAL".equals(k) //$NON-NLS-1$
                            || "CONNECTTIMEOUT".equals(k)) { //$NON-NLS-1$
                        // These values are in seconds. Jsch 0.1.54 passes them
                        // on as is to java.net.Socket.setSoTimeout(), which
                        // expects milliseconds. So convert here to
                        // milliseconds.
                        try {
                            int timeout = Integer.parseInt(result);
                            result = Long.toString(TimeUnit.SECONDS.toMillis(timeout));
                        } catch (NumberFormatException e) {
                            // Ignore
                        }
                    }
                }
                return result;
            }

            @Override
            public String[] getValues(String key) {
                String k = key.toUpperCase(Locale.ROOT);
                if ("IDENTITYFILE".equals(k)) { //$NON-NLS-1$
                    return NO_IDENTITIES;
                }
                return real.getValues(key);
            }
        }
    }

    /**
     * Set the {@link OpenSshConfig} to use. Intended for use in tests.
     *
     * @param config
     *            to use
     */
    synchronized void setConfig(OpenSshConfig config) {
        this.config = config;
    }
}