Skip to content

Commit 1764231

Browse files
authored
Kbd int (#406)
* Added keyboard interactive authentication on native clients and tests - resolves #389 * Added keyboard interactive tests * Added keyboard interactive option to HostConfig and tests * Updated changelog * Updated docstrings
1 parent 29b7841 commit 1764231

File tree

10 files changed

+154
-7
lines changed

10 files changed

+154
-7
lines changed

Changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ Changes
99

1010
* Added compression support for all clients via `SSHClient(compress=True)`, `ParallelSSHClient(compress=True)` and
1111
`HostConfig(compress=True)` - defaults to off. #252
12+
* Added "keyboard interactive" login support for native clients. This is fully automated username and password
13+
authentication via SSH's keyboard interactive authentication mechanism and does not actually require a human at the
14+
keyboard. Used in cases where the server does not allow any other authentication mechanism.
15+
Note that server configuration may disallow remote command execution via `run_command` when keyboard interactive
16+
authentication is required - use interactive shells to run commands with in such cases. See
17+
`Interactive Shells <https://parallel-ssh.readthedocs.io/en/latest/advanced.html#running-commands-on-shells>`_
18+
documentation. Also supported via `HostConfig` entries. Currently native clients only.
19+
* Added `pssh.exceptions.InvalidAPIUseError` for errors raised on client initialisation when an invalid API use is
20+
detected. For example, keyboard interactive authentication enabled without a password provided.
1221
* Updated minimum `ssh2-python` and `ssh-python` requirements.
1322

1423

pssh/clients/base/parallel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from gevent import joinall, spawn, Timeout as GTimeout
2424
from gevent.hub import Hub
2525

26-
from ..common import _validate_pkey_path, _validate_pkey
26+
from ..common import _validate_pkey_path, _validate_pkey, _validate_api
2727
from ...config import HostConfig
2828
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
2929
from ...exceptions import HostArgumentError, Timeout, ShellError, HostConfigError
@@ -56,6 +56,7 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
5656
gssapi_delegate_credentials=False,
5757
forward_ssh_agent=False,
5858
compress=False,
59+
keyboard_interactive=False,
5960
_auth_thread_pool=True,
6061
):
6162
self.allow_agent = allow_agent
@@ -88,6 +89,8 @@ def __init__(self, hosts, user=None, password=None, port=None, pkey=None,
8889
self.gssapi_client_identity = gssapi_client_identity
8990
self.gssapi_delegate_credentials = gssapi_delegate_credentials
9091
self.compress = compress
92+
self.keyboard_interactive = keyboard_interactive
93+
_validate_api(self.keyboard_interactive, self.password)
9194
self._auth_thread_pool = _auth_thread_pool
9295
self._check_host_config()
9396

pssh/clients/base/single.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@
2323

2424
from gevent import sleep, socket, Timeout as GTimeout
2525
from gevent.hub import Hub
26+
from gevent.pool import Pool
2627
from gevent.select import poll, POLLIN, POLLOUT
2728
from gevent.socket import SHUT_RDWR
28-
from gevent.pool import Pool
2929
from ssh2.exceptions import AgentConnectionError, AgentListIdentitiesError, \
3030
AgentAuthenticationError, AgentGetIdentityError
3131
from ssh2.utils import find_eol
3232

33-
from ..common import _validate_pkey
33+
from ..common import _validate_pkey, _validate_api
3434
from ..reader import ConcurrentRWBuffer
3535
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
3636
from ...exceptions import UnknownHostError, AuthenticationError, \
@@ -227,13 +227,15 @@ def __init__(self, host,
227227
identity_auth=True,
228228
ipv6_only=False,
229229
compress=False,
230+
keyboard_interactive=False,
230231
):
231232
super(PollMixIn, self).__init__()
232233
self._auth_thread_pool = _auth_thread_pool
233234
self.host = host
234235
self.alias = alias
235236
self.user = user if user else getuser()
236237
self.password = password
238+
self.keyboard_interactive = keyboard_interactive
237239
self.port = port if port else 22
238240
self.num_retries = num_retries
239241
self.timeout = timeout if timeout else None
@@ -247,6 +249,8 @@ def __init__(self, host,
247249
self._keepalive_greenlet = None
248250
self.ipv6_only = ipv6_only
249251
self.compress = compress
252+
self.keyboard_interactive = keyboard_interactive
253+
_validate_api(self.keyboard_interactive, self.password)
250254
self._pool = Pool()
251255
self._init()
252256

@@ -439,7 +443,7 @@ def auth(self):
439443
msg = "No remaining authentication methods"
440444
logger.error(msg)
441445
raise AuthenticationError(msg)
442-
logger.debug("Private key auth failed, trying password")
446+
logger.debug("Private key auth failed or not enabled, trying password")
443447
self._password_auth()
444448

445449
def _agent_auth(self):

pssh/clients/common.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
# License along with this library; if not, write to the Free Software
1616
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1717

18+
import logging
1819
import os
1920

20-
from ..exceptions import PKeyFileError
21+
from ..exceptions import PKeyFileError, InvalidAPIUseError
22+
23+
logger = logging.getLogger('pssh')
2124

2225

2326
def _validate_pkey_path(pkey):
@@ -39,3 +42,10 @@ def _validate_pkey(pkey):
3942
if isinstance(pkey, str):
4043
return _validate_pkey_path(pkey)
4144
return pkey
45+
46+
47+
def _validate_api(keyboard_interactive, password):
48+
if keyboard_interactive and not password:
49+
msg = "Keyboard interactive authentication is enabled but no password is provided - cannot continue"
50+
logger.error(msg)
51+
raise InvalidAPIUseError(msg)

pssh/clients/native/parallel.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
3838
keepalive_seconds=60, identity_auth=True,
3939
ipv6_only=False,
4040
compress=False,
41+
keyboard_interactive=False,
4142
):
4243
"""
4344
:param hosts: Hosts to connect to
@@ -118,9 +119,15 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
118119
:type ipv6_only: bool
119120
:param compress: Enable/Disable compression on the client. Defaults to off.
120121
:type compress: bool
122+
:param keyboard_interactive: Enable/Disable keyboard interactive authentication with provided username and
123+
password. An `InvalidAPIUse` error is raised when keyboard_interactive is enabled without a provided password.
124+
Defaults to off.
125+
:type keyboard_interactive: bool
121126
122127
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
123128
provided private key.
129+
:raises: :py:class:`pssh.exceptions.InvalidAPIUseError` when `keyboard_interactive=True` with no password
130+
provided.
124131
"""
125132
BaseParallelSSHClient.__init__(
126133
self, hosts, user=user, password=password, port=port, pkey=pkey,
@@ -130,6 +137,7 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
130137
identity_auth=identity_auth,
131138
ipv6_only=ipv6_only,
132139
compress=compress,
140+
keyboard_interactive=keyboard_interactive,
133141
)
134142
self.proxy_host = proxy_host
135143
self.proxy_port = proxy_port

pssh/clients/native/single.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(self, host,
111111
identity_auth=True,
112112
ipv6_only=False,
113113
compress=False,
114+
keyboard_interactive=False,
114115
):
115116
"""
116117
:param host: Host name or IP to connect to.
@@ -161,9 +162,15 @@ def __init__(self, host,
161162
:type ipv6_only: bool
162163
:param compress: Enable/Disable compression on the client. Defaults to off.
163164
:type compress: bool
165+
:param keyboard_interactive: Enable/Disable keyboard interactive authentication with provided username and
166+
password. An `InvalidAPIUse` error is raised when keyboard_interactive is enabled without a provided password.
167+
Defaults to off.
168+
:type keyboard_interactive: bool
164169
165170
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
166171
provided private key.
172+
:raises: :py:class:`pssh.exceptions.InvalidAPIUseError` when `keyboard_interactive=True` with no password
173+
provided.
167174
"""
168175
self.forward_ssh_agent = forward_ssh_agent
169176
self._forward_requested = False
@@ -186,6 +193,7 @@ def __init__(self, host,
186193
keepalive_seconds=keepalive_seconds,
187194
identity_auth=identity_auth,
188195
compress=compress,
196+
keyboard_interactive=keyboard_interactive,
189197
)
190198
proxy_host = '127.0.0.1'
191199
self._chan_stdout_lock = RLock()
@@ -199,6 +207,7 @@ def __init__(self, host,
199207
identity_auth=identity_auth,
200208
ipv6_only=ipv6_only,
201209
compress=compress,
210+
keyboard_interactive=keyboard_interactive,
202211
)
203212

204213
def _shell(self, channel):
@@ -213,6 +222,7 @@ def _connect_proxy(self, proxy_host, proxy_port, proxy_pkey,
213222
keepalive_seconds=60,
214223
identity_auth=True,
215224
compress=False,
225+
keyboard_interactive=False,
216226
):
217227
assert isinstance(self.port, int)
218228
try:
@@ -224,6 +234,7 @@ def _connect_proxy(self, proxy_host, proxy_port, proxy_pkey,
224234
identity_auth=identity_auth,
225235
keepalive_seconds=keepalive_seconds,
226236
compress=compress,
237+
keyboard_interactive=keyboard_interactive,
227238
_auth_thread_pool=False)
228239
except Exception as ex:
229240
msg = "Proxy authentication failed. " \
@@ -320,7 +331,9 @@ def _pkey_from_memory(self, pkey_data):
320331
)
321332

