summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJeff Forcier <jeff@bitprophet.org>2023-05-09 13:51:07 -0400
committerJeff Forcier <jeff@bitprophet.org>2023-05-18 13:57:19 -0400
commit629e7809a5c651728ea983c903a4973efcc91526 (patch)
tree26045202efbf8a77917320bdc49900a09ae1b7f7
parent992c9967330bf977e45e21079e6f2e974ac4f045 (diff)
Partial implementation of new AuthStrategy mechanism re #387
-rw-r--r--paramiko/__init__.py1
-rw-r--r--paramiko/auth_strategy.py249
-rw-r--r--paramiko/client.py25
-rw-r--r--paramiko/ssh_exception.py5
4 files changed, 279 insertions, 1 deletions
diff --git a/paramiko/__init__.py b/paramiko/__init__.py
index b51ddfb7..c93a405d 100644
--- a/paramiko/__init__.py
+++ b/paramiko/__init__.py
@@ -32,6 +32,7 @@ from paramiko.client import (
WarningPolicy,
)
from paramiko.auth_handler import AuthHandler
+from paramiko.auth_strategy import AuthStrategy, AuthResult
from paramiko.ssh_gss import GSSAuth, GSS_AUTH_AVAILABLE, GSS_EXCEPTIONS
from paramiko.channel import (
Channel,
diff --git a/paramiko/auth_strategy.py b/paramiko/auth_strategy.py
new file mode 100644
index 00000000..872ea7d6
--- /dev/null
+++ b/paramiko/auth_strategy.py
@@ -0,0 +1,249 @@
+from collections import namedtuple
+
+from .agent import AgentKey
+from .util import get_logger
+from .ssh_exception import AuthenticationException
+
+
+class AuthSource:
+ """
+ Some SSH authentication source, such as a password, private key, or agent.
+
+ See subclasses in this module for concrete implementations.
+
+ All implementations must accept at least a ``username`` (``str``) kwarg.
+ """
+
+ def __init__(self, username):
+ self.username = username
+
+ def _repr(self, **kwargs):
+ # TODO: are there any good libs for this? maybe some helper from
+ # structlog?
+ pairs = [f"{k}={v!r}" for k, v in kwargs.items()]
+ joined = ", ".join(pairs)
+ return f"{self.__class__.__name__}({joined})"
+
+ def __repr__(self):
+ return self._repr()
+
+ def authenticate(self, transport):
+ """
+ Perform authentication.
+ """
+ raise NotImplementedError
+
+
+def NoneAuth(AuthSource):
+ """
+ Auth type "none", ie https://www.rfc-editor.org/rfc/rfc4252#section-5.2 .
+ """
+
+ def authenticate(self, transport):
+ return transport.auth_none(self.username)
+
+
+class Password(AuthSource):
+ """
+ Password authentication.
+
+ :param callable password_getter:
+ A lazy callable that should return a `str` password value at
+ authentication time, such as a `functools.partial` wrapping
+ `getpass.getpass`, an API call to a secrets store, or similar.
+
+ If you already know the password at instantiation time, you should
+ simply use something like ``lambda: "my literal"`` (for a literal, but
+ also, shame on you!) or ``lambda: variable_name (for something stored
+ in a variable).
+ """
+
+ def __init__(self, username, password_getter):
+ super().__init__(username=username)
+ self.password_getter = password_getter
+
+ def __repr__(self):
+ # Password auth is marginally more 'username-caring' than pkeys, so may
+ # as well log that info here.
+ return super()._repr(user=self.username)
+
+ def authenticate(self, transport):
+ # Lazily get the password, in case it's prompting a user
+ password = self.password_getter()
+ return transport.auth_password(self.username, password)
+
+
+class PrivateKey(AuthSource):
+ """
+ Essentially a mixin for private keys.
+
+ Knows how to auth, but leaves key material discovery/loading/decryption to
+ subclasses.
+
+ Subclasses **must** ensure that they've set ``self.pkey`` to a decrypted
+ `.PKey` instance before calling ``super().authenticate``; typically
+ either in their ``__init__``, or in an overridden ``authenticate`` prior to
+ its `super` call.
+ """
+
+ def authenticate(self, transport):
+ return transport.auth_publickey(self.username, self.pkey)
+
+
+class InMemoryPrivateKey(PrivateKey):
+ """
+ An in-memory, decrypted `.PKey` object.
+ """
+
+ def __init__(self, username, pkey):
+ super().__init__(username=username)
+ # No decryption (presumably) necessary!
+ self.pkey = pkey
+
+ def __repr__(self):
+ # NOTE: most of interesting repr-bits for private keys is in PKey.
+ # TODO: tacking on agent-ness like this is a bit awkward, but, eh?
+ rep = super()._repr(pkey=self.pkey)
+ if isinstance(self.pkey, AgentKey):
+ rep += " [agent]"
+ return rep
+
+
+class OnDiskPrivateKey(PrivateKey):
+ """
+ Some on-disk private key that needs opening and possibly decrypting.
+
+ :param str source:
+ String tracking where this key's path was specified; should be one of
+ ``"ssh-config"``, ``"python-config"``, or ``"implicit-home"``.
+ :param Path path:
+ The filesystem path this key was loaded from.
+ :param PKey pkey:
+ The `PKey` object this auth source uses/represents.
+ """
+
+ # TODO: how to log/note how this path came to our attention (ssh_config,
+ # fabric config, some direct kwarg somewhere, CLI flag, etc)? Different
+ # subclasses for all of those seems like massive overkill, so just some
+ # sort of "via" or "source" string argument?
+ def __init__(self, username, source, path, pkey):
+ super().__init__(username=username)
+ self.source = source
+ self.path = path
+ # Superclass wants .pkey, other two are mostly for display/debugging.
+ self.pkey = pkey
+
+ def __repr__(self):
+ return self._repr(
+ key=self.pkey, source=self.source, path=str(self.path)
+ )
+
+
+SourceResult = namedtuple("SourceResult", ["source", "result"])
+
+class AuthResult(list):
+ """
+ Represents a partial or complete SSH authentication attempt.
+
+ This class conceptually extends `AuthStrategy` by pairing the former's
+ authentication **sources** with the **results** of trying to authenticate
+ with them.
+
+ `AuthResult` is a (subclass of) `list` of `namedtuple`, which are of the
+ form ``namedtuple('SourceResult', 'source', 'result')`` (where the
+ ``source`` member is an `AuthSource` and the ``result`` member is either a
+ return value from the relevant `.Transport` method, or an exception
+ object).
+
+ Instances also have a `strategy` attribute referencing the `AuthStrategy`
+ which was attempted.
+ """
+
+ def __init__(self, strategy, *args, **kwargs):
+ self.strategy = strategy
+ super().__init__(*args, **kwargs)
+
+ def __str__(self):
+ # NOTE: meaningfully distinct from __repr__, which still wants to use
+ # superclass' implementation.
+ return "\n".join(f"{x.source} -> {x.result}" for x in self)
+
+
+# TODO 4.0: descend from SSHException or even just Exception
+class AuthFailure(AuthenticationException):
+ """
+ Basic exception wrapping an `AuthResult` indicating overall auth failure.
+
+ Note that `AuthFailure` descends from `AuthenticationException` but is
+ generally "higher level"; the latter is now only raised by individual
+ `AuthSource` attempts and should typically only be seen by users when
+ encapsulated in this class. It subclasses `AuthenticationException`
+ primarily for backwards compatibility reasons.
+ """
+
+ def __init__(self, result):
+ self.result = result
+
+ def __str__(self):
+ return "\n" + str(self.result)
+
+
+class AuthStrategy:
+ """
+ This class represents one or more attempts to auth with an SSH server.
+
+ By default, subclasses must at least accept an ``ssh_config``
+ (`.SSHConfig`) keyword argument, but may opt to accept more as needed for
+ their particular strategy.
+ """
+
+ def __init__(
+ self,
+ ssh_config,
+ ):
+ self.ssh_config = ssh_config
+ self.log = get_logger(__name__)
+
+ def get_sources(self):
+ """
+ Generator yielding `AuthSource` instances, in the order to try.
+
+ This is the primary override point for subclasses: you figure out what
+ sources you need, and ``yield`` them.
+
+ Subclasses _of_ subclasses may find themselves wanting to do things
+ like filtering or discarding around a call to `super`.
+ """
+ raise NotImplementedError
+
+ def authenticate(self, transport):
+ """
+ Handles attempting `AuthSource` instances yielded from `get_sources`.
+
+ You *normally* won't need to override this, but it's an option for
+ advanced users.
+ """
+ succeeded = False
+ overall_result = AuthResult(strategy=self)
+ for source in self.get_sources(transport):
+ self.log.debug(f"Trying {source}")
+ try: # NOTE: this really wants to _only_ wrap the authenticate()!
+ result = source.authenticate(transport)
+ succeeded = True
+ except Exception as e:
+ result = e
+ # NOTE: showing type, not message, for tersity & also most of
+ # the time it's basically just "Authentication failed."
+ source_class = e.__class__.__name__
+ self.log.info(
+ f"Authentication via {source} failed with {source_class}"
+ )
+ overall_result.append(SourceResult(source, result))
+ if succeeded:
+ break
+ # Gotta die here if nothing worked, otherwise Transport's main loop
+ # just kinda hangs out until something times out!
+ if not succeeded:
+ raise AuthFailure(result=overall_result)
+ # Success: give back what was done, in case they care.
+ return overall_result
diff --git a/paramiko/client.py b/paramiko/client.py
index 3d1b801c..461dbb07 100644
--- a/paramiko/client.py
+++ b/paramiko/client.py
@@ -238,6 +238,7 @@ class SSHClient(ClosingContextManager):
passphrase=None,
disabled_algorithms=None,
transport_factory=None,
+ auth_strategy=None,
):
"""
Connect to an SSH server and authenticate to it. The server's host key
@@ -323,10 +324,25 @@ class SSHClient(ClosingContextManager):
functionality, and algorithm selection) and generates a
`.Transport` instance to be used by this client. Defaults to
`.Transport.__init__`.
+ :param auth_strategy:
+ an optional instance of `.AuthStrategy`, triggering use of the
+ newer authentication mechanism (see :ref:`auth-flow`). This
+ parameter is **incompatible** with all other authentication-related
+ parameters (such as, but not limited to, ``password``,
+ ``key_filename`` and ``allow_agent``) and will trigger an exception
+ if given alongside them.
+
+ :returns:
+ `.AuthResult` if ``auth_strategy`` is non-``None``; otherwise,
+ returns ``None``.
:raises BadHostKeyException:
if the server's host key could not be verified.
- :raises AuthenticationException: if authentication failed.
+ :raises AuthenticationException:
+ if authentication failed.
+ :raises UnableToAuthenticate:
+ if authentication failed (when ``auth_strategy`` is non-``None``;
+ and note that this is a subclass of ``AuthenticationException``).
:raises socket.error:
if a socket error (other than connection-refused or
host-unreachable) occurred while connecting.
@@ -349,6 +365,8 @@ class SSHClient(ClosingContextManager):
Added the ``disabled_algorithms`` argument.
.. versionchanged:: 2.12
Added the ``transport_factory`` argument.
+ .. versionchanged:: 3.2
+ Added the ``auth_strategy`` argument.
"""
if not sock:
errors = {}
@@ -449,6 +467,11 @@ class SSHClient(ClosingContextManager):
if username is None:
username = getpass.getuser()
+ # New auth flow!
+ if auth_strategy is not None:
+ return auth_strategy.authenticate(transport=t)
+
+ # Old auth flow!
if key_filename is None:
key_filenames = []
elif isinstance(key_filename, str):
diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py
index 9b1b44c3..a1e1cfc3 100644
--- a/paramiko/ssh_exception.py
+++ b/paramiko/ssh_exception.py
@@ -89,6 +89,11 @@ class PartialAuthentication(AuthenticationException):
)
+# TODO 4.0: stop inheriting from SSHException, move to auth.py
+class UnableToAuthenticate(AuthenticationException):
+ pass
+
+
class ChannelException(SSHException):
"""
Exception raised when an attempt to open a new `.Channel` fails.