diff options
author | Jeff Forcier <jeff@bitprophet.org> | 2023-05-09 13:51:07 -0400 |
---|---|---|
committer | Jeff Forcier <jeff@bitprophet.org> | 2023-05-18 13:57:19 -0400 |
commit | 629e7809a5c651728ea983c903a4973efcc91526 (patch) | |
tree | 26045202efbf8a77917320bdc49900a09ae1b7f7 | |
parent | 992c9967330bf977e45e21079e6f2e974ac4f045 (diff) |
Partial implementation of new AuthStrategy mechanism re #387
-rw-r--r-- | paramiko/__init__.py | 1 | ||||
-rw-r--r-- | paramiko/auth_strategy.py | 249 | ||||
-rw-r--r-- | paramiko/client.py | 25 | ||||
-rw-r--r-- | paramiko/ssh_exception.py | 5 |
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. |