Skip to content

Commit

Permalink
Use instances instead of classes for mechanisms
Browse files Browse the repository at this point in the history
  • Loading branch information
icgood committed Nov 9, 2017
1 parent fef3799 commit 28f3177
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 118 deletions.
147 changes: 86 additions & 61 deletions pysasl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from __future__ import absolute_import

from collections import OrderedDict

from pkg_resources import iter_entry_points

__all__ = ['AuthenticationError', 'UnexpectedAuthChallenge',
Expand Down Expand Up @@ -74,6 +76,8 @@ class AuthenticationCredentials(object):
"""

__slots__ = ['authcid', 'secret', 'authzid']

def __init__(self, authcid, secret, authzid=None):
super(AuthenticationCredentials, self).__init__()
self.authcid = authcid
Expand All @@ -99,6 +103,8 @@ class ClientResponse(object):
"""

__slots__ = ['response', 'challenge']

def __init__(self, response):
super(ClientResponse, self).__init__()
self.response = response
Expand Down Expand Up @@ -128,6 +134,8 @@ class ServerChallenge(Exception):
"""

__slots__ = ['challenge', 'response']

def __init__(self, challenge):
super(ServerChallenge, self).__init__()
self.challenge = challenge
Expand All @@ -151,110 +159,127 @@ def set_response(self, data):
self.response = data


class ServerMechanism(object):
"""Base class for implementing SASL mechanisms that support server-side
credential verification.
class _BaseMechanism(object):

.. classmethod:: server_attempt(self, challenges)
def __init__(self, name=None):
super(_BaseMechanism, self).__init__()
if name is not None:
self.name = name

For SASL server-side credential verification, receives responses from
the client and issues challenges until it has everything needed to
verify the credentials.

If a challenge is necessary, an :class:`ServerChallenge` exception will
be raised. Send the challenge string to the client with
:meth:`~ServerChallenge.get_challenge` and then populate its response
with :meth:`~ServerChallenge.set_response`. Finally, append the
exception to the ``challenges`` argument before calling again.
class ServerMechanism(_BaseMechanism):
"""Base class for implementing SASL mechanisms that support server-side
credential verification.
:param list challenges: The list of :class:`ServerChallenge` objects
that have been issued by the mechanism and
responded to by the client.
:raises: :class:`ServerChallenge`
:rtype: :class:`AuthenticationCredentials`
:param str name: Override the standard SASL mechanism name.
"""
pass


class ClientMechanism(object):
"""Base class for implementing SASL mechanisms that support client-side
credential verification.
def server_attempt(self, challenges):
"""For SASL server-side credential verification, receives responses
from the client and issues challenges until it has everything needed to
verify the credentials.
.. classmethod:: client_attempt(self, creds, responses)
If a challenge is necessary, an :class:`ServerChallenge` exception will
be raised. Send the challenge string to the client with
:meth:`~ServerChallenge.get_challenge` and then populate its response
with :meth:`~ServerChallenge.set_response`. Finally, append the
exception to the ``challenges`` argument before calling again.
For SASL client-side credential verification, produce responses to send
to the server and react to its challenges until the server returns a
final success or failure.
:param list challenges: The list of :class:`ServerChallenge` objects
that have been issued by the mechanism and
responded to by the client.
:raises: :class:`ServerChallenge`
:rtype: :class:`AuthenticationCredentials`
Send the response string to the server with the
:meth:`~ClientResponse.get_response` method of the returned
:class:`ClientResponse` object. If the server returns another challenge,
set it with the object's :meth:`~ClientResponse.set_challenge` method
and append the object to the ``responses`` argument before calling
again.
"""
raise NotImplementedError()

The mechanism may raise :class:`AuthenticationError` if it receives
unexpected challenges from the server.

:param creds: The credentials to attempt authentication with.
:type creds: :class:`AuthenticationCredentials`
:param list responses: The list of :class:`ClientResponse` objects that
have been sent to the server. New attempts begin
with an empty list.
:rtype: :class:`ChallengeResponse`
:raises: :class:`AuthenticationError`
class ClientMechanism(_BaseMechanism):
"""Base class for implementing SASL mechanisms that support client-side
credential verification.
"""
pass

def client_attempt(self, creds, responses):
"""For SASL client-side credential verification, produce responses to
send to the server and react to its challenges until the server returns
a final success or failure.
Send the response string to the server with the
:meth:`~ClientResponse.get_response` method of the returned
:class:`ClientResponse` object. If the server returns another
challenge, set it with the object's
:meth:`~ClientResponse.set_challenge` method and append the object to
the ``responses`` argument before calling again.
The mechanism may raise :class:`AuthenticationError` if it receives
unexpected challenges from the server.
:param creds: The credentials to attempt authentication with.
:type creds: :class:`AuthenticationCredentials`
:param list responses: The list of :class:`ClientResponse` objects that
have been sent to the server. New attempts begin
with an empty list.
:rtype: :class:`ChallengeResponse`
:raises: :class:`AuthenticationError`
"""
raise NotImplementedError()


class SASLAuth(object):
"""Manages the mechanisms available for authentication attempts.
:param list advertised: List of SASL mechanism name strings. The set of
known mechanisms will be intersected with these
names. By default, all known mechanisms are
available.
:param list advertised: List of available SASL mechanism objects. Using the
name of a built-in mechanism (e.g. ``b'PLAIN'``)
works as well. By default, all built-in mechanisms
are available.
"""

__slots__ = ['mechs']

def __init__(self, advertised=None):
super(SASLAuth, self).__init__()
self.mechs = self._load_known_mechanisms()
known_mechs = self._load_known_mechanisms()
if advertised:
advertised = set(advertised)
self.mechs = dict([(name, mech)
for name, mech in self.mechs.items()
if name in advertised])
self.mechs = OrderedDict()
for mech in advertised:
if isinstance(mech, _BaseMechanism):
self.mechs[mech.name] = mech
else:
self.mechs[mech] = known_mechs[mech]
else:
self.mechs = known_mechs

@classmethod
def _load_known_mechanisms(cls):
mechs = {}
mechs = OrderedDict()
for entry_point in iter_entry_points('pysasl.mechanisms'):
mech = entry_point.load()
mechs[mech.name] = mech
mechs[mech.name] = mech()
return mechs

@property
def server_mechanisms(self):
"""List of available :class:`ServerMechanism` classes."""
"""List of available :class:`ServerMechanism` objects."""
return [mech for mech in self.mechs.values()
if hasattr(mech, 'server_attempt')]
if isinstance(mech, ServerMechanism)]

@property
def client_mechanisms(self):
"""List of available :class:`ClientMechanism` classes."""
"""List of available :class:`ClientMechanism` objects."""
return [mech for mech in self.mechs.values()
if hasattr(mech, 'client_attempt')]
if isinstance(mech, ClientMechanism)]

def get(self, name):
"""Get a SASL mechanism by name. The resulting class should support
either :meth:`~ServerMechanism.server_attempt`,
:meth:`~ClientMechanism.client_attempt` or both.
"""Get a SASL mechanism by name. The resulting object should inherit
either :class:`ServerMechanism`, :class:`ClientMechanism`, or both.
:param bytes name: The SASL mechanism name.
:returns: The mechanism class or ``None``
:returns: The mechanism object or ``None``
"""
return self.mechs.get(name.upper())
16 changes: 5 additions & 11 deletions pysasl/crammd5.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

class CramMD5Result(AuthenticationCredentials):

__slots__ = ['challenge', 'digest']

def __init__(self, username, challenge, digest):
super(CramMD5Result, self).__init__(username, None)
self.challenge = challenge
Expand Down Expand Up @@ -67,36 +69,28 @@ class CramMD5Mechanism(ServerMechanism, ClientMechanism):
This mechanism is considered secure for non-encrypted sessions.
.. attribute:: hostname
Unless this class-level attribute is set, :py:func:`~socket.gethostname`
will be used when generating challenge string.
"""

name = b'CRAM-MD5'
insecure = False
hostname = None
_pattern = re.compile(br'^(.*) ([^ ]+)$')

@classmethod
def server_attempt(cls, challenges):
def server_attempt(self, challenges):
if not challenges:
challenge = email.utils.make_msgid().encode('utf-8')
raise ServerChallenge(challenge)
challenge = challenges[0].challenge
response = challenges[0].response

match = re.match(cls._pattern, response)
match = re.match(self._pattern, response)
if not match:
raise AuthenticationError('Invalid CRAM-MD5 response')
username, digest = match.groups()

username_str = username.decode('utf-8')
return CramMD5Result(username_str, challenge, digest)

@classmethod
def client_attempt(cls, creds, responses):
def client_attempt(self, creds, responses):
if len(responses) < 1:
return ClientResponse(b'')
elif len(responses) > 1:
Expand Down
6 changes: 2 additions & 4 deletions pysasl/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ class LoginMechanism(ServerMechanism, ClientMechanism):
name = b'LOGIN'
insecure = True

@classmethod
def server_attempt(cls, challenges):
def server_attempt(self, challenges):
if len(challenges) < 1:
raise ServerChallenge(b'Username:')
if len(challenges) < 2:
Expand All @@ -54,8 +53,7 @@ def server_attempt(cls, challenges):
password = challenges[1].response.decode('utf-8')
return AuthenticationCredentials(username, password)

@classmethod
def client_attempt(cls, creds, responses):
def client_attempt(self, creds, responses):
if len(responses) < 1:
return ClientResponse(b'')
if len(responses) < 2:
Expand Down
3 changes: 1 addition & 2 deletions pysasl/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ class OAuth2Mechanism(ClientMechanism):

name = b'XOAUTH2'

@classmethod
def client_attempt(cls, creds, responses):
def client_attempt(self, creds, responses):
if len(responses) > 1:
raise UnexpectedAuthChallenge()
elif len(responses) > 0:
Expand Down
9 changes: 3 additions & 6 deletions pysasl/plain.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,14 @@ class PlainMechanism(ServerMechanism, ClientMechanism):

name = b'PLAIN'
insecure = True

_pattern = re.compile(br'^([^\x00]*)\x00([^\x00]+)\x00([^\x00]*)$')

@classmethod
def server_attempt(cls, challenges):
def server_attempt(self, challenges):
if not challenges:
raise ServerChallenge(b'')

response = challenges[0].response
match = re.match(cls._pattern, response)
match = re.match(self._pattern, response)
if not match:
raise AuthenticationError('Invalid PLAIN response')
zid, cid, secret = match.groups()
Expand All @@ -64,8 +62,7 @@ def server_attempt(cls, challenges):
zid_str = zid.decode('utf-8')
return AuthenticationCredentials(cid_str, secret_str, zid_str)

@classmethod
def client_attempt(cls, creds, responses):
def client_attempt(self, creds, responses):
if len(responses) > 1:
raise UnexpectedAuthChallenge()
authzid = (creds.authzid or '').encode('utf-8')
Expand Down
23 changes: 14 additions & 9 deletions test/test_crammd5.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,22 @@

class TestCramMD5Mechanism(unittest.TestCase):

def setUp(self):
self.mech = CramMD5Mechanism()

def test_availability(self):
sasl = SASLAuth([self.mech])
self.assertEqual([self.mech], sasl.client_mechanisms)
self.assertEqual([self.mech], sasl.server_mechanisms)
self.assertEqual(self.mech, sasl.get(b'CRAM-MD5'))
sasl = SASLAuth([b'CRAM-MD5'])
self.assertEqual([CramMD5Mechanism], sasl.client_mechanisms)
self.assertEqual([CramMD5Mechanism], sasl.server_mechanisms)
self.assertEqual(CramMD5Mechanism, sasl.get(b'CRAM-MD5'))
self.assertIsInstance(sasl.get(b'CRAM-MD5'), CramMD5Mechanism)

@patch.object(email.utils, 'make_msgid')
def test_server_attempt_issues_challenge(self, make_msgid_mock):
make_msgid_mock.return_value = '<abc123.1234@testhost>'
try:
CramMD5Mechanism.server_attempt([])
self.mech.server_attempt([])
except ServerChallenge as exc:
self.assertEqual(b'<abc123.1234@testhost>', exc.challenge)
else:
Expand All @@ -38,15 +43,15 @@ def test_server_attempt_bad_response(self, make_msgid_mock):
resp = ServerChallenge(b'')
resp.set_response(b'testing')
self.assertRaises(AuthenticationError,
CramMD5Mechanism.server_attempt, [resp])
self.mech.server_attempt, [resp])

@patch.object(email.utils, 'make_msgid')
def test_server_attempt_successful(self, make_msgid_mock):
make_msgid_mock.return_value = '<abc123.1234@testhost>'
response = b'testuser 3a569c3950e95c490fd42f5d89e1ef67'
resp = ServerChallenge(b'<abc123.1234@testhost>')
resp.set_response(response)
result = CramMD5Mechanism.server_attempt([resp])
result = self.mech.server_attempt([resp])
self.assertTrue(result.authzid is None)
self.assertEqual('testuser', result.authcid)
self.assertTrue(result.check_secret(u'testpass'))
Expand All @@ -55,12 +60,12 @@ def test_server_attempt_successful(self, make_msgid_mock):

def test_client_attempt(self):
creds = AuthenticationCredentials('testuser', 'testpass')
resp1 = CramMD5Mechanism.client_attempt(creds, [])
resp1 = self.mech.client_attempt(creds, [])
self.assertEqual(b'', resp1.get_response())
resp1.set_challenge(b'<abc123.1234@testhost>')
resp2 = CramMD5Mechanism.client_attempt(creds, [resp1])
resp2 = self.mech.client_attempt(creds, [resp1])
self.assertEqual(b'testuser 3a569c3950e95c490fd42f5d89e1ef67',
resp2.get_response())
self.assertRaises(UnexpectedAuthChallenge,
CramMD5Mechanism.client_attempt,
self.mech.client_attempt,
creds, [resp1, resp2])

0 comments on commit 28f3177

Please sign in to comment.