From cf0be16845c461438ccead60c3b2e06b312512d6 Mon Sep 17 00:00:00 2001 From: Ioannis Latousakis Date: Wed, 26 Sep 2018 21:40:40 +0100 Subject: [PATCH] Add authentication class for DRF --- .gitignore | 1 + oidc_provider/authentication.py | 187 +++++++++++++ oidc_provider/settings.py | 22 ++ .../tests/cases/test_authentication.py | 261 ++++++++++++++++++ oidc_provider/utils.py | 21 ++ 5 files changed, 492 insertions(+) create mode 100644 oidc_provider/authentication.py create mode 100644 oidc_provider/tests/cases/test_authentication.py create mode 100644 oidc_provider/utils.py diff --git a/.gitignore b/.gitignore index c0574194..a3874a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/_build/ .eggs/ .python-version .pytest_cache/ +*.swp diff --git a/oidc_provider/authentication.py b/oidc_provider/authentication.py new file mode 100644 index 00000000..89803538 --- /dev/null +++ b/oidc_provider/authentication.py @@ -0,0 +1,187 @@ +import requests +import time +import datetime +import six +from requests.auth import HTTPBasicAuth +from urllib.parse import urljoin +from requests.exceptions import HTTPError +from jwkest import JWKESTException +from jwkest.jwk import KEYS +from jwkest.jws import JWS +from django.utils.encoding import smart_text +from django.utils.functional import cached_property +from django.utils.translation import ugettext as _ +from django.conf import settings +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.exceptions import AuthenticationFailed +from oidc_provider.util import cache + + +class AuthenticatedServiceClient: + def __init__(self, token): + self.token = token + + def is_authenticated(self): + return True + + @staticmethod + def create(payload): + return AuthenticatedServiceClient(token) + + +class BaseOidcAuthentication(BaseAuthentication): + + @cached_property + def oidc_config(self): + url = urljoin(setttings.OIDC_ENDPOINT, '.well-known/openid-configuration') + return requests.get(url).json() + + +class AccessTokenAuthentication(BaseOidcAuthentication): + www_authenticate_realm = 'api' + + def authenticate(self, request): + bearer_token = self.get_bearer_token(request) + if bearer_token is None: + return None + + try: + token_info = self.introspect_token(bearer_token) + except HTTPError: + msg = _('Invalid Authorization header. Unable to verify bearer token') + raise AuthenticationFailed(msg) + self.validate_bearer_token(token_info) + + return AuthenticatedServiceClient.create(token_info), True + + def validate_bearer_token(self, token_info): + if token_info['active'] is False: + msg = _('Authentication Failed. Received Inactive Token') + raise AuthenticationFailed(msg) + + if setttings.OIDC_SCOPE not in token_info['scope']: + msg = _('Authentication Failed. Invalid token scope') + raise AuthenticationFailed(msg) + + utc_timestamp = int(time.time()) + if utc_timestamp > int(token_info.get('exp', 0)): + msg = _('Authentication Failed. Token expired') + raise AuthenticationFailed(msg) + + def get_bearer_token(self, request): + auth = get_authorization_header(request).split() + auth_header_prefix = setttings.BEARER_AUTH_HEADER_PREFIX.lower() + + if not auth or smart_text(auth[0].lower()) != auth_header_prefix: + return None + if len(auth) == 1: + msg = _('Invalid Authorization header. No credentials provided') + raise AuthenticationFailed(msg) + elif len(auth) > 2: + msg = _('Invalid Authorization header. Credentials string should not contain spaces.') + raise AuthenticationFailed(msg) + elif smart_text(auth[1]).count('.') == 2: + return None + return auth[1] + + @cache(ttl=setttings.OIDC_BEARER_TOKEN_EXPIRATION_TIME) + def introspect_token(self, token): + response = requests.post( + self.oidc_config['introspection_endpoint'], + auth=HTTPBasicAuth(setttings.OIDC_INTERSPECT_USERNAME, setttings.OIDC_INTROSPECT_PASSWORD), + data={'token': token.decode('ascii')}) + return response.json() + + +class IDTokenAuthentication(BaseOidcAuthentication): + """Token based authentication using the JSON Web Token standard""" + + www_authenticate_realm = 'api' + + def authenticate(self, request): + jwt_value = self.get_jwt_value(request) + if jwt_value is None: + return None + payload = self.decode_jwt(jwt_value) + self.validate_claims(payload) + + return AuthenticatedServiceClient.create(payload), True + + def get_jwt_value(self, request): + auth = get_authorization_header(request).split() + auth_header_prefix = setttings.BEARER_AUTH_HEADER_PREFIX.lower() + + if not auth or smart_text(auth[0].lower()) != auth_header_prefix: + return None + + if len(auth) == 1: + msg = _('Invalid Authorization header. No credentials provided') + raise AuthenticationFailed(msg) + elif len(auth) > 2: + msg = _('Invalid Authorization header. Credentials string should not contain spaces.') + raise AuthenticationFailed(msg) + elif smart_text(auth[1]).count('.') != 2: + return None + + return auth[1] + + def jwks(self): + keys = KEYS() + keys.load_from_url(self.oidc_config['jwks_uri'], verify=False) + return keys + + @cached_property + def issuer(self): + return self.oidc_config['issuer'] + + @cache(ttl=setttings.OIDC_JWKS_EXPIRATION_TIME) + def decode_jwt(self, jwt_value): + keys = self.jwks() + try: + id_token = JWS().verify_compact(jwt_value, keys=keys) + except JWKESTException: + msg = _('Invalid Authorization header. JWT Signature verification failed.') + raise AuthenticationFailed(msg) + except UnicodeDecodeError: + msg = _('Bad token format. Token decoding failed.') + raise AuthenticationFailed(msg) + return id_token + + def get_audiences(self, id_token): + return setttings.OIDC_AUDIENCES + + def validate_claims(self, id_token): + if isinstance(id_token.get('aud'), six.string_types): + # Support for multiple audiences + id_token['aud'] = [id_token['aud']] + + if id_token.get('iss') != self.issuer: + msg = _('Invalid Authorization header. Invalid JWT issuer.') + raise AuthenticationFailed(msg) + if not any(aud in self.get_audiences(id_token) for aud in id_token.get('aud', [])): + msg = _('Invalid Authorization header. Invalid JWT audience.') + raise AuthenticationFailed(msg) + if settings.OIDC_AUTHORIZED_PARTY_CHECK: + if len(id_token['aud']) > 1 and 'azp' not in id_token: + msg = _('Invalid Authorization header. Missing JWT authorized party.') + raise AuthenticationFailed(msg) + if 'azp' in id_token and id_token['azp'] not in setttings.OIDC_AUDIENCES: + msg = _('Invalid Authorization header. Invalid JWT authorized party.') + raise AuthenticationFailed(msg) + + utc_timestamp = int(time.time()) + if utc_timestamp > id_token.get('exp', 0): + msg = _('Invalid Authorization header. JWT has expired.') + raise AuthenticationFailed(msg) + if 'nbf' in id_token and utc_timestamp < id_token['nbf']: + msg = _('Invalid Authorization header. JWT not yet valid.') + raise AuthenticationFailed(msg) + if 'iat' in id_token and utc_timestamp > id_token['iat'] + setttings.OIDC_LEEWAY: + msg = _('Invalid Authorization header. JWT too old.') + raise AuthenticationFailed(msg) + if setttings.OIDC_SCOPE not in id_token.get('scope'): + msg = _('Invalid Authorization header. Invalid JWT scope.') + raise AuthenticationFailed(msg) + + def authenticate_header(self, request): + return 'JWT realm="{0}"'.format(self.www_authenticate_realm) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 6d0607ee..59368aa7 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -168,6 +168,28 @@ def OIDC_TEMPLATES(self): 'error': 'oidc_provider/error.html' } + @property + def OIDC_INTROSPECT_PASSWORD(self): + """ + OPTIONAL. The password used for authenticating against introspect endpoint + """ + return None + + @property + def OIDC_INTERSPECT_USERNAME(self): + """ + OPTIONAL. The username used for authenticating against introspect endpoint + """ + return None + + @property + def OIDC_AUTHORIZED_PARTY_CHECK(self): + """ + OPTIONAL. A boolean to set whether to validate AZP when multiple audiences + are present + """ + return True + default_settings = DefaultSettings() diff --git a/oidc_provider/tests/cases/test_authentication.py b/oidc_provider/tests/cases/test_authentication.py new file mode 100644 index 00000000..f3f22de8 --- /dev/null +++ b/oidc_provider/tests/cases/test_authentication.py @@ -0,0 +1,261 @@ +import time +from unittest.mock import patch, PropertyMock, Mock +from datetime import datetime +from django.conf import settings +from collections import namedtuple +from django.test import override_settings +from rest_framework.exceptions import AuthenticationFailed +from oidc_provider.authentication import BearerTokenAuthentication, JSONWebTokenAuthentication +from oidc_provider.client import fetch_service_token + + +class BearerTokenAuthenticationTestCase(BaseTestCase): + + def setUp(self): + self.active = True + patch_config = patch( + 'oidc_provider.authentication.BearerTokenAuthentication.oidc_config', + new_callable=PropertyMock(return_value={ + 'introspection_endpoint': 'introspection-endpoint-url'})) + patch_introspect = patch('oidc_provider.authentication.requests.post', side_effect=self.mocked_introspect_post) + + patch_header = patch( + 'oidc_provider.authentication.get_authorization_header', + return_value='Bearer some-token'.encode('ascii')) + cache_mock = Mock() + cache_mock.get.return_value = None + patch_dj_cache = patch('oidc_provider.util.dj_cache', new=cache_mock) + + patch_dj_cache.start() + patch_config.start() + patch_introspect.start() + patch_header.start() + + self.addCleanup(patch_dj_cache.stop) + self.addCleanup(patch_header.stop) + self.addCleanup(patch_introspect.stop) + self.addCleanup(patch_config.stop) + + def get_response(self): + return { + "aud": "http://oidc-service.com/resources", + "iss": "http://oidc-service.com", + "nbf": int(time.time()) - 1000, + "exp": int(time.time()) + 1000, + "client_id": "paw-frontend", + "role": [ + "service-directory-reader", + "service-directory-writer" + ], + "active": self.active, + "scope": "paw-service" + } + + def mocked_introspect_post(self, *args, **kwargs): + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + # requested URL needs to much mocked oidc config to give the JSON response + if args[0] == 'introspection-endpoint-url': + return MockResponse(self.get_response(), 200) + + return MockResponse(None, 404) + + def test_valid_token(self): + auth = BearerTokenAuthentication() + user, authenticated = auth.authenticate({}) + self.assertTrue(authenticated) + self.assertEqual(user.token, {}) + + def test_inactive_token(self): + self.active = False + + auth = BearerTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate({}) + + @override_settings(OIDC_AUTH={**settings.OIDC_AUTH, **{'OIDC_SCOPE': 'missing'}}) + def test_bad_scope(self): + auth = BearerTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate({}) + + def test_invalid_header(self): + # Two spaces, must only have one + with patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer bad token'.encode('ascii')): + auth = BearerTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate({}) + # No spaces at all + with patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer'.encode('ascii')): + auth = BearerTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate({}) + + # JWT token + with patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer some.jwt.token'.encode('ascii')): + auth = BearerTokenAuthentication() + self.assertEqual(auth.authenticate({}), None) + + +@override_settings(OIDC_AUTH={**settings.OIDC_AUTH, **{'OIDC_AUDIENCES': 'http://oidc-service.com/resources'}}) +class JWTAuthenticationTestCase(BaseTestCase): + + def setUp(self): + super().setUp() + patch_header = patch( + 'oidc_provider.authentication.get_authorization_header', + return_value='Bearer ..some-token') + patch_get_aud = patch('oidc_provider.authentication.JSONWebTokenAuthentication.get_audiences', return_value='http://oidc-service.com/resources') + patch_decode_jwt = patch('oidc_provider.authentication.JSONWebTokenAuthentication.decode_jwt', return_value=self.get_token()) + patch_config = patch( + 'oidc_provider.authentication.JSONWebTokenAuthentication.oidc_config', + new_callable=PropertyMock(return_value={ + 'issuer': 'http://oidc-service.com'})) + + patch_get_aud.start() + patch_header.start() + patch_config.start() + patch_decode_jwt.start() + + self.addCleanup(patch_get_aud.stop) + self.addCleanup(patch_header.stop) + self.addCleanup(patch_decode_jwt.stop) + self.addCleanup(patch_config.stop) + + def get_token(self): + return { + 'aud': ['http://oidc-service.com/resources'], + 'iss': 'http://oidc-service.com', + 'nbf': int(time.time()) - 1000, + 'exp': int(time.time()) + 1000, + 'client_id': 'paw-frontend', + 'role': [ + 'service-directory-reader', + 'service-directory-writer'], + 'scope': [ + 'openid', + 'paw-service' + ] + } + + def test_valid_token(self): + auth = JSONWebTokenAuthentication() + user, authenticated = auth.authenticate({}) + self.assertTrue(authenticated) + + @patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer some-token') + def test_bad_header_prefix(self, mock_header): + result = JSONWebTokenAuthentication().authenticate(None) + self.assertEqual(result, None) + + @patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer onle.onedot') + def test_bad_JWT_format(self, mock_header): + result = JSONWebTokenAuthentication().authenticate(None) + self.assertEqual(result, None) + + def test_invalid_header(self): + # Two spaces, must only have one + with patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer bad token'): + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate(None) + # No spaces at all + with patch('oidc_provider.authentication.get_authorization_header', return_value='Bearer'): + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed): + auth.authenticate(None) + + @patch('oidc_provider.authentication.JSONWebTokenAuthentication.get_audiences', return_value='wrong-aud') + def test_validate_claims_audience(self, mock_aud): + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.authenticate(None) + + self.assertIn('Invalid JWT audience', str(error.exception)) + + @patch('oidc_provider.authentication.JSONWebTokenAuthentication.oidc_config', new_callable=PropertyMock(return_value={'issuer': 'wrong'})) + def test_validate_claims_issuer(self, config_patch): + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.authenticate(None) + + self.assertIn('Invalid JWT issuer', str(error.exception)) + + def test_validate_authorized_party_missing(self): + token = self.get_token() + token['aud'] += ['second-audience'] + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('Missing JWT authorized party', str(error.exception)) + + def test_validate_authorized_party_invalid(self): + token = self.get_token() + token['azp'] = 'authorized-party' + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('Invalid JWT authorized party', str(error.exception)) + + def test_expired_token(self): + token = self.get_token() + token['exp'] = token['nbf'] + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('JWT has expired', str(error.exception)) + + def test_not_yet_valid_token(self): + token = self.get_token() + token['nbf'] = token['exp'] + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('JWT not yet valid', str(error.exception)) + + @override_settings(OIDC_AUTH={**settings.OIDC_AUTH, **{'OIDC_LEEWAY': 500}}) + def test_token_too_old(self): + token = self.get_token() + token['iat'] = token['nbf'] + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('JWT too old', str(error.exception)) + + @override_settings(OIDC_AUTH={**settings.OIDC_AUTH, **{'OIDC_LEEWAY': 2500}}) + def test_token_iat_valid(self): + token = self.get_token() + token['iat'] = token['nbf'] + auth = JSONWebTokenAuthentication() + self.assertEqual(auth.validate_claims(token), None) + + @override_settings(OIDC_AUTH={**settings.OIDC_AUTH, **{'OIDC_SCOPE': 'bad-scope'}}) + def test_token_scope(self): + token = self.get_token() + auth = JSONWebTokenAuthentication() + with self.assertRaises(AuthenticationFailed) as error: + auth.validate_claims(token) + + self.assertIn('Invalid JWT scope', str(error.exception)) + + +class FetchServiceTokenTestCase(BaseTestCase): + + @patch('oidc_provider.client.BackendApplicationClient') + @patch('oidc_provider.client.OAuth2Session') + def test_fetch_service_token(self, mock_session, mock_client): + mock_session.return_value.fetch_token.return_value = 'fake-token' + token = fetch_service_token('fake-claims') + self.assertEqual(token, 'fake-token') + mock_session.return_value.fetch_token.assert_called() diff --git a/oidc_provider/utils.py b/oidc_provider/utils.py new file mode 100644 index 00000000..feb34c5f --- /dev/null +++ b/oidc_provider/utils.py @@ -0,0 +1,21 @@ +from django.core.cache import cache as dj_cache + + +class cache: + """ + Cache decorator that memoizes the return value of a method for some time. + This will not be functional for functions returning None + """ + def __init__(self, ttl): + self.ttl = ttl + + def __call__(self, fn): + def wrapped(this, *args): + cached_value = dj_cache.get(str(args)) + if cached_value is None: + cached_value = fn(this, *args) + dj_cache.set(str(args), cached_value, self.ttl) + return cached_value + + return wrapped +