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):