322333
def _password_auth(self):
323-
self.session.userauth_password(self.user, self.password)
334+
if self.keyboard_interactive:
335+
return self.session.userauth_keyboardinteractive(self.user, self.password)
336+
return self.session.userauth_password(self.user, self.password)
324337

325338
def _open_session(self):
326339
chan = self.eagain(self.session.open_session)

pssh/config.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
"""Host specific configuration."""
2020

21+
from .exceptions import InvalidAPIUseError
22+
2123

2224
class HostConfig(object):
2325
"""Host configuration for ParallelSSHClient.
@@ -29,7 +31,7 @@ class HostConfig(object):
2931
'proxy_host', 'proxy_port', 'proxy_user', 'proxy_password', 'proxy_pkey',
3032
'keepalive_seconds', 'ipv6_only', 'cert_file', 'auth_thread_pool', 'gssapi_auth',
3133
'gssapi_server_identity', 'gssapi_client_identity', 'gssapi_delegate_credentials',
32-
'forward_ssh_agent', 'compress',
34+
'forward_ssh_agent', 'compress', 'keyboard_interactive',
3335
)
3436

3537
def __init__(self, user=None, port=None, password=None, private_key=None,
@@ -47,6 +49,7 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
4749
gssapi_delegate_credentials=False,
4850
forward_ssh_agent=False,
4951
compress=False,
52+
keyboard_interactive=False,
5053
):
5154
"""
5255
:param user: Username to login as.
@@ -102,6 +105,13 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
102105
:type gssapi_delegate_credentials: bool
103106
:param compress: Enable/Disable compression on the client. Defaults to off.
104107
:type compress: bool
108+
:param keyboard_interactive: Enable/Disable keyboard interactive authentication with provided username and
109+
password. An `InvalidAPIUse` error is raised when keyboard_interactive is enabled without a provided password.
110+
Defaults to off.
111+
:type keyboard_interactive: bool
112+
113+
:raises: :py:class:`pssh.exceptions.InvalidAPIUseError` when `keyboard_interactive=True` with no password
114+
provided.
105115
"""
106116
self.user = user
107117
self.port = port
@@ -128,6 +138,9 @@ def __init__(self, user=None, port=None, password=None, private_key=None,
128138
self.gssapi_client_identity = gssapi_client_identity
129139
self.gssapi_delegate_credentials = gssapi_delegate_credentials
130140
self.compress = compress
141+
self.keyboard_interactive = keyboard_interactive
142+
if self.keyboard_interactive and not self.password:
143+
raise InvalidAPIUseError("Keyboard interactive authentication is enabled but no password is provided")
131144
self._sanity_checks()
132145

133146
def _sanity_checks(self):
@@ -187,3 +200,5 @@ def _sanity_checks(self):
187200
raise ValueError("GSSAPI delegate credentials %s is not a bool", self.gssapi_delegate_credentials)
188201
if self.compress is not None and not isinstance(self.compress, bool):
189202
raise ValueError("Compress %s is not a bool", self.compress)
203+
if self.keyboard_interactive is not None and not isinstance(self.keyboard_interactive, bool):
204+
raise ValueError("keyboard_interactive %s is not a bool", self.keyboard_interactive)

pssh/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,7 @@ class ShellError(Exception):
100100

101101
class HostConfigError(Exception):
102102
"""Raised on invalid host configuration"""
103+
104+
105+
class InvalidAPIUseError(Exception):
106+
"""Raised on invalid use of library API"""

tests/test_api_use.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# This file is part of parallel-ssh.
2+
#
3+
# Copyright (C) 2014-2025 Panos Kittenis and contributors.
4+
#
5+
# This library is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public
7+
# License as published by the Free Software Foundation, version 2.1.
8+
#
9+
# This library is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
# Lesser General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Lesser General Public
15+
# License along with this library; if not, write to the Free Software
16+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17+
18+
19+
import unittest
20+
from unittest.mock import patch, MagicMock
21+
22+
from pssh.clients import ParallelSSHClient, SSHClient
23+
from pssh.exceptions import InvalidAPIUseError, UnknownHostError
24+
25+
26+
class APIUseTest(unittest.TestCase):
27+
28+
@patch('gevent.socket')
29+
@patch('pssh.clients.native.single.Session')
30+
def test_kbd_interactive_enabled_single_clients(self, mock_sess, mock_sock):
31+
self.assertRaises(UnknownHostError, SSHClient,
32+
'fakehost', password='fake_pass', keyboard_interactive=True, num_retries=0,
33+
timeout=.1,
34+
retry_delay=.1,
35+
_auth_thread_pool=False,
36+
allow_agent=False,
37+
)
38+
self.assertRaises(InvalidAPIUseError, SSHClient, 'fakehost', keyboard_interactive=True)
39+
40+
@patch('gevent.socket')
41+
@patch('pssh.clients.native.single.Session')
42+
def test_kbd_interactive_enabled_parallel_clients(self, mock_sess, mock_sock):
43+
client = ParallelSSHClient(
44+
['fakehost'], password='fake_pass', keyboard_interactive=True, num_retries=0,
45+
timeout=.1,
46+
retry_delay=.1,
47+
allow_agent=False,
48+
)
49+
self.assertRaises(UnknownHostError, client.run_command, 'echo me')
50+
self.assertRaises(InvalidAPIUseError, ParallelSSHClient, ['fakehost'], keyboard_interactive=True)
51+
52+
@patch('gevent.socket.socket')
53+
@patch('pssh.clients.native.single.Session')
54+
def test_kbd_interactive_enabled_and_used(self, mock_sess, mock_sock):
55+
sess = MagicMock()
56+
mock_sess.return_value = sess
57+
kbd_auth = MagicMock()
58+
password_auth = MagicMock()
59+
sess.userauth_keyboardinteractive = kbd_auth
60+
sess.userauth_password = password_auth
61+
my_user = "my_user"
62+
my_pass = "fake_pass"
63+
64+
client = SSHClient(
65+
'127.0.0.1', port=1234, user=my_user, password=my_pass, keyboard_interactive=True, num_retries=0,
66+
timeout=.1,
67+
retry_delay=.1,
68+
_auth_thread_pool=False,
69+
allow_agent=False,
70+
identity_auth=False,
71+
)
72+
sess.userauth_keyboardinteractive.assert_called_once_with(my_user, my_pass)
73+
sess.userauth_password.assert_not_called()
74+
self.assertEqual(client.user, my_user)
75+
self.assertEqual(client.password, my_pass)

tests/test_host_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import unittest
1919

2020
from pssh.config import HostConfig
21+
from pssh.exceptions import InvalidAPIUseError
2122

2223

2324
class TestHostConfig(unittest.TestCase):
@@ -44,6 +45,7 @@ def test_host_config_entries(self):
4445
gssapi_client_identity = 'some_id'
4546
gssapi_delegate_credentials = True
4647
compress = True
48+
keyboard_interactive = True
4749
cfg = HostConfig(
4850
user=user, port=port, password=password, alias=alias, private_key=private_key,
4951
allow_agent=allow_agent, num_retries=num_retries, retry_delay=retry_delay,
@@ -58,6 +60,7 @@ def test_host_config_entries(self):
5860
gssapi_client_identity=gssapi_client_identity,
5961
gssapi_delegate_credentials=gssapi_delegate_credentials,
6062
compress=compress,
63+
keyboard_interactive=keyboard_interactive,
6164
)
6265
self.assertEqual(cfg.user, user)
6366
self.assertEqual(cfg.port, port)
@@ -79,6 +82,7 @@ def test_host_config_entries(self):
7982
self.assertEqual(cfg.gssapi_client_identity, gssapi_client_identity)
8083
self.assertEqual(cfg.gssapi_delegate_credentials, gssapi_delegate_credentials)
8184
self.assertEqual(cfg.compress, compress)
85+
self.assertEqual(cfg.keyboard_interactive, keyboard_interactive)
8286

8387
def test_host_config_bad_entries(self):
8488
self.assertRaises(ValueError, HostConfig, user=22)
@@ -106,3 +110,5 @@ def test_host_config_bad_entries(self):
106110
self.assertRaises(ValueError, HostConfig, gssapi_client_identity=1)
107111
self.assertRaises(ValueError, HostConfig, gssapi_delegate_credentials='')
108112
self.assertRaises(ValueError, HostConfig, compress='')
113+
self.assertRaises(ValueError, HostConfig, password='fake', keyboard_interactive='')
114+
self.assertRaises(InvalidAPIUseError, HostConfig, keyboard_interactive=True)

0 commit comments

Comments
 (0)