From 2b4817b102970f2b5aca702a2df91c185b615516 Mon Sep 17 00:00:00 2001 From: Clinton Blackburn Date: Sun, 23 Apr 2017 03:07:49 -0400 Subject: [PATCH] Added OpenID Connect discovery endpoint Although we are phasing out our support of OIDC, this particular feature will allow us to eliminate many of the settings we share across services. Instead of reading various endpoints and secret keys from settings or hardcoded values, services with the proper authentication backend can simply read (and cache) the information from this endpoint. ECOM-3629 --- lms/envs/aws.py | 4 +- lms/envs/common.py | 4 +- lms/envs/devstack.py | 13 +- .../oauth_dispatch/tests/test_views.py | 128 ++++++++++++++++-- .../core/djangoapps/oauth_dispatch/urls.py | 4 +- .../core/djangoapps/oauth_dispatch/views.py | 49 ++++++- openedx/core/lib/token_utils.py | 20 ++- requirements/edx/base.txt | 2 + 8 files changed, 191 insertions(+), 33 deletions(-) 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