From 02ba5fb0e868817d2e8e8a787c7fcf1647832d83 Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Thu, 20 Sep 2018 15:00:09 -0400 Subject: [PATCH] Login service support for JWT Cookies --- .../certificates/apis/v0/tests/test_views.py | 13 +- lms/envs/common.py | 14 +- lms/envs/test.py | 15 +- .../core/djangoapps/auth_exchange/views.py | 24 +-- .../djangoapps/oauth_dispatch/adapters/dop.py | 16 +- .../djangoapps/oauth_dispatch/adapters/dot.py | 21 +- openedx/core/djangoapps/oauth_dispatch/api.py | 92 ++++++++ openedx/core/djangoapps/oauth_dispatch/jwt.py | 204 ++++++++++++++++++ .../djangoapps/oauth_dispatch/tests/mixins.py | 14 +- .../oauth_dispatch/tests/test_api.py | 103 +++++++++ .../oauth_dispatch/tests/test_dop_adapter.py | 4 +- .../oauth_dispatch/tests/test_dot_adapter.py | 12 +- .../oauth_dispatch/tests/test_jwt.py | 93 ++++++++ .../oauth_dispatch/tests/test_views.py | 39 ++-- .../core/djangoapps/oauth_dispatch/views.py | 61 +----- openedx/core/djangoapps/user_authn/cookies.py | 134 ++++++++++-- .../user_authn/tests/test_cookies.py | 102 +++++++-- .../core/djangoapps/user_authn/tests/utils.py | 17 ++ .../core/djangoapps/user_authn/urls_common.py | 1 + .../core/djangoapps/user_authn/views/login.py | 14 +- .../user_authn/views/tests/test_login.py | 16 ++ .../tests/test_login_registration_forms.py | 5 +- .../core/djangoapps/waffle_utils/__init__.py | 8 + openedx/core/lib/tests/test_token_utils.py | 40 ++-- openedx/core/lib/token_utils.py | 141 +++--------- openedx/features/enterprise_support/api.py | 7 +- .../enterprise_support/tests/test_api.py | 5 +- 27 files changed, 884 insertions(+), 331 deletions(-) create mode 100644 openedx/core/djangoapps/oauth_dispatch/jwt.py create mode 100644 openedx/core/djangoapps/oauth_dispatch/tests/test_api.py create mode 100644 openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py create mode 100644 openedx/core/djangoapps/user_authn/tests/utils.py diff --git a/lms/djangoapps/certificates/apis/v0/tests/test_views.py b/lms/djangoapps/certificates/apis/v0/tests/test_views.py index 090dc48af3..b474c6143f 100644 --- a/lms/djangoapps/certificates/apis/v0/tests/test_views.py +++ b/lms/djangoapps/certificates/apis/v0/tests/test_views.py @@ -19,8 +19,8 @@ from course_modes.models import CourseMode from lms.djangoapps.certificates.apis.v0.views import CertificatesDetailView from lms.djangoapps.certificates.models import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from openedx.core.djangoapps.oauth_dispatch.jwt import _create_jwt from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES -from openedx.core.lib.token_utils import JwtBuilder from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -131,12 +131,11 @@ class CertificatesRestApiTest(SharedModuleStoreTestCase, APITestCase): if scopes is None: scopes = CertificatesDetailView.required_scopes - return JwtBuilder(user).build_token( - scopes, - additional_claims=dict( - is_restricted=(auth_type == AuthType.jwt_restricted), - filters=filters, - ), + return _create_jwt( + user, + scopes=scopes, + is_restricted=(auth_type == AuthType.jwt_restricted), + filters=filters, ) def _get_response(self, requesting_user, auth_type, url=None, token=None): diff --git a/lms/envs/common.py b/lms/envs/common.py index 33f026c16e..fb1b05245b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -517,6 +517,10 @@ OAUTH2_PROVIDER = { # otherwise it fails saying this attribute is not present in Settings OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' +# Automatically clean up edx-django-oauth2-provider tokens on use +OAUTH_DELETE_EXPIRED = True +OAUTH_ID_TOKEN_EXPIRATION = 60 * 60 + ################################## TEMPLATE CONFIGURATION ##################################### # Mako templating import tempfile @@ -2933,10 +2937,6 @@ DEFAULT_MOBILE_AVAILABLE = True # Enrollment API Cache Timeout ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = 60 -# Automatically clean up edx-django-oauth2-provider tokens on use -OAUTH_DELETE_EXPIRED = True -OAUTH_ID_TOKEN_EXPIRATION = 60 * 60 - # These tabs are currently disabled NOTES_DISABLED_TABS = ['course_structure', 'tags'] @@ -3161,8 +3161,12 @@ JWT_AUTH = { 'JWT_LEEWAY': 1, 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler', - # Number of seconds before JWT tokens expire + # Number of seconds before JWTs expire 'JWT_EXPIRATION': 30, + 'JWT_COOKIE_EXPIRATION': 60 * 60, + + 'JWT_LOGIN_CLIENT_ID': 'login-service-client-id', + 'JWT_SUPPORTED_VERSION': '1.1.0', 'JWT_ALGORITHM': 'HS256', diff --git a/lms/envs/test.py b/lms/envs/test.py index 7266121d2a..d2afa71876 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -580,14 +580,10 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict( JWT_AUTH.update({ 'JWT_PUBLIC_SIGNING_JWK_SET': ( - '{"keys": [{"kid": "TEST_KEY", "e": "AQAB", "kty": "RSA", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu' - '48BUI5VcVtUWIPqzRK_LDSlZYh9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC' - '4Ee9qG5T38LFe8_oAuFCEntimWxN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzG' - 'y5ZLcTUomo4rZLjghVpq6KZxfS6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw"}, {"kid": "BTZ9HA6K", "e": "A' - 'QAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQ' - 'n6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3K' - 'EUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_' - 'qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}' + '{"keys": [{"kid": "BTZ9HA6K", "e": "AQAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6' + 'sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc' + '4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEu' + 'lLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}' ), 'JWT_PRIVATE_SIGNING_JWK': ( '{"e": "AQAB", "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_Xv' @@ -599,9 +595,10 @@ JWT_AUTH.update({ 'q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Lo' 'z3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy' '1KB6fCby0C9WE", "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfH' - 'ayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", "kid": "TEST_KEY", "kty"' + 'ayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", "kid": "BTZ9HA6K", "kty"' ': "RSA"}' ), + 'JWT_LOGIN_CLIENT_ID': 'test-login-service-client-id', }) ####################### Plugin Settings ########################## diff --git a/openedx/core/djangoapps/auth_exchange/views.py b/openedx/core/djangoapps/auth_exchange/views.py index b2b3fcb1be..63634ad2e2 100644 --- a/openedx/core/djangoapps/auth_exchange/views.py +++ b/openedx/core/djangoapps/auth_exchange/views.py @@ -16,7 +16,6 @@ from django.contrib.auth import login from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from edx_oauth2_provider.constants import SCOPE_VALUE_DICT from oauth2_provider import models as dot_models from oauth2_provider.settings import oauth2_settings from oauth2_provider.views.base import TokenView as DOTAccessTokenView @@ -30,6 +29,7 @@ from rest_framework.views import APIView from openedx.core.djangoapps.auth_exchange.forms import AccessTokenExchangeForm from openedx.core.djangoapps.oauth_dispatch import adapters +from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser @@ -111,13 +111,7 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView): """ Create and return a new access token. """ - _days = 24 * 60 * 60 - token_generator = BearerToken( - expires_in=settings.OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS * _days, - request_validator=oauth2_settings.OAUTH2_VALIDATOR_CLASS(), - ) - self._populate_create_access_token_request(request, user, scope, client) - return token_generator.create_token(request, refresh_token=True) + return create_dot_access_token(request, user, client, scope=scope) def access_token_response(self, token): """ @@ -125,20 +119,6 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView): """ return Response(data=token) - def _populate_create_access_token_request(self, request, user, scope, client): - """ - django-oauth-toolkit expects certain non-standard attributes to - be present on the request object. This function modifies the - request object to match these expectations - """ - request.user = user - request.scopes = [SCOPE_VALUE_DICT[scope]] - request.client = client - request.state = None - request.refresh_token = None - request.extra_credentials = None - request.grant_type = client.authorization_grant_type - def error_response(self, form_errors, **kwargs): """ Return an error response consisting of the errors in the form diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py index 8223f7610a..ec8afa11f5 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py @@ -57,6 +57,18 @@ class DOPAdapter(object): """ return models.AccessToken.objects.get(token=token_string) + def create_access_token_for_test(self, token_string, client, user, expires): + """ + Returns a new AccessToken object created from the given arguments. + This method is currently used only by tests. + """ + return models.AccessToken.objects.create( + token=token_string, + client=client, + user=user, + expires=expires, + ) + def normalize_scopes(self, scopes): """ Given a list of scopes, return a space-separated list of those scopes. @@ -69,13 +81,13 @@ class DOPAdapter(object): """ return scope.to_names(token.scope) - def is_client_restricted(self, client_id): # pylint: disable=unused-argument + def is_client_restricted(self, client): # pylint: disable=unused-argument """ Returns true if the client is set up as a RestrictedApplication. """ return False - def get_authorization_filters(self, client_id): # pylint: disable=unused-argument + def get_authorization_filters(self, client): # pylint: disable=unused-argument """ Get the authorization filters for the given client application. """ diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py index 2408731edd..18ab3f4491 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py @@ -67,6 +67,18 @@ class DOTAdapter(object): """ return models.AccessToken.objects.get(token=token_string) + def create_access_token_for_test(self, token_string, client, user, expires): + """ + Returns a new AccessToken object created from the given arguments. + This method is currently used only by tests. + """ + return models.AccessToken.objects.create( + token=token_string, + application=client, + user=user, + expires=expires, + ) + def normalize_scopes(self, scopes): """ Given a list of scopes, return a space-separated list of those scopes. @@ -81,18 +93,17 @@ class DOTAdapter(object): """ return list(token.scopes) - def is_client_restricted(self, client_id): + def is_client_restricted(self, client): """ Returns true if the client is set up as a RestrictedApplication. """ - application = self.get_client(client_id=client_id) - return RestrictedApplication.objects.filter(application=application).exists() + return RestrictedApplication.objects.filter(application=client).exists() - def get_authorization_filters(self, client_id): + def get_authorization_filters(self, client): """ Get the authorization filters for the given client application. """ - application = self.get_client(client_id=client_id) + application = client filters = [org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()] # Allow applications configured with the client credentials grant type to access diff --git a/openedx/core/djangoapps/oauth_dispatch/api.py b/openedx/core/djangoapps/oauth_dispatch/api.py index 33c635a304..e1603a5ba2 100644 --- a/openedx/core/djangoapps/oauth_dispatch/api.py +++ b/openedx/core/djangoapps/oauth_dispatch/api.py @@ -1,6 +1,14 @@ """ OAuth related Python apis. """ +import json +from django.conf import settings + +from edx_oauth2_provider.constants import SCOPE_VALUE_DICT +from oauthlib.oauth2.rfc6749.errors import OAuth2Error +from oauthlib.oauth2.rfc6749.tokens import BearerToken from oauth2_provider.models import AccessToken as dot_access_token from oauth2_provider.models import RefreshToken as dot_refresh_token +from oauth2_provider.oauth2_backends import get_oauthlib_core +from oauth2_provider.settings import oauth2_settings as dot_settings from provider.oauth2.models import AccessToken as dop_access_token from provider.oauth2.models import RefreshToken as dop_refresh_token @@ -13,3 +21,87 @@ def destroy_oauth_tokens(user): dop_refresh_token.objects.filter(user=user.id).delete() dot_access_token.objects.filter(user=user.id).delete() dot_refresh_token.objects.filter(user=user.id).delete() + + +def create_dot_access_token(request, user, client, expires_in=None, scope=None): + """ + Create and return a new (persisted) access token, including a refresh token. + The token is returned in the form of a Dict: + { + u'access_token': u'some string', + u'refresh_token': u'another string', + u'token_type': u'Bearer', + u'expires_in': 36000, + u'scope': u'default', + }, + """ + # TODO (ARCH-204) the 'scope' argument may not really be needed by callers. + + expires_in = _get_expires_in_value(expires_in) + token_generator = BearerToken( + expires_in=expires_in, + request_validator=dot_settings.OAUTH2_VALIDATOR_CLASS(), + ) + _populate_create_access_token_request(request, user, client, scope) + return token_generator.create_token(request, refresh_token=True) + + +def refresh_dot_access_token(request, client_id, refresh_token, expires_in=None): + """ + Create and return a new (persisted) access token, given a previously created + refresh_token, possibly returned from create_dot_access_token above. + """ + auth_core = get_oauthlib_core() + expires_in = _get_expires_in_value(expires_in) + _populate_refresh_token_request(request, client_id, refresh_token) + + # Note: Unlike create_dot_access_token, we use the top-level auth library + # code for creating the token since we want to enforce registered validations + # (valid refresh token, valid client, etc), rather than create the token + # ourselves directly. + _, _, body, status = auth_core.create_token_response(request) # returns uri, headers, body, status + + if status != 200: + raise OAuth2Error(body) + return json.loads(body) + + +def _get_expires_in_value(expires_in): + """ + Returns the expires_in value to use for the token. + """ + # TODO (ARCH-246) Fix expiration configuration as this does not actually + # override the token's expiration. Rather, DOT's save_bearer_token method + # will always use dot_settings.ACCESS_TOKEN_EXPIRE_SECONDS. + if not expires_in: + seconds_in_a_day = 24 * 60 * 60 + expires_in = settings.OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS * seconds_in_a_day + return expires_in + + +def _populate_create_access_token_request(request, user, client, scope=None): + """ + django-oauth-toolkit expects certain non-standard attributes to + be present on the request object. This function modifies the + request object to match these expectations + """ + if scope is None: + scope = 0 + request.user = user + request.scopes = [SCOPE_VALUE_DICT[scope]] + request.client = client + request.state = None + request.refresh_token = None + request.extra_credentials = None + request.grant_type = client.authorization_grant_type + + +def _populate_refresh_token_request(request, client_id, refresh_token): + """ + django-oauth-toolkit expects parameters passed through the request's POST. + """ + request.POST = dict( + client_id=client_id, + refresh_token=refresh_token, + grant_type='refresh_token', + ) diff --git a/openedx/core/djangoapps/oauth_dispatch/jwt.py b/openedx/core/djangoapps/oauth_dispatch/jwt.py new file mode 100644 index 0000000000..fdb813738c --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/jwt.py @@ -0,0 +1,204 @@ +"""Utilities for working with ID tokens.""" +import json +from time import time + +from django.conf import settings +from jwkest import jwk +from jwkest.jws import JWS + +from edx_django_utils.monitoring import set_custom_metric +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES +from student.models import UserProfile, anonymous_id_for_user + + +def create_jwt_for_user(user, secret=None, aud=None, additional_claims=None): + """ + Returns a JWT to identify the given user. + + TODO (ARCH-204) Note the returned JWT does not have an underlying access + token associated with it and so cannot be invalidated nor refreshed. This + interface should be revisited when addressing authentication-related cleanup + as part of ARCH-204. + + Arguments: + user (User): User for which to generate the JWT. + + Deprecated Arguments (to be removed): + secret (string): Overrides configured JWT secret (signing) key. + aud (string): Optional. Overrides configured JWT audience claim. + additional_claims (dict): Optional. Additional claims to include in the token. + """ + expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION + return _create_jwt( + user, + expires_in=expires_in, + aud=aud, + additional_claims=additional_claims, + secret=secret, + use_asymmetric_key=False, + ) + + +def create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=None): + """ + Returns a JWT created from the given access token. + + Arguments: + token_dict (dict): An access token structure as returned from an + underlying OAuth provider. + + Deprecated Arguments (to be removed): + oauth_adapter (DOPAdapter|DOTAdapter): An OAuth adapter that will + provide the given token's information. + use_asymmetric_key (Boolean): Optional. Whether the JWT should be signed + with this app's private key. If not provided, defaults to whether + ENFORCE_JWT_SCOPES is enabled and the OAuth client is restricted. + """ + access_token = oauth_adapter.get_access_token(token_dict['access_token']) + client = oauth_adapter.get_client_for_token(access_token) + + # TODO (ARCH-204) put access_token as a JWT ID claim (jti) + return _create_jwt( + access_token.user, + scopes=token_dict['scope'].split(' '), + expires_in=token_dict['expires_in'], + use_asymmetric_key=use_asymmetric_key, + is_restricted=oauth_adapter.is_client_restricted(client), + filters=oauth_adapter.get_authorization_filters(client), + ) + + +def _create_jwt( + user, + scopes=None, + expires_in=None, + is_restricted=False, + filters=None, + aud=None, + additional_claims=None, + use_asymmetric_key=None, + secret=None, +): + """ + Returns an encoded JWT (string). + + Arguments: + user (User): User for which to generate the JWT. + scopes (list): Optional. Scopes that limit access to the token bearer and + controls which optional claims are included in the token. + Defaults to ['email', 'profile']. + expires_in (int): Optional. Overrides time to token expiry, specified in seconds. + filters (list): Optional. Filters to include in the JWT. + is_restricted (Boolean): Whether the client to whom the JWT is issued is restricted. + + Deprecated Arguments (to be removed): + aud (string): Optional. Overrides configured JWT audience claim. + additional_claims (dict): Optional. Additional claims to include in the token. + use_asymmetric_key (Boolean): Optional. Whether the JWT should be signed + with this app's private key. If not provided, defaults to whether + ENFORCE_JWT_SCOPES is enabled and the OAuth client is restricted. + secret (string): Overrides configured JWT secret (signing) key. + """ + use_asymmetric_key = _get_use_asymmetric_key_value(is_restricted, use_asymmetric_key) + scopes = scopes or ['email', 'profile'] + iat, exp = _compute_time_fields(expires_in) + + payload = { + # TODO (ARCH-204) Consider getting rid of the 'aud' claim since we don't use it. + 'aud': aud if aud else settings.JWT_AUTH['JWT_AUDIENCE'], + 'exp': exp, + 'iat': iat, + 'iss': settings.JWT_AUTH['JWT_ISSUER'], + 'preferred_username': user.username, + 'scopes': scopes, + 'version': settings.JWT_AUTH['JWT_SUPPORTED_VERSION'], + 'sub': anonymous_id_for_user(user, None), + 'filters': filters or [], + 'is_restricted': is_restricted, + } + payload.update(additional_claims or {}) + _update_from_additional_handlers(payload, user, scopes) + return _encode_and_sign(payload, use_asymmetric_key, secret) + + +def _get_use_asymmetric_key_value(is_restricted, use_asymmetric_key): + """ + Returns the value to use for use_asymmetric_key. + """ + # TODO: (ARCH-162) + # If JWT scope enforcement is enabled, we need to sign tokens + # given to restricted applications with a key that + # other IDAs do not have access to. This prevents restricted + # applications from getting access to API endpoints available + # on other IDAs which have not yet been protected with the + # scope-related DRF permission classes. Once all endpoints have + # been protected, we can enable all IDAs to use the same new + # (asymmetric) key. + if use_asymmetric_key is None: + use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_restricted + return use_asymmetric_key + + +def _compute_time_fields(expires_in): + """ + Returns (iat, exp) tuple to be used as time-related values in a token. + """ + now = int(time()) + expires_in = expires_in or settings.JWT_AUTH['JWT_EXPIRATION'] + set_custom_metric('jwt_expires_in', expires_in) + return now, now + expires_in + + +def _update_from_additional_handlers(payload, user, scopes): + """ + Updates the given token payload with data from additional handlers, as + requested by the given scopes. + """ + _claim_handlers = { + 'email': _attach_email_claim, + 'profile': _attach_profile_claim + } + for scope in scopes: + handler = _claim_handlers.get(scope) + if handler: + handler(payload, user) + + +def _attach_email_claim(payload, user): + """Add the email claim details to the JWT payload.""" + payload['email'] = user.email + + +def _attach_profile_claim(payload, user): + """Add the profile claim details to the JWT payload.""" + try: + # Some users (e.g., service users) may not have user profiles. + name = UserProfile.objects.get(user=user).name + except UserProfile.DoesNotExist: + name = None + + payload.update({ + 'name': name, + 'family_name': user.last_name, + 'given_name': user.first_name, + 'administrator': user.is_staff, + }) + + +def _encode_and_sign(payload, use_asymmetric_key, secret): + """Encode and sign the provided payload.""" + set_custom_metric('jwt_is_asymmetric', use_asymmetric_key) + keys = jwk.KEYS() + + if use_asymmetric_key: + serialized_keypair = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK']) + keys.add(serialized_keypair) + algorithm = settings.JWT_AUTH['JWT_SIGNING_ALGORITHM'] + else: + key = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY'] + keys.add({'key': key, 'kty': 'oct'}) + algorithm = settings.JWT_AUTH['JWT_ALGORITHM'] + + data = json.dumps(payload) + jws = JWS(data, alg=algorithm) + return jws.sign_compact(keys=keys) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py index e8dddd7ae6..63675c7ed8 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py @@ -15,24 +15,16 @@ class AccessTokenMixin(object): """ Mixin for tests dealing with OAuth 2 access tokens. """ def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None, - should_be_asymmetric_key=False, should_be_restricted=None): + should_be_asymmetric_key=False, should_be_restricted=None, aud=None, secret=None): """ Verify the specified JWT access token is valid, and belongs to the specified user. - - Args: - access_token (str): JWT - user (User): User whose information is contained in the JWT payload. - (optional) should_be_expired: indicates if the passed in JWT token is expected to be expired - (optional) should_be_asymmetric_key: indicates if the JWT token should be signed with an - asymmetric key. - Returns: dict: Decoded JWT payload """ scopes = scopes or [] - audience = settings.JWT_AUTH['JWT_AUDIENCE'] + audience = aud or settings.JWT_AUTH['JWT_AUDIENCE'] + secret_key = secret or settings.JWT_AUTH['JWT_SECRET_KEY'] issuer = settings.JWT_AUTH['JWT_ISSUER'] - secret_key = settings.JWT_AUTH['JWT_SECRET_KEY'] def _decode_jwt(verify_expiration): """ diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py new file mode 100644 index 0000000000..247bc5fa6f --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py @@ -0,0 +1,103 @@ +""" Tests for OAuth Dispatch python API module. """ +import unittest +from django.conf import settings +from django.http import HttpRequest +from django.test import TestCase + +from oauth2_provider.models import AccessToken +from student.tests.factories import UserFactory + + +OAUTH_PROVIDER_ENABLED = settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER') +if OAUTH_PROVIDER_ENABLED: + from openedx.core.djangoapps.oauth_dispatch import api + from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter + from openedx.core.djangoapps.oauth_dispatch.tests.constants import DUMMY_REDIRECT_URL + +EXPECTED_DEFAULT_EXPIRES_IN = 36000 + + +@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled') +class TestOAuthDispatchAPI(TestCase): + """ Tests for oauth_dispatch's api.py module. """ + def setUp(self): + super(TestOAuthDispatchAPI, self).setUp() + self.adapter = DOTAdapter() + self.user = UserFactory() + self.client = self.adapter.create_public_client( + name='public app', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='public-client-id', + ) + self.request = HttpRequest() + + def _assert_stored_token(self, stored_token_value, expected_token_user, expected_client): + stored_access_token = AccessToken.objects.get(token=stored_token_value) + self.assertEqual(stored_access_token.user.id, expected_token_user.id) + self.assertEqual(stored_access_token.application.client_id, expected_client.client_id) + self.assertEqual(stored_access_token.application.user.id, expected_client.user.id) + + def test_create_token_success(self): + token = api.create_dot_access_token(self.request, self.user, self.client) + self.assertTrue(token['access_token']) + self.assertTrue(token['refresh_token']) + self.assertDictContainsSubset( + { + u'token_type': u'Bearer', + u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN, + u'scope': u'default', + }, + token, + ) + self._assert_stored_token(token['access_token'], self.user, self.client) + + def test_create_token_another_user(self): + another_user = UserFactory() + token = api.create_dot_access_token(self.request, another_user, self.client) + self._assert_stored_token(token['access_token'], another_user, self.client) + + def test_create_token_overrides(self): + expires_in = 4800 + token = api.create_dot_access_token(self.request, self.user, self.client, expires_in=expires_in, scope=2) + self.assertDictContainsSubset({u'scope': u'profile'}, token) + with self.assertRaises(AssertionError): # TODO (ARCH-246) expiration override does not actually work + self.assertDictContainsSubset({u'expires_in': expires_in}, token) + self.assertDictContainsSubset({u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN}, token) + + def test_refresh_token_success(self): + old_token = api.create_dot_access_token(self.request, self.user, self.client) + new_token = api.refresh_dot_access_token(self.request, self.client.client_id, old_token['refresh_token']) + self.assertDictContainsSubset( + { + u'token_type': u'Bearer', + u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN, + u'scope': u'default', + }, + new_token, + ) + + # verify new tokens are generated + self.assertNotEqual(old_token['access_token'], new_token['access_token']) + self.assertNotEqual(old_token['refresh_token'], new_token['refresh_token']) + + # verify old token is replaced by the new token + with self.assertRaises(AccessToken.DoesNotExist): + self._assert_stored_token(old_token['access_token'], self.user, self.client) + self._assert_stored_token(new_token['access_token'], self.user, self.client) + + def test_refresh_token_invalid_client(self): + token = api.create_dot_access_token(self.request, self.user, self.client) + with self.assertRaises(api.OAuth2Error) as error: + api.refresh_dot_access_token( + self.request, 'invalid_client_id', token['refresh_token'], + ) + self.assertIn('invalid_client', error.exception.description) + + def test_refresh_token_invalid_token(self): + api.create_dot_access_token(self.request, self.user, self.client) + with self.assertRaises(api.OAuth2Error) as error: + api.refresh_dot_access_token( + self.request, self.client.client_id, 'invalid_refresh_token', + ) + self.assertIn('invalid_grant', error.exception.description) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_dop_adapter.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_dop_adapter.py index 6dfe9ac9d4..a4656478a7 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_dop_adapter.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_dop_adapter.py @@ -68,8 +68,8 @@ class DOPAdapterTestCase(TestCase): self.assertEqual(self.adapter.get_client_for_token(token), self.public_client) def test_get_access_token(self): - token = models.AccessToken.objects.create( - token='token-id', + token = self.adapter.create_access_token_for_test( + 'token-id', client=self.public_client, user=self.user, expires=now() + timedelta(days=30), diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_adapter.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_adapter.py index 4c6954e1f1..fb670c7a13 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_adapter.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_adapter.py @@ -93,9 +93,9 @@ class DOTAdapterTestCase(TestCase): self.assertEqual(self.adapter.get_client_for_token(token), self.public_client) def test_get_access_token(self): - token = models.AccessToken.objects.create( - token='token-id', - application=self.public_client, + token = self.adapter.create_access_token_for_test( + 'token-id', + client=self.public_client, user=self.user, expires=now() + timedelta(days=30), ) @@ -106,9 +106,9 @@ class DOTAdapterTestCase(TestCase): Make sure when generating an access_token for a restricted client that the token is immediately expired """ - models.AccessToken.objects.create( - token='expired-token-id', - application=self.restricted_client, + self.adapter.create_access_token_for_test( + 'expired-token-id', + client=self.restricted_client, user=self.user, expires=now() + timedelta(days=30), ) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py new file mode 100644 index 0000000000..08cb99db31 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py @@ -0,0 +1,93 @@ +""" Tests for OAuth Dispatch's jwt module. """ +import itertools +from datetime import timedelta + +import ddt +from django.test import TestCase +from django.utils.timezone import now + +from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api +from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter, DOPAdapter +from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication +from openedx.core.djangoapps.oauth_dispatch.tests.mixins import AccessTokenMixin +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES +from student.tests.factories import UserFactory + + +@ddt.ddt +class TestCreateJWTs(AccessTokenMixin, TestCase): + """ Tests for oauth_dispatch's jwt creation functionality. """ + def setUp(self): + super(TestCreateJWTs, self).setUp() + self.user = UserFactory() + self.default_scopes = ['email', 'profile'] + + def _create_client(self, oauth_adapter, client_restricted): + """ + Creates and returns an OAuth client using the given oauth_adapter. + Configures the client as a RestrictedApplication if client_restricted is + True. + """ + client = oauth_adapter.create_public_client( + name='public app', + user=self.user, + redirect_uri='', + client_id='public-client-id', + ) + if client_restricted: + RestrictedApplication.objects.create(application=client) + return client + + def _create_jwt_for_token( + self, oauth_adapter, use_asymmetric_key, client_restricted=False, + ): + """ Creates and returns the jwt returned by jwt_api.create_jwt_from_token. """ + client = self._create_client(oauth_adapter, client_restricted) + expires_in = 60 * 60 + expires = now() + timedelta(seconds=expires_in) + token_dict = dict( + access_token=oauth_adapter.create_access_token_for_test('token', client, self.user, expires), + expires_in=expires_in, + scope=' '.join(self.default_scopes) + ) + return jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=use_asymmetric_key) + + def _assert_jwt_is_valid(self, jwt_token, should_be_asymmetric_key): + """ Asserts the given jwt_token is valid and meets expectations. """ + self.assert_valid_jwt_access_token( + jwt_token, self.user, self.default_scopes, should_be_asymmetric_key=should_be_asymmetric_key, + ) + + @ddt.data(DOPAdapter, DOPAdapter) + def test_create_jwt_for_token(self, oauth_adapter_cls): + oauth_adapter = oauth_adapter_cls() + jwt_token = self._create_jwt_for_token(oauth_adapter, use_asymmetric_key=False) + self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=False) + + def test_dot_create_jwt_for_token_with_asymmetric(self): + jwt_token = self._create_jwt_for_token(DOTAdapter(), use_asymmetric_key=True) + self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=True) + + @ddt.data(*itertools.product( + (True, False), + (True, False), + )) + @ddt.unpack + def test_dot_create_jwt_for_token(self, scopes_enforced, client_restricted): + with ENFORCE_JWT_SCOPES.override(scopes_enforced): + jwt_token = self._create_jwt_for_token( + DOTAdapter(), + use_asymmetric_key=None, + client_restricted=client_restricted, + ) + self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=scopes_enforced and client_restricted) + + def test_create_jwt_for_user(self): + aud = 'test_aud' + secret = 'test_secret' + additional_claims = {'claim1_key': 'claim1_val'} + jwt_token = jwt_api.create_jwt_for_user(self.user, secret=secret, aud=aud, additional_claims=additional_claims) + token_payload = self.assert_valid_jwt_access_token( + jwt_token, self.user, self.default_scopes, aud=aud, secret=secret, + ) + self.assertDictContainsSubset(additional_claims, token_payload) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index a13adabb18..3305cc67a8 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -271,30 +271,21 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa (i.e. expiry set to Jan 1, 1970) """ with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled): + response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt') + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) - public_jwk_set, private_jwk = self._generate_key_pair() - jwt_auth_settings = settings.JWT_AUTH - jwt_auth_settings.update({ - 'JWT_PRIVATE_SIGNING_JWK': private_jwk, - 'JWT_PUBLIC_SIGNING_JWK_SET': public_jwk_set, - }) - with override_settings(JWT_AUTH=jwt_auth_settings): - - response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - - self.assertIn('expires_in', data) - self.assertEqual(data['expires_in'] < 0, expiration_expected) - self.assertEqual(data['token_type'], 'JWT') - self.assert_valid_jwt_access_token( - data['access_token'], - self.user, - data['scope'].split(' '), - should_be_expired=expiration_expected, - should_be_asymmetric_key=enforce_jwt_scopes_enabled, - should_be_restricted=True, - ) + self.assertIn('expires_in', data) + self.assertEqual(data['expires_in'] < 0, expiration_expected) + self.assertEqual(data['token_type'], 'JWT') + self.assert_valid_jwt_access_token( + data['access_token'], + self.user, + data['scope'].split(' '), + should_be_expired=expiration_expected, + should_be_asymmetric_key=enforce_jwt_scopes_enabled, + should_be_restricted=True, + ) def test_restricted_access_token(self): """ @@ -349,7 +340,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa organization=OrganizationFactory() ) scopes = dot_app_access.scopes - filters = self.dot_adapter.get_authorization_filters(dot_app.client_id) + filters = self.dot_adapter.get_authorization_filters(dot_app) response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes) self.assertEqual(response.status_code, 200) data = json.loads(response.content) diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 132479c010..cda1deb66a 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -17,11 +17,9 @@ from ratelimit import ALL from ratelimit.mixins import RatelimitMixin from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views -from openedx.core.lib.token_utils import JwtBuilder - -from . import adapters -from .dot_overrides import views as dot_overrides_views -from .toggles import ENFORCE_JWT_SCOPES +from openedx.core.djangoapps.oauth_dispatch import adapters +from openedx.core.djangoapps.oauth_dispatch.dot_overrides import views as dot_overrides_views +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token class _DispatchingView(View): @@ -112,54 +110,13 @@ class AccessTokenView(RatelimitMixin, _DispatchingView): def _build_jwt_response_from_access_token_response(self, request, response): """ Builds the content of the response, including the JWT token. """ - client_id = self._get_client_id(request) - adapter = self.get_adapter(request) - is_client_restricted = adapter.is_client_restricted(client_id) - - expires_in, scope, user = self._parse_access_token_response(adapter, response) - jwt_builder = self._get_jwt_builder(user, is_client_restricted) - - content = { - 'access_token': jwt_builder.build_token( - scope.split(' '), - expires_in, - additional_claims={ - 'filters': adapter.get_authorization_filters(client_id), - 'is_restricted': is_client_restricted, - }, - ), - 'expires_in': expires_in, - 'scope': scope, + token_dict = json.loads(response.content) + jwt = create_jwt_from_token(token_dict, self.get_adapter(request)) + token_dict.update({ + 'access_token': jwt, 'token_type': 'JWT', - } - return json.dumps(content) - - def _parse_access_token_response(self, adapter, response): - """ Parses the expires_in, scope, and user values of the response. """ - content = json.loads(response.content) - access_token = content['access_token'] - expires_in = content['expires_in'] - scope = content['scope'] - user = adapter.get_access_token(access_token).user - return expires_in, scope, user - - def _get_jwt_builder(self, user, is_client_restricted): - """ Creates and returns a JWTBuilder object for creating JWTs. """ - - # If JWT scope enforcement is enabled, we need to sign tokens - # given to restricted applications with a key that - # other IDAs do not have access to. This prevents restricted - # applications from getting access to API endpoints available - # on other IDAs which have not yet been protected with the - # scope-related DRF permission classes. Once all endpoints have - # been protected, we can enable all IDAs to use the same new - # (asymmetric) key. - # TODO: ARCH-162 - use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted - return JwtBuilder( - user, - asymmetric=use_asymmetric_key, - ) + }) + return json.dumps(token_dict) class AuthorizationView(_DispatchingView): diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 6acd5604c1..fcdb083ba8 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -4,21 +4,31 @@ Utility functions for setting "logged in" cookies used by subdomains. from __future__ import unicode_literals import json +import logging import time import six from django.conf import settings from django.contrib.auth.models import User -from django.urls import NoReverseMatch, reverse from django.dispatch import Signal +from django.urls import NoReverseMatch, reverse from django.utils.http import cookie_date from edx_rest_framework_extensions.auth.jwt import cookies as jwt_cookies +from edx_rest_framework_extensions.auth.jwt.constants import JWT_DELIMITER +from oauth2_provider.models import Application +from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter +from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token, refresh_dot_access_token +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed +from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from openedx.core.djangoapps.user_authn.waffle import JWT_COOKIES_FLAG from student.models import CourseEnrollment +log = logging.getLogger(__name__) + + CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response']) @@ -48,14 +58,9 @@ ALL_LOGGED_IN_COOKIE_NAMES = JWT_COOKIE_NAMES + DEPRECATED_LOGGED_IN_COOKIE_NAME def is_logged_in_cookie_set(request): """ Check whether the request has logged in cookies set. """ - if JWT_COOKIES_FLAG.is_enabled(): - expected_cookie_names = ALL_LOGGED_IN_COOKIE_NAMES - else: - expected_cookie_names = DEPRECATED_LOGGED_IN_COOKIE_NAMES - - return all( - cookie_name in request.COOKIES - for cookie_name in expected_cookie_names + return ( + settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and + request.COOKIES[settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] ) @@ -131,12 +136,26 @@ def set_logged_in_cookies(request, response, user): _set_deprecated_logged_in_cookie(response, request) _set_deprecated_user_info_cookie(response, request, user) - _set_jwt_cookies(response, request, user) + _create_and_set_jwt_cookies(response, request, user) CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) return response +def refresh_jwt_cookies(request, response): + """ + Resets the JWT related cookies in the response, while expecting a refresh + cookie in the request. + """ + if JWT_COOKIES_FLAG.is_enabled(): + try: + refresh_token = request.COOKIES[jwt_cookies.jwt_refresh_cookie_name()] + except KeyError: + raise AuthFailedError(u"JWT Refresh Cookie not found in request.") + _create_and_set_jwt_cookies(response, request, refresh_token=refresh_token) + return response + + def _set_deprecated_logged_in_cookie(response, request): """ Sets the logged in cookie on the response. """ @@ -182,13 +201,6 @@ def _set_deprecated_user_info_cookie(response, request, user): ) -def _set_jwt_cookies(response, request, user): # pylint: disable=unused-argument - """ Sets a cookie containing a JWT on the response. """ - if not JWT_COOKIES_FLAG.is_enabled(): - return - # TODO (ARCH-236) - - def _get_user_info_cookie_data(request, user): """ Returns information that will populate the user info cookie. """ @@ -229,6 +241,94 @@ def _get_user_info_cookie_data(request, user): return user_info +def _create_and_set_jwt_cookies(response, request, user=None, refresh_token=None): + """ Sets a cookie containing a JWT on the response. """ + if not JWT_COOKIES_FLAG.is_enabled(): + return + + # TODO (ARCH-246) Need to fix configuration of token expiration settings. + cookie_settings = standard_cookie_settings(request) + _set_jwt_expiration(cookie_settings) + expires_in = cookie_settings['max_age'] + + oauth_application = _get_login_oauth_client() + if refresh_token: + access_token = refresh_dot_access_token( + request, oauth_application.client_id, refresh_token, expires_in=expires_in, + ) + else: + access_token = create_dot_access_token( + request, user, oauth_application, expires_in=expires_in, + ) + jwt = create_jwt_from_token(access_token, DOTAdapter(), use_asymmetric_key=True) + jwt_header_and_payload, jwt_signature = _parse_jwt(jwt) + _set_jwt_cookies( + response, + cookie_settings, + jwt_header_and_payload, + jwt_signature, + access_token['refresh_token'], + ) + + +def _parse_jwt(jwt): + """ + Parses and returns the following parts of the jwt: header_and_payload, signature + """ + jwt_parts = jwt.split(JWT_DELIMITER) + header_and_payload = JWT_DELIMITER.join(jwt_parts[0:2]) + signature = jwt_parts[2] + return header_and_payload, signature + + +def _set_jwt_cookies(response, cookie_settings, jwt_header_and_payload, jwt_signature, refresh_token): + """ + Sets the given jwt_header_and_payload, jwt_signature, and refresh token in 3 different cookies. + The latter 2 cookies are set as httponly. + """ + cookie_settings['httponly'] = None + response.set_cookie( + jwt_cookies.jwt_cookie_header_payload_name(), + jwt_header_and_payload, + **cookie_settings + ) + + cookie_settings['httponly'] = True + response.set_cookie( + jwt_cookies.jwt_cookie_signature_name(), + jwt_signature, + **cookie_settings + ) + response.set_cookie( + jwt_cookies.jwt_refresh_cookie_name(), + refresh_token, + **cookie_settings + ) + + +def _set_jwt_expiration(cookie_settings): + """ + Updates cookie_settings with the configured expiration values for JWT + Cookies. + """ + max_age = settings.JWT_AUTH['JWT_COOKIE_EXPIRATION'] + cookie_settings['max_age'] = max_age + cookie_settings['expires'] = _cookie_expiration_based_on_max_age(max_age) + + def _cookie_expiration_based_on_max_age(max_age): expires_time = time.time() + max_age return cookie_date(expires_time) + + +def _get_login_oauth_client(): + """ + Returns the configured OAuth Client/Application used for Login. + """ + login_client_id = settings.JWT_AUTH['JWT_LOGIN_CLIENT_ID'] + try: + return Application.objects.get(client_id=login_client_id) + except Application.DoesNotExist: + raise AuthFailedError( + u"OAuth Client for the Login service, '{}', is not configured.".format(login_client_id) + ) diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index ed3be5b38b..1cf1e9852f 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -1,30 +1,36 @@ # pylint: disable=missing-docstring from __future__ import unicode_literals +from mock import MagicMock import six from django.conf import settings +from django.http import HttpResponse from django.urls import reverse -from django.test import RequestFactory +from django.test import RequestFactory, TestCase -from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data +from edx_rest_framework_extensions.auth.jwt.middleware import JwtAuthCookieMiddleware +from openedx.core.djangoapps.user_authn import cookies as cookies_api +from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed from student.models import CourseEnrollment -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, AnonymousUserFactory -class CookieTests(SharedModuleStoreTestCase): - @classmethod - def setUpClass(cls): - super(CookieTests, cls).setUpClass() - cls.course = CourseFactory() - +class CookieTests(TestCase): def setUp(self): super(CookieTests, self).setUp() self.user = UserFactory.create() + self.request = RequestFactory().get('/') + self.request.user = self.user + self.request.session = self._get_stub_session() - def _get_expected_header_urls(self, request): + def _get_stub_session(self, expire_at_browser_close=False, max_age=604800): + return MagicMock( + get_expire_at_browser_close=lambda: expire_at_browser_close, + get_expiry_age=lambda: max_age, + ) + + def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), 'resume_block': retrieve_last_sitewide_block_completed(self.user.username) @@ -39,21 +45,81 @@ class CookieTests(SharedModuleStoreTestCase): # Convert relative URL paths to absolute URIs for url_name, url_path in six.iteritems(expected_header_urls): - expected_header_urls[url_name] = request.build_absolute_uri(url_path) + expected_header_urls[url_name] = self.request.build_absolute_uri(url_path) return expected_header_urls - def test_get_user_info_cookie_data(self): - request = RequestFactory().get('/') - request.user = self.user + def _copy_cookies_to_request(self, response, request): + request.COOKIES = { + key: val.value + for key, val in response.cookies.iteritems() + } - actual = _get_user_info_cookie_data(request, self.user) + def _assert_recreate_jwt_from_cookies(self, response, can_recreate): + """ + Verifies that a JWT can be properly recreated from the 2 separate + JWT-related cookies using the JwtAuthCookieMiddleware middleware. + """ + self.request.COOKIES = response.cookies + JwtAuthCookieMiddleware().process_request(self.request) + self.assertEqual( + cookies_api.jwt_cookies.jwt_cookie_name() in self.request.COOKIES, + can_recreate, + ) + + def _assert_cookies_present(self, response, expected_cookies): + self.assertSetEqual(set(response.cookies.keys()), set(expected_cookies)) + + def test_get_user_info_cookie_data(self): + actual = cookies_api._get_user_info_cookie_data(self.request, self.user) # pylint: disable=protected-access expected = { 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, 'username': self.user.username, - 'header_urls': self._get_expected_header_urls(request), + 'header_urls': self._get_expected_header_urls(), 'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(self.user) } self.assertDictEqual(actual, expected) + + def test_set_logged_in_cookies_anonymous_user(self): + anonymous_user = AnonymousUserFactory() + response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), anonymous_user) + self._assert_cookies_present(response, []) + + def test_set_logged_in_deprecated_cookies(self): + response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user) + self._assert_cookies_present(response, cookies_api.DEPRECATED_LOGGED_IN_COOKIE_NAMES) + self._assert_recreate_jwt_from_cookies(response, can_recreate=False) + + def test_set_logged_in_jwt_cookies(self): + setup_login_oauth_client() + with cookies_api.JWT_COOKIES_FLAG.override(True): + response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user) + self._assert_cookies_present(response, cookies_api.ALL_LOGGED_IN_COOKIE_NAMES) + self._assert_recreate_jwt_from_cookies(response, can_recreate=True) + + def test_delete_and_is_logged_in_cookie_set(self): + response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user) + self._copy_cookies_to_request(response, self.request) + self.assertTrue(cookies_api.is_logged_in_cookie_set(self.request)) + + cookies_api.delete_logged_in_cookies(response) + self._copy_cookies_to_request(response, self.request) + self.assertFalse(cookies_api.is_logged_in_cookie_set(self.request)) + + def test_refresh_jwt_cookies(self): + def _get_refresh_token_value(response): + return response.cookies[cookies_api.jwt_cookies.jwt_refresh_cookie_name()].value + + setup_login_oauth_client() + with cookies_api.JWT_COOKIES_FLAG.override(True): + response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user) + self._copy_cookies_to_request(response, self.request) + + new_response = cookies_api.refresh_jwt_cookies(self.request, HttpResponse()) + self._assert_recreate_jwt_from_cookies(new_response, can_recreate=True) + self.assertNotEqual( + _get_refresh_token_value(response), + _get_refresh_token_value(new_response), + ) diff --git a/openedx/core/djangoapps/user_authn/tests/utils.py b/openedx/core/djangoapps/user_authn/tests/utils.py new file mode 100644 index 0000000000..c0de8113bf --- /dev/null +++ b/openedx/core/djangoapps/user_authn/tests/utils.py @@ -0,0 +1,17 @@ +""" Common utilities for tests in the user_authn app. """ +from django.conf import settings +from openedx.core.djangoapps.oauth_dispatch.adapters.dot import DOTAdapter +from student.tests.factories import UserFactory + + +def setup_login_oauth_client(): + """ + Sets up a test OAuth client for the login service. + """ + login_service_user = UserFactory.create() + DOTAdapter().create_public_client( + name='login-service', + user=login_service_user, + redirect_uri='', + client_id=settings.JWT_AUTH['JWT_LOGIN_CLIENT_ID'], + ) diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py index d53633a4c5..1ba79798d0 100644 --- a/openedx/core/djangoapps/user_authn/urls_common.py +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -17,6 +17,7 @@ urlpatterns = [ url(r'^login_post$', login.login_user, name='login_post'), url(r'^login_ajax$', login.login_user, name="login"), url(r'^login_ajax/(?P[^/]*)$', login.login_user), + url(r'^login_refresh$', login.login_refresh, name="login_refresh"), url(r'^logout$', logout.LogoutView.as_view(), name='logout'), ] diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 96af4156d9..c19d620dac 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -20,7 +20,7 @@ from ratelimitbackend.exceptions import RateLimitException from edxmako.shortcuts import render_to_response from eventtracking import tracker -from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies +from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies, refresh_jwt_cookies from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError import openedx.core.djangoapps.external_auth.views from openedx.core.djangoapps.external_auth.models import ExternalAuthMap @@ -392,4 +392,16 @@ def login_user(request): # detect that the user is logged in. return set_logged_in_cookies(request, response, possibly_authenticated_user) except AuthFailedError as error: + log.exception(error.get_response()) return JsonResponse(error.get_response()) + + +@ensure_csrf_cookie +@require_http_methods(['POST']) +def login_refresh(request): + try: + response = JsonResponse({'success': True}) + return refresh_jwt_cookies(request, response) + except AuthFailedError as error: + log.exception(error.get_response()) + return JsonResponse(error.get_response(), status=400) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 09bc7682b2..6c6f8e49b6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -16,6 +16,9 @@ from six import text_type from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle +from openedx.core.djangoapps.user_authn.cookies import jwt_cookies +from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client +from openedx.core.djangoapps.user_authn.waffle import JWT_COOKIES_FLAG from openedx.core.djangoapps.password_policy.compliance import ( NonCompliantPasswordException, NonCompliantPasswordWarning @@ -284,6 +287,19 @@ class LoginTest(CacheIsolationTestCase): response, _audit_log = self._login_response('test@edx.org', 'wrong_password') self._assert_response(response, success=False, value='Too many failed login attempts') + def test_login_refresh(self): + def _assert_jwt_cookie_present(response): + self.assertEqual(response.status_code, 200) + self.assertIn(jwt_cookies.jwt_refresh_cookie_name(), self.client.cookies) + + setup_login_oauth_client() + with JWT_COOKIES_FLAG.override(True): + response, _ = self._login_response('test@edx.org', 'test_password') + _assert_jwt_cookie_present(response) + + response = self.client.post(reverse('login_refresh')) + _assert_jwt_cookie_present(response) + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) def test_single_session(self): creds = {'email': 'test@edx.org', 'password': 'test_password'} diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py b/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py index e3db74fbb4..90aaac02e7 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py @@ -40,7 +40,10 @@ def _finish_auth_url(params): class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase): """Test rendering of the login form. """ - URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls'] + URLCONF_MODULES = [ + 'openedx.core.djangoapps.user_authn.urls', + 'openedx.core.djangoapps.user_api.legacy_urls', + ] @classmethod def setUpClass(cls): diff --git a/openedx/core/djangoapps/waffle_utils/__init__.py b/openedx/core/djangoapps/waffle_utils/__init__.py index f7691264ee..7eb0704a4a 100644 --- a/openedx/core/djangoapps/waffle_utils/__init__.py +++ b/openedx/core/djangoapps/waffle_utils/__init__.py @@ -331,6 +331,14 @@ class WaffleFlag(object): flag_undefined_default=self.flag_undefined_default ) + @contextmanager + def override(self, active=True): + # TODO We can move this import to the top of the file once this code is + # not all contained within the __init__ module. + from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag + with override_waffle_flag(self, active): + yield + class CourseWaffleFlag(WaffleFlag): """ diff --git a/openedx/core/lib/tests/test_token_utils.py b/openedx/core/lib/tests/test_token_utils.py index 6d4460f587..f2ea488d6d 100644 --- a/openedx/core/lib/tests/test_token_utils.py +++ b/openedx/core/lib/tests/test_token_utils.py @@ -1,6 +1,5 @@ -"""Tests covering JWT construction utilities.""" +"""Tests covering the JwtBuilder utility.""" import ddt -import jwt from django.test import TestCase from openedx.core.djangoapps.oauth_dispatch.tests import mixins @@ -9,32 +8,27 @@ from student.tests.factories import UserFactory, UserProfileFactory @ddt.ddt -class TestJwtBuilder(mixins.AccessTokenMixin, TestCase): +class TestDeprecatedJwtBuilder(mixins.AccessTokenMixin, TestCase): """ - Test class for JwtBuilder. + Test class for the deprecated JwtBuilder class. """ expires_in = 10 shard = 2 def setUp(self): - super(TestJwtBuilder, self).setUp() + super(TestDeprecatedJwtBuilder, self).setUp() self.user = UserFactory() self.profile = UserProfileFactory(user=self.user) + self.scopes = ['email', 'profile'] - @ddt.data( - [], - ['email'], - ['profile'], - ['email', 'profile'], - ) - def test_jwt_construction(self, scopes): + def test_jwt_construction(self): """ Verify that a valid JWT is built, including claims for the requested scopes. """ - token = JwtBuilder(self.user).build_token(scopes, self.expires_in) - self.assert_valid_jwt_access_token(token, self.user, scopes) + token = JwtBuilder(self.user).build_token(expires_in=self.expires_in) + self.assert_valid_jwt_access_token(token, self.user, self.scopes) def test_user_profile_missing(self): """ @@ -42,27 +36,21 @@ class TestJwtBuilder(mixins.AccessTokenMixin, TestCase): """ self.profile.delete() - scopes = ['profile'] - token = JwtBuilder(self.user).build_token(scopes, self.expires_in) - self.assert_valid_jwt_access_token(token, self.user, scopes) + token = JwtBuilder(self.user).build_token(expires_in=self.expires_in) + self.assert_valid_jwt_access_token(token, self.user, self.scopes) - def test_override_secret_and_audience_and_issuer(self): + def test_override_secret_and_audience(self): """ - Verify that the signing key, audience, and issuer can be overridden. + Verify that the signing key and audience can be overridden. """ secret = 'avoid-this' audience = 'avoid-this-too' - issuer = 'avoid-this-too' - scopes = [] token = JwtBuilder( self.user, secret=secret, - issuer=issuer, ).build_token( - scopes, - self.expires_in, + expires_in=self.expires_in, aud=audience, ) - - jwt.decode(token, secret, audience=audience, issuer=issuer) + self.assert_valid_jwt_access_token(token, self.user, self.scopes, aud=audience, secret=secret) diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index d6b637be69..076c460c61 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -1,124 +1,33 @@ -"""Utilities for working with ID tokens.""" -import json -from time import time - -from django.conf import settings -from django.utils.functional import cached_property -from jwkest import jwk -from jwkest.jws import JWS - -from edx_django_utils.monitoring import set_custom_metric -from student.models import UserProfile, anonymous_id_for_user +""" +TODO (ARCH-248) +Deprecated JwtBuilder class. +Use openedx.core.djangoapps.oauth_dispatch.jwt.JwtBuilder directly. +This is here for backward compatibility reasons only. +""" +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user class JwtBuilder(object): - """Utility for building JWTs. - - Unifies diverse approaches to JWT creation in a single class. This utility defaults to using the system's - JWT configuration. - - NOTE: This utility class will allow you to override the signing key and audience claim to support those - clients which still require this. This approach to JWT creation is DEPRECATED. Avoid doing this for new clients. - - Arguments: - user (User): User for which to generate the JWT. - - Keyword Arguments: - 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. - issuer (string): Overrides configured JWT issuer. """ - - def __init__(self, user, asymmetric=False, secret=None, issuer=None): + Deprecated. See module docstring above. + """ + def __init__(self, user, secret=None): self.user = user - self.asymmetric = asymmetric self.secret = secret - self.issuer = issuer - self.jwt_auth = settings.JWT_AUTH - def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None): - """Returns a JWT access token. - - Arguments: - scopes (list): Scopes controlling which optional claims are included in the token. - - Keyword Arguments: - expires_in (int): Time to token expiry, specified in seconds. - aud (string): Overrides configured JWT audience claim. - additional_claims (dict): Additional claims to include in the token. - - Returns: - str: Encoded JWT + def build_token( + self, + scopes=None, # pylint: disable=unused-argument + expires_in=None, # pylint: disable=unused-argument + aud=None, + additional_claims=None, + ): """ - now = int(time()) - expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION'] - set_custom_metric('jwt_expires_in', expires_in) - - 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, - 'iss': self.issuer if self.issuer else self.jwt_auth['JWT_ISSUER'], - 'preferred_username': self.user.username, - 'scopes': scopes, - 'version': self.jwt_auth['JWT_SUPPORTED_VERSION'], - 'sub': anonymous_id_for_user(self.user, None), - } - - if additional_claims: - payload.update(additional_claims) - - for scope in scopes: - handler = self.claim_handlers.get(scope) - - if handler: - handler(payload) - - return self.encode(payload) - - @cached_property - def claim_handlers(self): - """Returns a dictionary mapping scopes to methods that will add claims to the JWT payload.""" - - return { - 'email': self.attach_email_claim, - 'profile': self.attach_profile_claim - } - - def attach_email_claim(self, payload): - """Add the email claim details to the JWT payload.""" - payload['email'] = self.user.email - - def attach_profile_claim(self, payload): - """Add the profile claim details to the JWT payload.""" - try: - # Some users (e.g., service users) may not have user profiles. - name = UserProfile.objects.get(user=self.user).name - except UserProfile.DoesNotExist: - name = None - - payload.update({ - 'name': name, - 'family_name': self.user.last_name, - 'given_name': self.user.first_name, - 'administrator': self.user.is_staff, - }) - - def encode(self, payload): - """Encode the provided payload.""" - set_custom_metric('jwt_asymmetric', self.asymmetric) - keys = jwk.KEYS() - - if self.asymmetric: - serialized_keypair = json.loads(self.jwt_auth['JWT_PRIVATE_SIGNING_JWK']) - keys.add(serialized_keypair) - algorithm = self.jwt_auth['JWT_SIGNING_ALGORITHM'] - else: - 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'] - - data = json.dumps(payload) - jws = JWS(data, alg=algorithm) - return jws.sign_compact(keys=keys) + Deprecated. See module docstring above. + """ + return create_jwt_for_user( + self.user, + secret=self.secret, + aud=aud, + additional_claims=additional_claims, + ) diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py index b6f1c75185..92ee78d9dd 100644 --- a/openedx/features/enterprise_support/api.py +++ b/openedx/features/enterprise_support/api.py @@ -546,9 +546,10 @@ def get_enterprise_learner_data(user): """ Client API operation adapter/wrapper """ - enterprise_learner_data = EnterpriseApiClient(user=user).fetch_enterprise_learner_data(user) - if enterprise_learner_data: - return enterprise_learner_data['results'] + if user.is_authenticated: + enterprise_learner_data = EnterpriseApiClient(user=user).fetch_enterprise_learner_data(user) + if enterprise_learner_data: + return enterprise_learner_data['results'] @enterprise_is_enabled(otherwise={}) diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index 077e10f6bb..fb8f1a0ffa 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -166,10 +166,7 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): @httpretty.activate def test_consent_needed_for_course(self): - user = mock.MagicMock( - username='janedoe', - is_authenticated=lambda: True, - ) + user = UserFactory(username='janedoe') request = mock.MagicMock(session={}, user=user) self.mock_enterprise_learner_api() self.mock_consent_missing(user.username, 'fake-course', 'cf246b88-d5f6-4908-a522-fc307e0b0c59')