diff --git a/.travis.yml b/.travis.yml
index 82d227a..7f761f0 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,18 +1,41 @@
language: python
-python:
- - "2.6"
- - "2.7"
- - "3.3"
- - "3.4"
- - "3.5"
- - "3.6"
+
+matrix:
+ include:
+ - python: 2.6
+ env:
+ - WITH_JWT=false
+ WITH_HAWK=false
+ - python: 2.7
+ env:
+ - WITH_JWT=true
+ WITH_HAWK=true
+ - python: 3.3
+ env:
+ - WITH_JWT=false
+ WITH_HAWK=false
+ - python: 3.4
+ env:
+ - WITH_JWT=true
+ WITH_HAWK=true
+ - python: 3.5
+ env:
+ - WITH_JWT=true
+ WITH_HAWK=true
+ - python: 3.6
+ env:
+ - WITH_JWT=true
+ WITH_HAWK=true
before_install:
- pip install --upgrade pytest
-install: "pip install -r requirements-dev.txt"
+install:
+ - pip install -r requirements-dev.txt"
+ $WITH_JWT && pip install pyjwt>=1.7.0
+ $WITH_HAWK && pip install mohawk>=1.0.0
script: py.test --cov-report term-missing --cov=falcon_auth
after_success:
- - coveralls
\ No newline at end of file
+ - coveralls
diff --git a/Dockerfile b/Dockerfile
new file mode 100755
index 0000000..6ee2be9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:latest
+
+RUN pip install -U pytest
+
+WORKDIR /srv
+
+COPY falcon_auth falcon_auth
+COPY tests tests
+COPY setup.py setup.py
+COPY setup.cfg setup.cfg
+COPY requirements.txt requirements.txt
+
+RUN pip install -r requirements.txt
+RUN pip install 'pytest<4' pyjwt
+
+CMD ["/bin/bash"]
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..4c1c290 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
@@ -40,32 +54,23 @@ def parse_auth_token_from_request(self, auth_header):
Parses and returns Auth token from the request header. Raises
`falcon.HTTPUnauthoried exception` with proper error message
"""
-
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 +148,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 +161,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 +174,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 +198,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 +220,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
@@ -246,7 +250,6 @@ def get_auth_token(self, user_payload):
class BasicAuthBackend(AuthBackend):
-
"""
Implements `HTTP Basic Authentication `__
Clients should authenticate by passing the `base64` encoded credentials
@@ -280,19 +283,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 +305,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
@@ -337,7 +334,6 @@ class TokenAuthBackend(BasicAuthBackend):
Implements Simple Token Based Authentication. Clients should authenticate by passing the token key in the "Authorization"
HTTP header, prepended with the string "Token ". For example:
-
Authorization: Token 401f7ac837da42b97f613d789819ff93537bee6a
Args:
@@ -367,9 +363,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
@@ -405,8 +399,90 @@ def authenticate(self, req, resp, resource):
return self.user_loader()
-class MultiAuthBackend(AuthBackend):
+class HawkAuthBackend(AuthBackend):
+ """
+ Holder-Of-Key Authentication Scheme defined by `Hawk `__
+ Clients should authenticate by passing a Hawk-formatted header as the `Authorization`
+ HTTP header. For example:
+
+ Authorization: Hawk id="dh37fgj492je", ts="1353832234", nonce="j4h3g2", ext="some-app-ext-data", mac="6R4rV5iE+NPoym+WwjeHzjAGXUtLNIxmo1vpMofpLAE="
+ Args:
+ user_loader(function, required): A callback function that is called with the `id`
+ value extracted from the `Hawk` header. Returns an `authenticated user` if the user
+ matching the credentials exists or returns `None` to indicate if no user was found.
+
+ receiver_kwargs(dict, required): A dictionary of arguments to be passed through
+ to the Receiver. One must provide the `credentials_map` function for the
+ purposes of looking up a user's credentials from their user id (the same value
+ passed to `user_loader()`). See the `docs `__
+ for further details.
+ """
+ def __init__(self, user_loader, receiver_kwargs):
+ 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
+
+ if not callable(self.receiver_kwargs.get('credentials_map')):
+ raise ValueError('Required "credentials_map" function not provided in receiver_kwargs')
+
+ def parse_auth_token_from_request(self, auth_header):
+ """
+ Parses and returns the Hawk Authorization header if it is present and well-formed.
+ Raises `falcon.HTTPUnauthoried exception` with proper error message
+ """
+ if not auth_header:
+ raise falcon.HTTPUnauthorized(
+ description='Missing Authorization Header')
+
+ try:
+ auth_header_prefix, _ = auth_header.split(' ', 1)
+ except ValueError:
+ raise falcon.HTTPUnauthorized(
+ description='Invalid Authorization Header: Missing Scheme or Parameters')
+
+ if auth_header_prefix.lower() != self.auth_header_prefix.lower():
+ raise falcon.HTTPUnauthorized(
+ description='Invalid Authorization Header: '
+ 'Must start with {0}'.format(self.auth_header_prefix))
+
+ return auth_header
+
+ def authenticate(self, req, resp, resource):
+ request_header = self.parse_auth_token_from_request(req.get_header('Authorization'))
+
+ try:
+ # Validate the Authorization header contents and lookup the user's credentials
+ # via the provided `credentials_map` function.
+ 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 []))
+
+ # The authentication was successful, get the actual user object now.
+ user = self.user_loader(receiver.parsed_header['id'])
+ if not user:
+ # Should never really happen unless your user objects and their
+ # credentials are out of sync.
+ raise falcon.HTTPUnauthorized(
+ description='Invalid User')
+
+
+class MultiAuthBackend(AuthBackend):
"""
A backend which takes two or more ``AuthBackend`` as inputs and successfully
authenticates if either of them succeeds else raises `falcon.HTTPUnauthoried exception`
@@ -430,24 +506,25 @@ 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
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 5aaf8aa..776bd77 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -10,5 +10,3 @@ tox==2.3.1
codecov==2.0.3
coverage==4.0.3
python-coveralls==2.9.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..5fa22d2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,13 +11,15 @@
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
+from falcon_auth.serializer import ExtendedJSONEncoder
EXPIRATION_DELTA = 30 * 60
@@ -160,7 +162,8 @@ def get_jwt_token(user, prefix='JWT'):
'exp': now + timedelta(seconds=EXPIRATION_DELTA)
}
- jwt_token = jwt.encode(payload, SECRET_KEY).decode('utf-8')
+ jwt_token = jwt.encode(payload, SECRET_KEY,
+ json_encoder=ExtendedJSONEncoder).decode('utf-8')
return '{prefix} {jwt_token}'.format(prefix=prefix, jwt_token=jwt_token)
@@ -176,6 +179,54 @@ def auth_token(self, user):
return get_jwt_token(user)
+@pytest.fixture(scope='function')
+def hawk_backend(user):
+ def user_loader(username):
+ return user if user.username == username 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..bc1cf71 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
@@ -104,6 +107,11 @@ def test_backend_get_auth_token(self, user, backend, auth_token):
class TestWithJWTAuth(JWTAuthFixture, ResourceFixture):
+ def test_get_auth_header(self, jwt_backend, user):
+ auth_header = jwt_backend.get_auth_header(user.to_dict())
+ prefix, data = auth_header.split()
+ assert prefix == 'jwt'
+
def test_valid_auth_success(self, client, auth_token, user):
resp = simulate_request(client, '/auth', auth_token=auth_token)
assert resp.status_code == 200
@@ -143,6 +151,50 @@ def test_init_iss_claim_none_issuer_fails(self):
assert 'Issuer parameter must be provided' in str(ex.value)
+ def test_backend_get_auth_token(self, user, backend):
+ user_payload = {
+ 'id': user.id,
+ 'username': user.username
+ }
+ auth_token = backend.get_auth_token(user_payload)
+ decoded_token = jwt.decode(auth_token, SECRET_KEY)
+ assert decoded_token['user'] == user_payload
+
+
+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):
@@ -258,3 +310,14 @@ def test_auth_middleware_none_backend():
FalconAuthMiddleware(backend=None)
assert 'Invalid authentication backend' in str(ex.value)
+
+
+def test_optional_jwt_not_present(monkeypatch):
+ monkeypatch.delattr('falcon_auth.backends.jwt')
+ with pytest.raises(ImportError):
+ JWTAuthBackend(lambda _: None, SECRET_KEY)
+
+def test_optional_hawk_not_present(monkeypatch):
+ monkeypatch.delattr('falcon_auth.backends.mohawk')
+ with pytest.raises(ImportError):
+ HawkAuthBackend(lambda _: None, {})