Skip to content

Commit

Permalink
Add Hawk authentication backend
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Josh Wilson committed Jan 10, 2019
1 parent 5ddbf62 commit 9e9d38b
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 53 deletions.
24 changes: 23 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----

Expand Down Expand Up @@ -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 <https://jwt.io/introduction/>`__
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" <https://github.com/hueniverse/hawk>`__
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**

Expand All @@ -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
-----
Expand Down
140 changes: 95 additions & 45 deletions falcon_auth/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -189,34 +199,29 @@ 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`
"""
payload = self._decode_jwt_token(req)
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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
4 changes: 3 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ codecov==2.0.3
coverage==4.0.3
python-coveralls==2.9.0


# Optional dependencies
pyjwt
mohawk==1.0.0
1 change: 0 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ description-file = README.md

[aliases]
test = pytest

11 changes: 8 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand All @@ -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
Expand Down
Loading

0 comments on commit 9e9d38b

Please sign in to comment.