diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d786a3e87b..526b733eb7 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -848,8 +848,8 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U #### JWT configuration #### JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) -PUBLIC_RSA_KEY = ENV_TOKENS.get('PUBLIC_RSA_KEY', PUBLIC_RSA_KEY) -PRIVATE_RSA_KEY = ENV_TOKENS.get('PRIVATE_RSA_KEY', PRIVATE_RSA_KEY) +JWT_PRIVATE_SIGNING_KEY = ENV_TOKENS.get('JWT_PRIVATE_SIGNING_KEY', JWT_PRIVATE_SIGNING_KEY) +JWT_EXPIRED_PRIVATE_SIGNING_KEYS = ENV_TOKENS.get('JWT_EXPIRED_PRIVATE_SIGNING_KEYS', JWT_EXPIRED_PRIVATE_SIGNING_KEYS) ################# PROCTORING CONFIGURATION ################## diff --git a/lms/envs/common.py b/lms/envs/common.py index b5215f9864..cf4fed8cc5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2975,8 +2975,8 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60 # For help generating a key pair import and run `openedx.core.lib.rsa_key_utils.generate_rsa_key_pair()` -PUBLIC_RSA_KEY = None -PRIVATE_RSA_KEY = None +JWT_PRIVATE_SIGNING_KEY = None +JWT_EXPIRED_PRIVATE_SIGNING_KEYS = [] # Credit notifications settings NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 12806a10d5..783dc9afc9 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -225,18 +225,7 @@ CORS_ORIGIN_WHITELIST = () CORS_ORIGIN_ALLOW_ALL = True # JWT settings for devstack -PUBLIC_RSA_KEY = """\ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApCujf5oZBGK4MafMRGY9 -+zdRRI9YDm1r+81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ -1m9OL3/JpmMyTi/YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxf -eymwWaIrAhALUS5+nDa7dauj8VngsWauMrEA/MWShEzsR53wGKlciEZA1r/AfQ55 -XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKd -xJ/l8O847Q7kg1lvckpLsKyu5167NK9Qj1X/O3SwVBL3cxx1HpQ6+q3SGLZ4ngow -hwIDAQAB ------END PUBLIC KEY-----""" - -PRIVATE_RSA_KEY = """\ +JWT_PRIVATE_SIGNING_KEY = """\ -----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index 5332b0441c..c8cc82ff84 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -3,25 +3,31 @@ Tests for Blocks Views """ import json +import unittest import ddt -from django.conf import settings -from django.test import RequestFactory, TestCase -from django.core.urlresolvers import reverse import httpretty +from Crypto.PublicKey import RSA +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import RequestFactory, TestCase, override_settings from oauth2_provider import models as dot_models from provider import constants -import unittest from student.tests.factories import UserFactory from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle - -from .constants import DUMMY_REDIRECT_URL from . import mixins +from .constants import DUMMY_REDIRECT_URL from .. import adapters from .. import models -if settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"): +# NOTE (CCB): We use this feature flag in a roundabout way to determine if the oauth_dispatch app is installed +# in the current service--LMS or Studio. Normally we would check if settings.ROOT_URLCONF == 'lms.urls'; however, +# simply importing the views will results in an error due to the requisite apps not being installed (in Studio). Thus, +# we are left with this hack, of checking the feature flag which will never be True for Studio. +OAUTH_PROVIDER_ENABLED = settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER') + +if OAUTH_PROVIDER_ENABLED: from .. import views @@ -62,7 +68,7 @@ class AccessTokenLoginMixin(object): self.assertEqual(self.login_with_access_token(access_token=access_token).status_code, 401) -@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled") +@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled') class _DispatchingViewTestCase(TestCase): """ Base class for tests that exercise DispatchingViews. @@ -117,6 +123,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa """ Test class for AccessTokenView """ + def setUp(self): super(TestAccessTokenView, self).setUp() self.url = reverse('access_token') @@ -235,6 +242,7 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut """ Test class for AccessTokenExchangeView """ + def setUp(self): self.url = reverse('exchange_access_token', kwargs={'backend': 'google-oauth2'}) self.view_class = views.AccessTokenExchangeView @@ -385,7 +393,7 @@ class TestAuthorizationView(_DispatchingViewTestCase): return response.redirect_chain[-1][0] -@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled") +@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled') class TestViewDispatch(TestCase): """ Test that the DispatchingView dispatches the right way. @@ -479,6 +487,7 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p """ Test class for RevokeTokenView """ + def setUp(self): self.revoke_token_url = reverse('revoke_token') self.access_token_url = reverse('access_token') @@ -554,3 +563,104 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p Tests invalidation/revoke of user access token for django-oauth-toolkit """ self.verify_revoke_token(self.access_token) + + +@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled') +class JwksViewTests(TestCase): + def test_serialize_rsa_key(self): + key = """\ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx +p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH +02vZdEnWb04vf8mmYzJOL9i7APu0h/rm1pvVI5JFiSjE4pG669m5dAb2dZtesYOd +yfC5bF97KbBZoisCEAtRLn6cNrt1q6PxWeCxZq4ysQD8xZKETOxHnfAYqVyIRkDW +v8B9DnldLjYa8GhuGHL1J5ncHoseJoATLCnAWYo+yy6gdI2Fs9rj0tbeBcnoKwUZ +ENwEUp3En+Xw7zjtDuSDWW9ySkuwrK7nXrs0r1CPVf87dLBUEvdzHHUelDr6rdIY +tnieCjCHAgMBAAECggEBAJvTiAdQPzq4cVlAilTKLz7KTOsknFJlbj+9t5OdZZ9g +wKQIDE2sfEcti5O+Zlcl/eTaff39gN6lYR73gMEQ7h0J3U6cnsy+DzvDkpY94qyC +/ZYqUhPHBcnW3Mm0vNqNj0XGae15yBXjrKgSy9lUknSXJ3qMwQHeNL/DwA2KrfiL +g0iVjk32dvSSHWcBh0M+Qy1WyZU0cf9VWzx+Q1YLj9eUCHteStVubB610XV3JUZt +UTWiUCffpo2okHsTBuKPVXK/5BL+BpGplcxRSlnSbMaI611kN3iKlO8KGISXHBz7 +nOPdkfZC9poEXt5SshtINuGGCCc8hDxpg1otYqCLaYECgYEA1MSCPs3pBkEagchV +g0rxYmDUC8QkeIOBuZFjhkdoUgZ6rFntyRZd1NbCUi3YBbV1YC12ZGohqWUWom1S +AtNbQ2ZTbqEnDKWbNvLBRwkdp/9cKBce85lCCD6+U2o2Ha8C0+hKeLBn8un1y0zY +1AQTqLAz9ItNr0aDPb89cs5voWcCgYEAxYdC8vR3t8iYMUnK6LWYDrKSt7YiorvF +qXIMANcXQrnO0ptC0B56qrUCgKHNrtPi5bGpNBJ0oKMfbmGfwX+ca8sCUlLvq/O8 +S2WZwSJuaHH4lEBi8ErtY++8F4B4l3ENCT84Hyy5jiMpbpkHEnh/1GNcvvmyI8ud +3jzovCNZ4+ECgYEA0r+Oz0zAOzyzV8gqw7Cw5iRJBRqUkXaZQUj8jt4eO9lFG4C8 +IolwCclrk2Drb8Qsbka51X62twZ1ZA/qwve9l0Y88ADaIBHNa6EKxyUFZglvrBoy +w1GT8XzMou06iy52G5YkZeU+IYOSvnvw7hjXrChUXi65lRrAFqJd6GEIe5MCgYA/ +0LxDa9HFsWvh+JoyZoCytuSJr7Eu7AUnAi54kwTzzL3R8tE6Fa7BuesODbg6tD/I +v4YPyaqePzUnXyjSxdyOQq8EU8EUx5Dctv1elTYgTjnmA4szYLGjKM+WtC3Bl4eD +pkYGZFeqYRfAoHXVdNKvlk5fcKIpyF2/b+Qs7CrdYQKBgQCc/t+JxC9OpI+LhQtB +tEtwvklxuaBtoEEKJ76P9vrK1semHQ34M1XyNmvPCXUyKEI38MWtgCCXcdmg5syO +PBXdDINx+wKlW7LPgaiRL0Mi9G2aBpdFNI99CWVgCr88xqgSE24KsOxViMwmi0XB +Ld/IRK0DgpGP5EJRwpKsDYe/UQ== +-----END PRIVATE KEY-----""" + + # pylint: disable=line-too-long + expected = { + 'kty': 'RSA', + 'use': 'sig', + 'alg': 'RS512', + 'n': 'pCujf5oZBGK4MafMRGY9-zdRRI9YDm1r-81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ1m9OL3_JpmMyTi_YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxfeymwWaIrAhALUS5-nDa7dauj8VngsWauMrEA_MWShEzsR53wGKlciEZA1r_AfQ55XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKdxJ_l8O847Q7kg1lvckpLsKyu5167NK9Qj1X_O3SwVBL3cxx1HpQ6-q3SGLZ4ngowhw', + 'e': 'AQAB', + 'kid': '6e80b9d2e5075ae8bb5d1dd762ebc62e' + } + self.assertEqual(views.JwksView.serialize_rsa_key(key), expected) + + def test_get(self): + JWT_PRIVATE_SIGNING_KEY = RSA.generate(2048).exportKey('PEM') + JWT_EXPIRED_PRIVATE_SIGNING_KEYS = [RSA.generate(2048).exportKey('PEM'), RSA.generate(2048).exportKey('PEM')] + secret_keys = [JWT_PRIVATE_SIGNING_KEY] + JWT_EXPIRED_PRIVATE_SIGNING_KEYS + + with override_settings(JWT_PRIVATE_SIGNING_KEY=JWT_PRIVATE_SIGNING_KEY, + JWT_EXPIRED_PRIVATE_SIGNING_KEYS=JWT_EXPIRED_PRIVATE_SIGNING_KEYS): + response = self.client.get(reverse('jwks')) + + self.assertEqual(response.status_code, 200) + actual = json.loads(response.content) + expected = { + 'keys': [views.JwksView.serialize_rsa_key(key) for key in secret_keys], + } + self.assertEqual(actual, expected) + + @override_settings(JWT_PRIVATE_SIGNING_KEY=None, JWT_EXPIRED_PRIVATE_SIGNING_KEYS=[]) + def test_get_without_keys(self): + """ The view should return an empty list if no keys are configured. """ + response = self.client.get(reverse('jwks')) + + self.assertEqual(response.status_code, 200) + actual = json.loads(response.content) + self.assertEqual(actual, {'keys': []}) + + +@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled') +class ProviderInfoViewTests(TestCase): + DOMAIN = 'testserver.fake' + + def build_url(self, path): + return 'http://{domain}{path}'.format(domain=self.DOMAIN, path=path) + + def test_get(self): + issuer = 'test-issuer' + self.client = self.client_class(SERVER_NAME=self.DOMAIN) + + expected = { + 'issuer': issuer, + 'authorization_endpoint': self.build_url(reverse('authorize')), + 'token_endpoint': self.build_url(reverse('access_token')), + 'end_session_endpoint': self.build_url(reverse('logout')), + 'token_endpoint_auth_methods_supported': ['client_secret_post'], + 'access_token_signing_alg_values_supported': ['RS512', 'HS256'], + 'scopes_supported': ['openid', 'profile', 'email'], + 'claims_supported': ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'], + 'jwks_uri': self.build_url(reverse('jwks')), + } + + with override_settings(JWT_AUTH={'JWT_ISSUER': issuer}): + response = self.client.get(reverse('openid-config')) + + self.assertEqual(response.status_code, 200) + actual = json.loads(response.content) + self.assertEqual(actual, expected) diff --git a/openedx/core/djangoapps/oauth_dispatch/urls.py b/openedx/core/djangoapps/oauth_dispatch/urls.py index bbf30d477f..9da7b5125b 100644 --- a/openedx/core/djangoapps/oauth_dispatch/urls.py +++ b/openedx/core/djangoapps/oauth_dispatch/urls.py @@ -13,7 +13,9 @@ urlpatterns = patterns( '', url(r'^authorize/?$', csrf_exempt(views.AuthorizationView.as_view()), name='authorize'), url(r'^access_token/?$', csrf_exempt(views.AccessTokenView.as_view()), name='access_token'), - url(r'^revoke_token/?$', csrf_exempt(views.RevokeTokenView.as_view()), name="revoke_token"), + url(r'^revoke_token/?$', csrf_exempt(views.RevokeTokenView.as_view()), name='revoke_token'), + url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='openid-config'), + url(r'^jwks\.json$', views.JwksView.as_view(), name='jwks') ) if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index c1f351adde..5104a0a602 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -5,15 +5,20 @@ django-oauth-toolkit as appropriate. from __future__ import unicode_literals +import hashlib import json +from Crypto.PublicKey import RSA +from django.conf import settings +from django.core.urlresolvers import reverse +from django.http import JsonResponse from django.views.generic import View from edx_oauth2_provider import views as dop_views # django-oauth2-provider views +from jwkest.jwk import RSAKey from oauth2_provider import models as dot_models, views as dot_views # django-oauth-toolkit from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views from openedx.core.lib.token_utils import JwtBuilder - from . import adapters @@ -132,3 +137,45 @@ class RevokeTokenView(_DispatchingView): Dispatch to the RevokeTokenView of django-oauth-toolkit """ dot_view = dot_views.RevokeTokenView + + +class ProviderInfoView(View): + def get(self, request, *args, **kwargs): + data = { + 'issuer': settings.JWT_AUTH['JWT_ISSUER'], + 'authorization_endpoint': request.build_absolute_uri(reverse('authorize')), + 'token_endpoint': request.build_absolute_uri(reverse('access_token')), + 'end_session_endpoint': request.build_absolute_uri(reverse('logout')), + 'token_endpoint_auth_methods_supported': ['client_secret_post'], + # NOTE (CCB): This is not part of the OpenID Connect standard. It is added here since we + # use JWS for our access tokens. + 'access_token_signing_alg_values_supported': ['RS512', 'HS256'], + 'scopes_supported': ['openid', 'profile', 'email'], + 'claims_supported': ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'], + 'jwks_uri': request.build_absolute_uri(reverse('jwks')), + } + response = JsonResponse(data) + return response + + +class JwksView(View): + @staticmethod + def serialize_rsa_key(key): + kid = hashlib.md5(key.encode('utf-8')).hexdigest() + key = RSAKey(kid=kid, key=RSA.importKey(key), use='sig', alg='RS512') + return key.serialize(private=False) + + def get(self, request, *args, **kwargs): + secret_keys = [] + + if settings.JWT_PRIVATE_SIGNING_KEY: + secret_keys.append(settings.JWT_PRIVATE_SIGNING_KEY) + + # NOTE: We provide the expired keys in case there are unexpired access tokens + # that need to have their signatures verified. + if settings.JWT_EXPIRED_PRIVATE_SIGNING_KEYS: + secret_keys += settings.JWT_EXPIRED_PRIVATE_SIGNING_KEYS + + return JsonResponse({ + 'keys': [self.serialize_rsa_key(key) for key in secret_keys if key], + }) diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index 3238ab6644..edbd52905e 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -1,11 +1,12 @@ """Utilities for working with ID tokens.""" +import json from time import time -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import load_pem_private_key +from Cryptodome.PublicKey import RSA from django.conf import settings from django.utils.functional import cached_property -import jwt +from jwkest.jwk import KEYS, RSAKey +from jwkest.jws import JWS from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from student.models import UserProfile, anonymous_id_for_user @@ -27,6 +28,7 @@ class JwtBuilder(object): asymmetric (Boolean): Whether the JWT should be signed with this app's private key. secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested. """ + def __init__(self, user, asymmetric=False, secret=None): self.user = user self.asymmetric = asymmetric @@ -50,6 +52,7 @@ class JwtBuilder(object): now = int(time()) expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION'] payload = { + # TODO Consider getting rid of this claim since we don't use it. 'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'], 'exp': now + expires_in, 'iat': now, @@ -100,11 +103,16 @@ class JwtBuilder(object): def encode(self, payload): """Encode the provided payload.""" + keys = KEYS() + if self.asymmetric: - secret = load_pem_private_key(settings.PRIVATE_RSA_KEY, None, default_backend()) + keys.add(RSAKey(key=RSA.importKey(settings.JWT_PRIVATE_SIGNING_KEY))) algorithm = 'RS512' else: - secret = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY'] + key = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY'] + keys.add({'key': key, 'kty': 'oct'}) algorithm = self.jwt_auth['JWT_ALGORITHM'] - return jwt.encode(payload, secret, algorithm=algorithm) + data = json.dumps(payload) + jws = JWS(data, alg=algorithm) + return jws.sign_compact(keys=keys) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1be104406c..188b95a86e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -84,6 +84,8 @@ polib==1.0.3 pycrypto>=2.6 pygments==2.0.1 pygraphviz==1.1 +pyjwkest==1.3.2 +# TODO Replace PyJWT usage with pyjwkest PyJWT==1.4.0 pymongo==2.9.1 python-memcached==1.48