/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.apache.sshd.client.auth.keyboard; import java.util.Arrays; import java.util.Iterator; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.apache.sshd.client.ClientAuthenticationManager; import org.apache.sshd.client.auth.AbstractUserAuth; import org.apache.sshd.client.auth.password.PasswordIdentityProvider; import org.apache.sshd.client.session.ClientSession; import org.apache.sshd.common.RuntimeSshException; import org.apache.sshd.common.SshConstants; import org.apache.sshd.common.util.GenericUtils; import org.apache.sshd.common.util.ValidateUtils; import org.apache.sshd.common.util.buffer.Buffer; /** * Manages a "keyboard-interactive" exchange according to * <A HREF="https://www.ietf.org/rfc/rfc4256.txt">RFC4256</A> * * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a> */ public class UserAuthKeyboardInteractive extends AbstractUserAuth { public static final String NAME = UserAuthKeyboardInteractiveFactory.NAME; public static final String INTERACTIVE_LANGUAGE_TAG = "kb-client-interactive-language-tag"; /* * As per RFC-4256: * * The language tag is deprecated and SHOULD be the empty string. It * may be removed in a future revision of this specification. Instead, * the server SHOULD select the language to be used based on the tags * communicated during key exchange */ public static final String DEFAULT_INTERACTIVE_LANGUAGE_TAG = ""; public static final String INTERACTIVE_SUBMETHODS = "kb-client-interactive-sub-methods"; /* * As per RFC-4256: * * The submethods field is included so the user can give a hint of which * actual methods he wants to use. It is a comma-separated list of * authentication submethods (software or hardware) that the user * prefers. If the client has knowledge of the submethods preferred by * the user, presumably through a configuration setting, it MAY use the * submethods field to pass this information to the server. Otherwise, * it MUST send the empty string. * * The actual names of the submethods is something the user and the * server need to agree upon. * * Server interpretation of the submethods field is implementation- * dependent. */ public static final String DEFAULT_INTERACTIVE_SUBMETHODS = ""; private final AtomicBoolean requestPending = new AtomicBoolean(false); private final AtomicInteger trialsCount = new AtomicInteger(0); private Iterator<String> passwords; private int maxTrials; public UserAuthKeyboardInteractive() { super(NAME); } @Override public void init(ClientSession session, String service) throws Exception { super.init(session, service); passwords = PasswordIdentityProvider.iteratorOf(session); maxTrials = session.getIntProperty(ClientAuthenticationManager.PASSWORD_PROMPTS, ClientAuthenticationManager.DEFAULT_PASSWORD_PROMPTS); ValidateUtils.checkTrue(maxTrials > 0, "Non-positive max. trials: %d", maxTrials); } @Override protected boolean sendAuthDataRequest(ClientSession session, String service) throws Exception { String name = getName(); if (requestPending.get()) { if (log.isDebugEnabled()) { log.debug("sendAuthDataRequest({})[{}] no reply for previous request for {}", session, service, name); } return false; } if (!verifyTrialsCount(session, service, SshConstants.SSH_MSG_USERAUTH_REQUEST, trialsCount.get(), maxTrials)) { return false; } String username = session.getUsername(); String lang = getExchangeLanguageTag(session); String subMethods = getExchangeSubMethods(session); if (log.isDebugEnabled()) { log.debug("sendAuthDataRequest({})[{}] send SSH_MSG_USERAUTH_REQUEST for {}: lang={}, methods={}", session, service, name, lang, subMethods); } Buffer buffer = session.createBuffer(SshConstants.SSH_MSG_USERAUTH_REQUEST, username.length() + service.length() + name.length() + GenericUtils.length(lang) + GenericUtils.length(subMethods) + Long.SIZE /* a bit extra for the lengths */); buffer.putString(username); buffer.putString(service); buffer.putString(name); buffer.putString(lang); buffer.putString(subMethods); requestPending.set(true); session.writePacket(buffer); return true; } @Override protected boolean processAuthDataRequest(ClientSession session, String service, Buffer buffer) throws Exception { int cmd = buffer.getUByte(); if (cmd != SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST) { throw new IllegalStateException("processAuthDataRequest(" + session + ")[" + service + "]" + " received unknown packet: cmd=" + SshConstants.getCommandMessageName(cmd)); } requestPending.set(false); if (!verifyTrialsCount(session, service, cmd, trialsCount.incrementAndGet(), maxTrials)) { return false; } String name = buffer.getString(); String instruction = buffer.getString(); String lang = buffer.getString(); int num = buffer.getInt(); if (log.isDebugEnabled()) { log.debug("processAuthDataRequest({})[{}] SSH_MSG_USERAUTH_INFO_REQUEST name={}, instruction={}, language={}, num-prompts={}", session, service, name, instruction, lang, num); } String[] prompt = new String[num]; boolean[] echo = new boolean[num]; for (int i = 0; i < num; i++) { // according to RFC4256: "The prompt field(s) MUST NOT be empty strings." prompt[i] = buffer.getString(); echo[i] = buffer.getBoolean(); } if (log.isTraceEnabled()) { log.trace("processAuthDataRequest({})[{}] Prompt: {}", session, service, Arrays.toString(prompt)); log.trace("processAuthDataRequest({})[{}] Echo: {}", session, service, Arrays.toString(echo)); } String[] rep = getUserResponses(name, instruction, lang, prompt, echo); if (rep == null) { if (log.isDebugEnabled()) { log.debug("processAuthDataRequest({})[{}] no responses for {}", session, service, name); } return false; } /* * According to RFC4256: * * If the num-responses field does not match the num-prompts * field in the request message, the server MUST send a failure * message. * * However it is the server's (!) responsibility to fail, so we only warn... */ if (num != rep.length) { log.warn("processAuthDataRequest({})[{}] Mismatched prompts ({}) vs. responses count ({})", session, service, num, rep.length); } buffer = session.createBuffer(SshConstants.SSH_MSG_USERAUTH_INFO_RESPONSE, rep.length * Long.SIZE + Byte.SIZE); buffer.putInt(rep.length); for (int index = 0; index < rep.length; index++) { String r = rep[index]; if (log.isTraceEnabled()) { log.trace("processAuthDataRequest({})[{}] response #{}: {}", session, service, index + 1, r); } buffer.putString(r); } session.writePacket(buffer); return true; } protected String getExchangeLanguageTag(ClientSession session) { return session.getStringProperty(INTERACTIVE_LANGUAGE_TAG, DEFAULT_INTERACTIVE_LANGUAGE_TAG); } protected String getExchangeSubMethods(ClientSession session) { return session.getStringProperty(INTERACTIVE_SUBMETHODS, DEFAULT_INTERACTIVE_SUBMETHODS); } protected String getCurrentPasswordCandidate() { if ((passwords != null) && passwords.hasNext()) { return passwords.next(); } else { return null; } } protected boolean verifyTrialsCount(ClientSession session, String service, int cmd, int nbTrials, int maxAllowed) { if (log.isDebugEnabled()) { log.debug("verifyTrialsCount({})[{}] cmd={} - {} out of {}", session, service, getAuthCommandName(cmd), nbTrials, maxAllowed); } return nbTrials <= maxAllowed; } /** * @param name The interaction name - may be empty * @param instruction The instruction - may be empty * @param lang The language tag - may be empty * @param prompt The prompts - may be empty * @param echo Whether to echo the response for the prompt or not - same * length as the prompts * @return The response for each prompt - if {@code null} then the assumption * is that some internal error occurred and no response is sent. <B>Note:</B> * according to <A HREF="https://www.ietf.org/rfc/rfc4256.txt">RFC4256</A> * the number of responses should be <U>exactly</U> the same as the number * of prompts. However, since it is the <U>server's</U> responsibility to * enforce this we do not validate the response (other than logging it as * a warning...) */ protected String[] getUserResponses(String name, String instruction, String lang, String[] prompt, boolean[] echo) { ClientSession session = getClientSession(); int num = GenericUtils.length(prompt); if (num == 0) { if (log.isDebugEnabled()) { log.debug("getUserResponses({}) no prompts for interaction={}", session, name); } return GenericUtils.EMPTY_STRING_ARRAY; } String candidate = getCurrentPasswordCandidate(); if (useCurrentPassword(candidate, name, instruction, lang, prompt, echo)) { if (log.isDebugEnabled()) { log.debug("getUserResponses({}) use password candidate for interaction={}", session, name); } return new String[]{candidate}; } UserInteraction ui = session.getUserInteraction(); try { if ((ui != null) && ui.isInteractionAllowed(session)) { return ui.interactive(session, name, instruction, lang, prompt, echo); } } catch (Error e) { log.warn("getUserResponses({}) failed ({}) to consult interaction: {}", session, e.getClass().getSimpleName(), e.getMessage()); if (log.isDebugEnabled()) { log.debug("getUserResponses(" + session + ") interaction consultation failure details", e); } throw new RuntimeSshException(e); } if (log.isDebugEnabled()) { log.debug("getUserResponses({}) no user interaction for name={}", session, name); } return null; } protected boolean useCurrentPassword(String password, String name, String instruction, String lang, String[] prompt, boolean[] echo) { int num = GenericUtils.length(prompt); if ((num != 1) || (password == null) || echo[0]) { return false; } // check that prompt is something like "XXX password YYY:" String value = GenericUtils.trimToEmpty(prompt[0]).toLowerCase(); int passPos = value.lastIndexOf("password"); if (passPos < 0) { // no password keyword in prompt return false; } int sepPos = value.lastIndexOf(':'); return sepPos > passPos; } public static String getAuthCommandName(int cmd) { switch(cmd) { case SshConstants.SSH_MSG_USERAUTH_REQUEST: return "SSH_MSG_USERAUTH_REQUEST"; case SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST: return "SSH_MSG_USERAUTH_INFO_REQUEST"; default: return SshConstants.getCommandMessageName(cmd); } } }