/* * 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 * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.internal.transport.sshd; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.apache.sshd.client.auth.keyboard.UserInteraction; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.session.Session; import org.apache.sshd.common.session.SessionListener; import org.eclipse.jgit.transport.CredentialItem; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.SshConstants; import org.eclipse.jgit.transport.URIish; /** * A {@link UserInteraction} callback implementation based on a * {@link CredentialsProvider}. */ public class JGitUserInteraction implements UserInteraction { private final CredentialsProvider provider; /** * We need to reset the JGit credentials provider if we have repeated * attempts. */ private final Map ongoing = new ConcurrentHashMap<>(); /** * Creates a new {@link JGitUserInteraction} for interactive password input * based on the given {@link CredentialsProvider}. * * @param provider * to use */ public JGitUserInteraction(CredentialsProvider provider) { this.provider = provider; } @Override public boolean isInteractionAllowed(ClientSession session) { return provider != null && provider.isInteractive(); } @Override public String[] interactive(ClientSession session, String name, String instruction, String lang, String[] prompt, boolean[] echo) { // This is keyboard-interactive or password authentication List items = new ArrayList<>(); int numberOfHiddenInputs = 0; for (int i = 0; i < prompt.length; i++) { boolean hidden = i < echo.length && !echo[i]; if (hidden) { numberOfHiddenInputs++; } } // RFC 4256 (SSH_MSG_USERAUTH_INFO_REQUEST) says: "The language tag is // deprecated and SHOULD be the empty string." and "[If there are no // prompts] the client SHOULD still display the name and instruction // fields" and "[The] client SHOULD print the name and instruction (if // non-empty)" if (name != null && !name.isEmpty()) { items.add(new CredentialItem.InformationalMessage(name)); } if (instruction != null && !instruction.isEmpty()) { items.add(new CredentialItem.InformationalMessage(instruction)); } for (int i = 0; i < prompt.length; i++) { boolean hidden = i < echo.length && !echo[i]; if (hidden && numberOfHiddenInputs == 1) { // We need to somehow trigger storing the password in the // Eclipse secure storage in EGit. Currently, this is done only // for password fields. items.add(new CredentialItem.Password()); // TODO Possibly change EGit to store all hidden strings // (keyed by the URI and the prompt?) so that we don't have to // use this kludge here. } else { items.add(new CredentialItem.StringType(prompt[i], hidden)); } } if (items.isEmpty()) { // Huh? No info, no prompts? return prompt; // Is known to have length zero here } URIish uri = toURI(session.getUsername(), (InetSocketAddress) session.getConnectAddress()); // Reset the provider for this URI if it's not the first attempt and we // have hidden inputs. Otherwise add a session listener that will remove // itself once authenticated. if (numberOfHiddenInputs > 0) { SessionListener listener = ongoing.get(session); if (listener != null) { provider.reset(uri); } else { listener = new SessionAuthMarker(ongoing); ongoing.put(session, listener); session.addSessionListener(listener); } } if (provider.get(uri, items)) { return items.stream().map(i -> { if (i instanceof CredentialItem.Password) { return new String(((CredentialItem.Password) i).getValue()); } else if (i instanceof CredentialItem.StringType) { return ((CredentialItem.StringType) i).getValue(); } return null; }).filter(s -> s != null).toArray(String[]::new); } 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 public String getUpdatedPassword(ClientSession session, String prompt, String lang) { // TODO Implement password update in password authentication? return null; } /** * Creates a {@link URIish} from the given remote address and user name. * * @param userName * for the uri * @param remote * address of the remote host * @return the uri, with {@link SshConstants#SSH_SCHEME} as scheme */ public static URIish toURI(String userName, InetSocketAddress remote) { String host = remote.getHostString(); int port = remote.getPort(); return new URIish() // .setScheme(SshConstants.SSH_SCHEME) // .setHost(host) // .setPort(port) // .setUser(userName); } /** * A {@link SessionListener} that removes itself from the session when * authentication is done or the session is closed. */ private static class SessionAuthMarker implements SessionListener { private final Map registered; public SessionAuthMarker(Map registered) { this.registered = registered; } @Override public void sessionEvent(Session session, SessionListener.Event event) { if (event == SessionListener.Event.Authenticated) { session.removeSessionListener(this); registered.remove(session, this); } } @Override public void sessionClosed(Session session) { session.removeSessionListener(this); registered.remove(session, this); } } }