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, {})