From 9e9d38ba96cf48d0770a109a53b17ec5324c38a7 Mon Sep 17 00:00:00 2001 From: Josh Wilson Date: Sun, 14 Oct 2018 16:40:35 -0700 Subject: [PATCH] Add Hawk authentication backend This adds support for the [Hawk "Holder-Of-Key Authentication Scheme"](https://github.com/hueniverse/hawk`) backend. This also updates the `MultiAuthBackend` to collect any challenges from failed backends and provide them on the synthesized `HTTPUnauthorized` error if no backend succeeds in authenticating the request. The hawk library, and now the jwt library, are optional dependencies that must be specified in the client project's requirements as ["extras"](https://www.python.org/dev/peps/pep-0508/#extras). This ensures that clients only install the backend libraries necessary to their use case. Lastly, this cleans up some redundant specification of default values when raising `falcon.HTTPUnauthorized` exceptions from the backend code. --- README.rst | 24 ++++++- falcon_auth/backends.py | 140 +++++++++++++++++++++++++++------------- requirements-dev.txt | 4 +- setup.cfg | 1 - setup.py | 11 +++- tests/conftest.py | 51 ++++++++++++++- tests/test_auth.py | 40 +++++++++++- 7 files changed, 218 insertions(+), 53 deletions(-) diff --git a/README.rst b/README.rst index 5131052..0d8e941 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,12 @@ Install the extension with pip, or easy\_install. $ pip install -U falcon-auth +If you wish to use the optional backends, specify those dependencies, too. + +.. code:: bash + + $ pip install -U falcon-auth[backend-hawk,backend-jwt] + Usage ----- @@ -126,6 +132,22 @@ header contains a prefix (typically Token) followed by an `API Token` + **JWT Authentication** Token based authentication using the `JSON Web Token standard `__ +If you wish to use this backend, be sure to add the optional dependency to your requirements: + +.. code:: text + + falcon-auth[backend-jwt] + + ++ **Hawk Authentication** + +Token based authentication using the `Hawk "Holder-Of-Key Authentication Scheme" `__ +If you wish to use this backend, be sure to add the optional dependency to your requirements: + +.. code:: text + + falcon-auth[backend-hawk] + + **Dummy Authentication** @@ -134,7 +156,7 @@ Backend which does not perform any authentication checks + **Multi Backend Authentication** A Backend which comprises of multiple backends and requires any of them to authenticate -the request successfully +the request successfully. Tests ----- diff --git a/falcon_auth/backends.py b/falcon_auth/backends.py index a372f8c..f5a17cc 100644 --- a/falcon_auth/backends.py +++ b/falcon_auth/backends.py @@ -7,8 +7,22 @@ from datetime import timedelta, datetime import falcon -import jwt -from jwt import InvalidTokenError + +try: + # This is an optional dependency. To use JWTAuthBackend be sure to add + # [backend-jwt] to your falcon-auth requirement. + # See https://www.python.org/dev/peps/pep-0508/#extras + import jwt +except ImportError: + pass + +try: + # This is an optional dependency. To use AuthBackend be sure to add + # [backend-hawk] to your falcon-auth requirement. + # See https://www.python.org/dev/peps/pep-0508/#extras + import mohawk +except ImportError: + pass from falcon_auth.serializer import ExtendedJSONEncoder @@ -43,29 +57,21 @@ def parse_auth_token_from_request(self, auth_header): if not auth_header: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Missing Authorization Header', - challenges=None) + description='Missing Authorization Header') parts = auth_header.split() if parts[0].lower() != self.auth_header_prefix.lower(): raise falcon.HTTPUnauthorized( - title='401 Unauthorized', description='Invalid Authorization Header: ' - 'Must start with {0}'.format(self.auth_header_prefix), - challenges=None) + 'Must start with {0}'.format(self.auth_header_prefix)) elif len(parts) == 1: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Authorization Header: Token Missing', - challenges=None) + description='Invalid Authorization Header: Token Missing') elif len(parts) > 2: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Authorization Header: Contains extra content', - challenges=None) + description='Invalid Authorization Header: Contains extra content') return parts[1] @@ -143,7 +149,7 @@ class JWTAuthBackend(AuthBackend): audience(string, optional): Specifies the string that will be specified as value of ``aud`` field in the jwt payload. It will also be checked agains the ``aud`` field while decoding. - + issuer(string, optional): Specifies the string that will be specified as value of ``iss`` field in the jwt payload. It will also be checked agains the ``iss`` field while decoding. @@ -156,6 +162,11 @@ def __init__(self, user_loader, secret_key, audience=None, issuer=None, verify_claims=None, required_claims=None): + try: + jwt + except NameError: + raise ImportError('Optional dependency falcon-auth[backend-jwt] not installed') + self.user_loader = user_loader self.secret_key = secret_key self.algorithm = algorithm @@ -164,8 +175,7 @@ def __init__(self, user_loader, secret_key, self.expiration_delta = timedelta(seconds=expiration_delta) self.audience = audience self.issuer = issuer - self.verify_claims = verify_claims or \ - ['signature', 'exp', 'nbf', 'iat'] + self.verify_claims = verify_claims or ['signature', 'exp', 'nbf', 'iat'] self.required_claims = required_claims or ['exp', 'iat', 'nbf'] if 'aud' in self.verify_claims and not audience: @@ -189,24 +199,21 @@ def _decode_jwt_token(self, req): ) try: - payload = jwt.decode(jwt=token, key=self.secret_key, options=options, algorithms=[self.algorithm], issuer=self.issuer, audience=self.audience, leeway=self.leeway) - except InvalidTokenError as ex: + except jwt.InvalidTokenError as ex: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description=str(ex), - challenges=None) + description=str(ex)) return payload def authenticate(self, req, resp, resource): """ - Extract auth token from request `authorization` header, deocode jwt token, + Extract auth token from request `authorization` header, decode jwt token, verify configured claims and return either a ``user`` object if successful else raise an `falcon.HTTPUnauthoried exception` """ @@ -214,9 +221,7 @@ def authenticate(self, req, resp, resource): user = self.user_loader(payload) if not user: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid JWT Credentials', - challenges=None) + description='Invalid JWT Credentials') return user @@ -280,19 +285,15 @@ def _extract_credentials(self, req): try: token = base64.b64decode(token).decode('utf-8') - except Exception as ex: + except Exception: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Authorization Header: Unable to decode credentials', - challenges=None) + description='Invalid Authorization Header: Unable to decode credentials') try: username, password = token.split(':', 1) except ValueError: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Authorization: Unable to decode credentials', - challenges=None) + description='Invalid Authorization: Unable to decode credentials') return username, password @@ -306,9 +307,7 @@ def authenticate(self, req, resp, resource): user = self.user_loader(username, password) if not user: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Username/Password', - challenges=None) + description='Invalid Username/Password') return user @@ -367,9 +366,7 @@ def authenticate(self, req, resp, resource): user = self.user_loader(token) if not user: raise falcon.HTTPUnauthorized( - title='401 Unauthorized', - description='Invalid Token', - challenges=None) + description='Invalid Token') return user @@ -430,25 +427,78 @@ def __init__(self, *backends): self.backends = backends def authenticate(self, req, resp, resource): + challenges = [] for backend in self.backends: try: user = backend.authenticate(req, resp, resource) if user: return user - except falcon.HTTPUnauthorized: - pass + except falcon.HTTPUnauthorized as ex: + www_authenticate = ex.headers.get('WWW-Authenticate') + if www_authenticate: + challenges.append(www_authenticate) raise falcon.HTTPUnauthorized( - title='401 Unauthorized', description='Authorization Failed', - challenges=None) + challenges=challenges) def get_auth_token(self, user_payload): for backend in self.backends: try: - auth_token = backend.get_auth_token(user_payload) - return auth_token + return backend.get_auth_token(user_payload) except Exception: pass return None + + +class HawkAuthBackend(AuthBackend): + + def __init__(self, user_loader, receiver_kwargs=None): + try: + mohawk + except NameError: + raise ImportError('Optional dependency falcon-auth[backend-hawk] not installed') + + self.user_loader = user_loader + self.auth_header_prefix = 'Hawk' + self.receiver_kwargs = receiver_kwargs or {} + + if not callable(self.receiver_kwargs.get('credentials_map')): + raise ValueError('Required "credentials_map" function not provided in receiver_kwargs') + + def authenticate(self, req, resp, resource): + request_header = req.get_header('Authorization') + + if not request_header: + raise falcon.HTTPUnauthorized( + description='Missing Authorization Header') + + try: + request_header_prefix, _ = request_header.split(' ', 1) + except ValueError: + raise falcon.HTTPUnauthorized( + description='Invalid Authorization Header: Missing Scheme or Parameters') + + if request_header_prefix.lower() != self.auth_header_prefix.lower(): + raise falcon.HTTPUnauthorized( + description='Invalid Authorization Header: ' + 'Must start with {0}'.format(self.auth_header_prefix)) + + try: + receiver = mohawk.Receiver( + request_header=request_header, + method=req.method, + url=req.forwarded_uri, + content=req.context.get('body'), + content_type=req.get_header('Content-Type'), + **self.receiver_kwargs) + except mohawk.exc.HawkFail as ex: + raise falcon.HTTPUnauthorized( + description='{0}({1!s})'.format(ex.__class__.__name__, ex), + challenges=( + [getattr(ex, 'www_authenticate')] + if hasattr(ex, 'www_authenticate') + else [])) + + return self.user_loader(**receiver.parsed_header) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5aaf8aa..bd61943 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,4 +11,6 @@ codecov==2.0.3 coverage==4.0.3 python-coveralls==2.9.0 - +# Optional dependencies +pyjwt +mohawk==1.0.0 diff --git a/setup.cfg b/setup.cfg index b811d36..4c7dc6d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,4 +16,3 @@ description-file = README.md [aliases] test = pytest - diff --git a/setup.py b/setup.py index a2b9545..0377062 100644 --- a/setup.py +++ b/setup.py @@ -40,9 +40,12 @@ download_url='', setup_requires=['pytest-runner'], install_requires=[ - 'falcon', - 'pyjwt' + 'falcon' ], + extras_require={ + 'backend-hawk': ['mohawk==1.0.0'], + 'backend-jwt': ['pyjwt'] + }, license='MIT', name='falcon-auth', packages=[ @@ -57,7 +60,9 @@ 'codecov>=2.0.3,<3.0.0', 'coverage>=4.0.3,<5.0.0', 'tox>=2.3.1,<3.0.0', - 'python-coveralls==2.9.0' + 'python-coveralls==2.9.0', + 'pyjwt', + 'mohawk==1.0.0', ], url='https://github.com/loanzen/falcon-auth', version=version diff --git a/tests/conftest.py b/tests/conftest.py index ccb6fd2..10882d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,11 +11,12 @@ import falcon import jwt +import mohawk import pytest from falcon import testing from falcon_auth.backends import AuthBackend, BasicAuthBackend, \ - JWTAuthBackend, NoneAuthBackend, MultiAuthBackend + JWTAuthBackend, NoneAuthBackend, MultiAuthBackend, HawkAuthBackend from falcon_auth.middleware import FalconAuthMiddleware from falcon_auth.backends import TokenAuthBackend @@ -176,6 +177,54 @@ def auth_token(self, user): return get_jwt_token(user) +@pytest.fixture(scope='function') +def hawk_backend(user): + def user_loader(id, mac, ts, nonce): + return user if user.username == id else None + + def credentials_map(username): + # Our backend will only know about the one user + creds = { + user.username: { + 'id': user.username, + 'key': user.password, + 'algorithm': 'sha256', + } + } + + return creds[username] + + return HawkAuthBackend( + user_loader, + receiver_kwargs=dict(credentials_map=credentials_map)) + + +def get_hawk_token(user): + sender = mohawk.Sender( + credentials={ + 'id': user.username, + 'key': user.password, + 'algorithm': 'sha256', + }, + url='http://falconframework.org/auth', + method='GET', + nonce='ABC123', + always_hash_content=False + ) + return str(sender.request_header) + + +class HawkAuthFixture: + + @pytest.fixture(scope='function') + def backend(self, hawk_backend): + return hawk_backend + + @pytest.fixture(scope='function') + def auth_token(self, user): + return get_hawk_token(user) + + @pytest.fixture(scope='function') def none_backend(none_user): return NoneAuthBackend(lambda: none_user) diff --git a/tests/test_auth.py b/tests/test_auth.py index 72eb208..2241a75 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -7,7 +7,10 @@ def simulate_request(client, url, method='POST', **kwargs): - headers = {'Content-Type': 'application/json'} + if method == 'POST': + headers = {'Content-Type': 'application/json'} + else: + headers = {} auth_token = kwargs.pop('auth_token', None) if auth_token: headers['Authorization'] = auth_token @@ -144,6 +147,41 @@ def test_init_iss_claim_none_issuer_fails(self): assert 'Issuer parameter must be provided' in str(ex.value) +class TestWithHawkAuth(HawkAuthFixture, ResourceFixture): + + def test_valid_auth_success(self, client, auth_token, user): + resp = simulate_request(client, '/auth', method='GET', auth_token=auth_token) + assert resp.status_code == 200 + assert resp.text == 'Success' + + def test_invalid_prefix_fail(self, client, user, auth_token): + auth_token = auth_token.replace('Hawk', 'Invalid') + resp = simulate_request(client, '/auth', method='GET', auth_token=auth_token) + assert resp.status_code == 401 + assert 'Must start with Hawk' in resp.text + + def test_unrecognized_user_fails(self, client, user): + cloned_user = user.clone() + cloned_user.username = 'jane' + auth_token = get_hawk_token(cloned_user) + resp = simulate_request(client, '/auth', method='GET', auth_token=auth_token) + assert resp.status_code == 401 + assert (resp.json['description'] + == 'CredentialsLookupError(Could not find credentials for ID jane)') + + def test_invalid_password_fails(self, client, user): + cloned_user = user.clone() + cloned_user.password = 'incorrect password' + auth_token = get_hawk_token(cloned_user) + resp = simulate_request(client, '/auth', method='GET', auth_token=auth_token) + assert resp.status_code == 401 + assert 'MacMismatch(MACs do not match' in resp.json['description'] + + def test_init_receiver_credentials_map_none_fails(self): + with pytest.raises(ValueError) as ex: + HawkAuthBackend(lambda u: u, receiver_kwargs={}) + + class TestWithNoneAuth(NoneAuthFixture, ResourceFixture): def test_valid_auth_success(self, client, none_user):