From 31acd4eb7f093581b2c6ece84487be4db138f27d Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Tue, 24 Jul 2018 09:14:40 -0400 Subject: [PATCH] Asymmetric JWT support --- edx_rest_framework_extensions/__init__.py | 2 +- edx_rest_framework_extensions/jwt_decoder.py | 119 +++++++++++------- edx_rest_framework_extensions/settings.py | 14 +++ .../tests/test_jwt_decoder.py | 30 +---- setup.py | 1 + test_requirements.txt | 1 + test_settings.py | 10 +- 7 files changed, 96 insertions(+), 81 deletions(-) diff --git a/edx_rest_framework_extensions/__init__.py b/edx_rest_framework_extensions/__init__.py index f3dadf29..d97fa68d 100644 --- a/edx_rest_framework_extensions/__init__.py +++ b/edx_rest_framework_extensions/__init__.py @@ -1,3 +1,3 @@ """ edx Django REST Framework extensions. """ -__version__ = '1.5.3' # pragma: no cover +__version__ = '1.5.4' # pragma: no cover diff --git a/edx_rest_framework_extensions/jwt_decoder.py b/edx_rest_framework_extensions/jwt_decoder.py index 28d27ad5..74e4bc27 100644 --- a/edx_rest_framework_extensions/jwt_decoder.py +++ b/edx_rest_framework_extensions/jwt_decoder.py @@ -1,12 +1,15 @@ """ JWT decoder utility functions. """ import logging +import sys +from jwkest.jwk import KEYS +from jwkest.jws import JWS import jwt from semantic_version import Version from django.conf import settings from rest_framework_jwt.settings import api_settings -from edx_rest_framework_extensions.settings import get_jwt_issuers +from edx_rest_framework_extensions.settings import get_jwt_issuers, get_first_jwt_issuer logger = logging.getLogger(__name__) @@ -26,8 +29,7 @@ def jwt_decode_handler(token): Notes: * Requires "exp" and "iat" claims to be present in the token's payload. - * Supports multiple issuer decoding via settings.JWT_AUTH['JWT_ISSUERS'] (see below) - * Aids debugging by logging DecodeError and InvalidTokenError log entries when decoding fails. + * Aids debugging by logging InvalidTokenError log entries when decoding fails. Examples: Use with `djangorestframework-jwt `_, by changing @@ -40,25 +42,9 @@ def jwt_decode_handler(token): 'JWT_ISSUER': 'https://the.jwt.issuer', 'JWT_SECRET_KEY': 'the-jwt-secret-key', (defaults to settings.SECRET_KEY) 'JWT_AUDIENCE': 'the-jwt-audience', + 'JWT_PUBLIC_SIGNING_JWK_SET': 'the-jwk-set-of-public-signing-keys', } - Enable multi-issuer support by specifying a list of dictionaries as settings.JWT_AUTH['JWT_ISSUERS']: - - .. code-block:: python - - JWT_ISSUERS = [ - { - 'ISSUER': 'test-issuer-1', - 'SECRET_KEY': 'test-secret-key-1', - 'AUDIENCE': 'test-audience-1', - }, - { - 'ISSUER': 'test-issuer-2', - 'SECRET_KEY': 'test-secret-key-2', - 'AUDIENCE': 'test-audience-2', - } - ] - Args: token (str): JWT to be decoded. @@ -69,34 +55,10 @@ def jwt_decode_handler(token): MissingRequiredClaimError: Either the exp or iat claims is missing from the JWT payload. InvalidTokenError: Decoding fails. """ - - options = { - 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION, - 'verify_aud': settings.JWT_AUTH.get('JWT_VERIFY_AUDIENCE', True), - 'require_exp': True, - 'require_iat': True, - } - - for jwt_issuer in get_jwt_issuers(): - try: - decoded_token = jwt.decode( - token, - jwt_issuer['SECRET_KEY'], - api_settings.JWT_VERIFY, - options=options, - leeway=api_settings.JWT_LEEWAY, - audience=jwt_issuer['AUDIENCE'], - issuer=jwt_issuer['ISSUER'], - algorithms=[api_settings.JWT_ALGORITHM] - ) - return _set_token_defaults(decoded_token) - except jwt.InvalidTokenError: - msg = "Token decode failed for issuer '{issuer}'".format(issuer=jwt_issuer['ISSUER']) - logger.info(msg, exc_info=True) - - msg = 'All combinations of JWT issuers and secret keys failed to validate the token.' - logger.error(msg) - raise jwt.InvalidTokenError(msg) + jwt_issuer = get_first_jwt_issuer() + _verify_jwt_signature(token, jwt_issuer) + decoded_token = _decode_and_verify_token(token, jwt_issuer) + return _set_token_defaults(decoded_token) def decode_jwt_scopes(token): @@ -169,3 +131,64 @@ def _set_filters(token, token_version): _set_is_restricted(token, token_version) _set_filters(token, token_version) return token + + +def _verify_jwt_signature(token, jwt_issuer): + key_set = _get_signing_jwk_key_set(jwt_issuer) + + try: + _ = JWS().verify_compact(token, key_set) + except Exception as exc: + logger.exception('Token verification failed.') + exc_info = sys.exc_info() + raise jwt.InvalidTokenError(exc_info[2]) + + +def _decode_and_verify_token(token, jwt_issuer): + options = { + 'require_exp': True, + 'require_iat': True, + + 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION, + 'verify_aud': settings.JWT_AUTH.get('JWT_VERIFY_AUDIENCE', True), + 'verify_iss': False, # TODO (ARCH-204): manually verify until issuer is configured correctly. + 'verify_signature': False, # Verified with JWS already + } + + decoded_token = jwt.decode( + token, + jwt_issuer['SECRET_KEY'], + api_settings.JWT_VERIFY, + options=options, + leeway=api_settings.JWT_LEEWAY, + audience=jwt_issuer['AUDIENCE'], + issuer=jwt_issuer['ISSUER'], + algorithms=[api_settings.JWT_ALGORITHM], + ) + + # TODO (ARCH-204): verify issuer manually until it is properly configured. + token_issuer = decoded_token.get('iss') + issuer_matched = any(issuer['ISSUER'] == token_issuer for issuer in get_jwt_issuers()) + if not issuer_matched: + logger.info('Token decode failed due to mismatched issuer [%s]', token_issuer) + raise jwt.InvalidTokenError('%s is not a valid issuer.', token_issuer) + + return decoded_token + + +def _get_signing_jwk_key_set(jwt_issuer): + """ + Returns a JWK Keyset containing all active keys that are configured + for verifying signatures. + """ + key_set = KEYS() + + # asymmetric keys + signing_jwk_set = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET') + if signing_jwk_set: + key_set.load_jwks(signing_jwk_set) + + # symmetric key + key_set.add({'key': jwt_issuer['SECRET_KEY'], 'kty': 'oct'}) + + return key_set diff --git a/edx_rest_framework_extensions/settings.py b/edx_rest_framework_extensions/settings.py index ce4b11c6..c4c73a43 100644 --- a/edx_rest_framework_extensions/settings.py +++ b/edx_rest_framework_extensions/settings.py @@ -1,4 +1,7 @@ """ +NOTE: Support for multiple JWT_ISSUERS is deprecated. Instead, Asymmetric JWTs +make this simpler by using JWK keysets to list all available public keys. + Settings for edx-drf-extensions are all namespaced in the EDX_DRF_EXTENSIONS setting. For example your project's `settings.py` file might look like this: @@ -81,3 +84,14 @@ def get_jwt_issuers(): return jwt_issuers # If we do not, return the deprecated configuration return _get_deprecated_jwt_issuers() + + +def get_first_jwt_issuer(): + """ + Retrieves the first issuer in the JWT_ISSUERS list. + + As mentioned above, support for multiple JWT_ISSUERS is deprecated. They + are currently used only to distinguish the "ISSUER" field across sites. + So in many cases, we just need the first issuer value. + """ + return get_jwt_issuers()[0] diff --git a/edx_rest_framework_extensions/tests/test_jwt_decoder.py b/edx_rest_framework_extensions/tests/test_jwt_decoder.py index 4b21ae98..9add2f18 100644 --- a/edx_rest_framework_extensions/tests/test_jwt_decoder.py +++ b/edx_rest_framework_extensions/tests/test_jwt_decoder.py @@ -128,15 +128,7 @@ def test_failure_invalid_issuer(self): # which will fail with an InvalidTokenError jwt_decode_handler(token) - # Verify that the proper entries were written to the log file - msg = "Token decode failed for issuer 'test-issuer-1'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "Token decode failed for issuer 'test-issuer-2'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "All combinations of JWT issuers and secret keys failed to validate the token." - patched_log.error.assert_any_call(msg) + patched_log.exception.assert_any_call("Token verification failed.") def test_failure_invalid_token(self): """ @@ -150,15 +142,7 @@ def test_failure_invalid_token(self): # Attempt to decode an invalid token, which will fail with an InvalidTokenError jwt_decode_handler("invalid.token") - # Verify that the proper entries were written to the log file - msg = "Token decode failed for issuer 'test-issuer-1'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "Token decode failed for issuer 'test-issuer-2'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "All combinations of JWT issuers and secret keys failed to validate the token." - patched_log.error.assert_any_call(msg) + patched_log.exception.assert_any_call("Token verification failed.") @override_settings(JWT_AUTH=exclude_from_jwt_auth_setting('JWT_SUPPORTED_VERSION')) def test_supported_jwt_version_not_specified(self): @@ -188,19 +172,9 @@ def test_unsupported_jwt_version(self): token = _generate_jwt_token(self.payload) jwt_decode_handler(token) - # Verify that the proper entries were written to the log file msg = "Token decode failed due to unsupported JWT version number [%s]" patched_log.info.assert_any_call(msg, '1.1.0') - msg = "Token decode failed for issuer 'test-issuer-1'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "Token decode failed for issuer 'test-issuer-2'" - patched_log.info.assert_any_call(msg, exc_info=True) - - msg = "All combinations of JWT issuers and secret keys failed to validate the token." - patched_log.error.assert_any_call(msg) - def test_upgrade(self): """ Verifies the JWT is upgraded when an old (starting) version is provided. diff --git a/setup.py b/setup.py index 020e02b5..a896d332 100755 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ 'django-waffle', 'edx-opaque-keys', 'semantic_version', + 'pyjwkest==1.3.2', 'python-dateutil>=2.0', 'requests>=2.7.0,<3.0.0', 'rest-condition>=1.0.3,<2.0', diff --git a/test_requirements.txt b/test_requirements.txt index c5460f09..1e81f5df 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -8,3 +8,4 @@ mock>=1.3.0,<2.0.0 pep8>=1.7.0,<2.0.0 tox>=2.3.1,<3.0.0 git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc86856441bbf0bd9cb#egg=djangorestframework==3.6.3 +pyjwkest==1.3.2 diff --git a/test_settings.py b/test_settings.py index 157baeff..c3d25358 100644 --- a/test_settings.py +++ b/test_settings.py @@ -51,16 +51,18 @@ 'JWT_VERIFY_EXPIRATION': True, # JWT_ISSUERS enables token decoding for multiple issuers (Note: This is not a native DRF-JWT field) + # We use it to allow different values for the 'ISSUER' field, but keep the same SECRET_KEY and + # AUDIENCE values across all issuers. 'JWT_ISSUERS': [ { 'ISSUER': 'test-issuer-1', - 'SECRET_KEY': 'test-secret-key-1', - 'AUDIENCE': 'test-audience-1', + 'SECRET_KEY': 'test-secret-key', + 'AUDIENCE': 'test-audience', }, { 'ISSUER': 'test-issuer-2', - 'SECRET_KEY': 'test-secret-key-2', - 'AUDIENCE': 'test-audience-2', + 'SECRET_KEY': 'test-secret-key', + 'AUDIENCE': 'test-audience', } ], }