diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index dc6e5a27f3..c22230654a 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -29,7 +29,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from pyquery import PyQuery as pq -from openedx.core.djangoapps.user_authn.cookies import get_user_info_cookie_data +from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data from student.helpers import DISABLE_UNENROLL_CERT_STATES from student.models import CourseEnrollment, UserProfile from student.signals import REFUND_ORDER @@ -301,7 +301,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, request = RequestFactory().get(self.path) request.user = self.user - expected = json.dumps(get_user_info_cookie_data(request)) + expected = json.dumps(_get_user_info_cookie_data(request, self.user)) self.client.get(self.path) actual = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME].value self.assertEqual(actual, expected) diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index c4610795b1..76afbcd0bd 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -44,7 +44,7 @@ from openedx.features.enterprise_support.api import get_dashboard_consent_notifi from openedx.features.journals.api import journals_enabled from shoppingcart.api import order_history from shoppingcart.models import CourseRegistrationCode, DonationConfiguration -from openedx.core.djangoapps.user_authn.cookies import set_user_info_cookie +from openedx.core.djangoapps.user_authn.cookies import _set_deprecated_user_info_cookie from student.helpers import cert_info, check_verify_status_by_course from student.models import ( CourseEnrollment, @@ -848,5 +848,5 @@ def student_dashboard(request): }) response = render_to_response('dashboard.html', context) - set_user_info_cookie(response, request) + _set_deprecated_user_info_cookie(response, request, user) # pylint: disable=protected-access return response diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index cfbdd4a600..132479c010 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -6,7 +6,6 @@ django-oauth-toolkit as appropriate. from __future__ import unicode_literals import json -import logging from django.conf import settings from django.views.generic import View @@ -24,8 +23,6 @@ from . import adapters from .dot_overrides import views as dot_overrides_views from .toggles import ENFORCE_JWT_SCOPES -log = logging.getLogger(__name__) - class _DispatchingView(View): """ @@ -159,15 +156,9 @@ class AccessTokenView(RatelimitMixin, _DispatchingView): # (asymmetric) key. # TODO: ARCH-162 use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted - monitoring_utils.set_custom_metric('oauth_asymmetric_jwt', use_asymmetric_key) - - log.info("Using Asymmetric JWT: %s", use_asymmetric_key) - return JwtBuilder( user, asymmetric=use_asymmetric_key, - secret=settings.JWT_AUTH['JWT_SECRET_KEY'], - issuer=settings.JWT_AUTH['JWT_ISSUER'], ) diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index c064a3daf0..6acd5604c1 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -13,12 +13,70 @@ from django.urls import NoReverseMatch, reverse from django.dispatch import Signal from django.utils.http import cookie_date +from edx_rest_framework_extensions.auth.jwt import cookies as jwt_cookies from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed +from openedx.core.djangoapps.user_authn.waffle import JWT_COOKIES_FLAG from student.models import CourseEnrollment + CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response']) +JWT_COOKIE_NAMES = ( + # Header and payload sections of a JSON Web Token containing user + # information and used as an access token. + jwt_cookies.jwt_cookie_header_payload_name(), + + # Signature section of a JSON Web Token. + jwt_cookies.jwt_cookie_signature_name(), + + # Refresh token, which can be used to get a new JSON Web Token. + jwt_cookies.jwt_refresh_cookie_name(), +) + +# TODO (ARCH-245): Remove the following deprecated cookies. +DEPRECATED_LOGGED_IN_COOKIE_NAMES = ( + # Set to 'true' if the user is logged in. + settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, + + # JSON-encoded dictionary with user information. + settings.EDXMKTG_USER_INFO_COOKIE_NAME, +) + +ALL_LOGGED_IN_COOKIE_NAMES = JWT_COOKIE_NAMES + DEPRECATED_LOGGED_IN_COOKIE_NAMES + + +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 + ) + + +def delete_logged_in_cookies(response): + """ + Delete cookies indicating that the user is logged in. + Arguments: + response (HttpResponse): The response sent to the client. + Returns: + HttpResponse + """ + for cookie_name in ALL_LOGGED_IN_COOKIE_NAMES: + response.delete_cookie( + cookie_name.encode('utf-8'), + path='/', + domain=settings.SESSION_COOKIE_DOMAIN + ) + + return response + + def standard_cookie_settings(request): """ Returns the common cookie settings (e.g. expiration time). """ @@ -27,8 +85,7 @@ def standard_cookie_settings(request): expires = None else: max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) + expires = _cookie_expiration_based_on_max_age(max_age) cookie_settings = { 'max_age': max_age, @@ -38,21 +95,69 @@ def standard_cookie_settings(request): 'httponly': None, } + # In production, TLS should be enabled so that this cookie is encrypted + # when we send it. We also need to set "secure" to True so that the browser + # will transmit it only over secure connections. + # + # In non-production environments (acceptance tests, devstack, and sandboxes), + # we still want to set this cookie. However, we do NOT want to set it to "secure" + # because the browser won't send it back to us. This can cause an infinite redirect + # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine + # whether it needs to set the cookie or continue to the next pipeline stage. + cookie_settings['secure'] = request.is_secure() + return cookie_settings def set_logged_in_cookies(request, response, user): """ - Set cookies indicating that the user is logged in. + Set cookies at the time of user login. See ALL_LOGGED_IN_COOKIE_NAMES to see + which cookies are set. - Some installations have an external marketing site configured - that displays a different UI when the user is logged in - (e.g. a link to the student dashboard instead of to the login page) + Arguments: + request (HttpRequest): The request to the view, used to calculate + the cookie's expiration date based on the session expiration date. + response (HttpResponse): The response on which the cookie will be set. + user (User): The currently logged in user. - Currently, two cookies are set: + Returns: + HttpResponse - * EDXMKTG_LOGGED_IN_COOKIE_NAME: Set to 'true' if the user is logged in. - * EDXMKTG_USER_INFO_COOKIE_VERSION: JSON-encoded dictionary with user information (see below). + """ + # Note: The user may not yet be set on the request object by this time, + # especially during third party authentication. So use the user object + # that is passed in when needed. + if user.is_authenticated and not user.is_anonymous: + + _set_deprecated_logged_in_cookie(response, request) + _set_deprecated_user_info_cookie(response, request, user) + _set_jwt_cookies(response, request, user) + CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) + + return response + + +def _set_deprecated_logged_in_cookie(response, request): + """ Sets the logged in cookie on the response. """ + + # Backwards compatibility: set the cookie indicating that the user + # is logged in. This is just a boolean value, so it's not very useful. + # In the future, we should be able to replace this with the "user info" + # cookie set below. + cookie_settings = standard_cookie_settings(request) + + response.set_cookie( + settings.EDXMKTG_LOGGED_IN_COOKIE_NAME.encode('utf-8'), + 'true', + **cookie_settings + ) + + return response + + +def _set_deprecated_user_info_cookie(response, request, user): + """ + Sets the user info cookie on the response. The user info cookie has the following format: { @@ -66,91 +171,26 @@ def set_logged_in_cookies(request, response, user): "logout": "https://example.com/logout" } } - - Arguments: - request (HttpRequest): The request to the view, used to calculate - the cookie's expiration date based on the session expiration date. - response (HttpResponse): The response on which the cookie will be set. - user (User): The currently logged in user. - - Returns: - HttpResponse - """ cookie_settings = standard_cookie_settings(request) - # Backwards compatibility: set the cookie indicating that the user - # is logged in. This is just a boolean value, so it's not very useful. - # In the future, we should be able to replace this with the "user info" - # cookie set below. - response.set_cookie( - settings.EDXMKTG_LOGGED_IN_COOKIE_NAME.encode('utf-8'), - 'true', - secure=None, - **cookie_settings - ) - - set_user_info_cookie(response, request) - - # give signal receivers a chance to add cookies - CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) - - return response - - -def set_user_info_cookie(response, request): - """ Sets the user info cookie on the response. """ - cookie_settings = standard_cookie_settings(request) - - # In production, TLS should be enabled so that this cookie is encrypted - # when we send it. We also need to set "secure" to True so that the browser - # will transmit it only over secure connections. - # - # In non-production environments (acceptance tests, devstack, and sandboxes), - # we still want to set this cookie. However, we do NOT want to set it to "secure" - # because the browser won't send it back to us. This can cause an infinite redirect - # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine - # whether it needs to set the cookie or continue to the next pipeline stage. - user_info_cookie_is_secure = request.is_secure() - user_info = get_user_info_cookie_data(request) - + user_info = _get_user_info_cookie_data(request, user) response.set_cookie( settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'), json.dumps(user_info), - secure=user_info_cookie_is_secure, **cookie_settings ) -def set_experiments_is_enterprise_cookie(request, response, experiments_is_enterprise): - """ Sets the experiments_is_enterprise cookie on the response. - This cookie can be used for tests or minor features, - but should not be used for payment related or other critical work - since users can edit their cookies - """ - cookie_settings = standard_cookie_settings(request) - # In production, TLS should be enabled so that this cookie is encrypted - # when we send it. We also need to set "secure" to True so that the browser - # will transmit it only over secure connections. - # - # In non-production environments (acceptance tests, devstack, and sandboxes), - # we still want to set this cookie. However, we do NOT want to set it to "secure" - # because the browser won't send it back to us. This can cause an infinite redirect - # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine - # whether it needs to set the cookie or continue to the next pipeline stage. - cookie_is_secure = request.is_secure() - - response.set_cookie( - 'experiments_is_enterprise', - json.dumps(experiments_is_enterprise), - secure=cookie_is_secure, - **cookie_settings - ) +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): - """ Returns information that wil populate the user info cookie. """ - user = request.user +def _get_user_info_cookie_data(request, user): + """ Returns information that will populate the user info cookie. """ # Set a cookie with user info. This can be used by external sites # to customize content based on user information. Currently, @@ -189,30 +229,6 @@ def get_user_info_cookie_data(request): return user_info -def delete_logged_in_cookies(response): - """ - Delete cookies indicating that the user is logged in. - - Arguments: - response (HttpResponse): The response sent to the client. - - Returns: - HttpResponse - - """ - for cookie_name in [settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, settings.EDXMKTG_USER_INFO_COOKIE_NAME]: - response.delete_cookie( - cookie_name.encode('utf-8'), - path='/', - domain=settings.SESSION_COOKIE_DOMAIN - ) - - return response - - -def is_logged_in_cookie_set(request): - """Check whether the request has logged in cookies set. """ - return ( - settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and - settings.EDXMKTG_USER_INFO_COOKIE_NAME in request.COOKIES - ) +def _cookie_expiration_based_on_max_age(max_age): + expires_time = time.time() + max_age + return cookie_date(expires_time) diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index 2660202bf8..ed3be5b38b 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import reverse from django.test import RequestFactory -from openedx.core.djangoapps.user_authn.cookies import get_user_info_cookie_data +from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data 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 @@ -47,7 +47,7 @@ class CookieTests(SharedModuleStoreTestCase): request = RequestFactory().get('/') request.user = self.user - actual = get_user_info_cookie_data(request) + actual = _get_user_info_cookie_data(request, self.user) expected = { 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, diff --git a/openedx/core/djangoapps/user_authn/waffle.py b/openedx/core/djangoapps/user_authn/waffle.py index af1c74126b..79fae6db81 100644 --- a/openedx/core/djangoapps/user_authn/waffle.py +++ b/openedx/core/djangoapps/user_authn/waffle.py @@ -9,5 +9,8 @@ _WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(_WAFFLE_NAMESPACE) # Flags +# TODO (ARCH-247) # Intended as a temporary toggle for roll-out of jwt cookies feature. +# Satisfies Use Case #3 "Ops - Monitored Rollout" from +# https://open-edx-proposals.readthedocs.io/en/latest/oep-0017-bp-feature-toggles.html JWT_COOKIES_FLAG = WaffleFlag(_WAFFLE_FLAG_NAMESPACE, u'jwt_cookies') diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index 706ce67bdb..d6b637be69 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -7,6 +7,7 @@ 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 @@ -51,6 +52,8 @@ class JwtBuilder(object): """ 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'], @@ -104,6 +107,7 @@ class JwtBuilder(object): def encode(self, payload): """Encode the provided payload.""" + set_custom_metric('jwt_asymmetric', self.asymmetric) keys = jwk.KEYS() if self.asymmetric: diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 34081d0dcf..0846de4220 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import hashlib +import json import six from django.conf import settings @@ -9,7 +10,7 @@ from django.utils.translation import ugettext as _ import third_party_auth from third_party_auth import pipeline -from openedx.core.djangoapps.user_authn.cookies import set_experiments_is_enterprise_cookie +from openedx.core.djangoapps.user_authn.cookies import standard_cookie_settings from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.markup import HTML, Text @@ -188,7 +189,7 @@ def handle_enterprise_cookies_for_logistration(request, response, context): # This cookie can be used for tests or minor features, # but should not be used for payment related or other critical work # since users can edit their cookies - set_experiments_is_enterprise_cookie(request, response, context['enable_enterprise_sidebar']) + _set_experiments_is_enterprise_cookie(request, response, context['enable_enterprise_sidebar']) # Remove enterprise cookie so that subsequent requests show default login page. response.delete_cookie( @@ -197,6 +198,21 @@ def handle_enterprise_cookies_for_logistration(request, response, context): ) +def _set_experiments_is_enterprise_cookie(request, response, experiments_is_enterprise): + """ Sets the experiments_is_enterprise cookie on the response. + This cookie can be used for tests or minor features, + but should not be used for payment related or other critical work + since users can edit their cookies + """ + cookie_settings = standard_cookie_settings(request) + + response.set_cookie( + 'experiments_is_enterprise', + json.dumps(experiments_is_enterprise), + **cookie_settings + ) + + def update_account_settings_context_for_enterprise(context, enterprise_customer): """ Take processed context for account settings page and update it taking enterprise customer into account. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6f2302964a..29277b6a86 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -117,7 +117,7 @@ edx-django-oauth2-provider==1.3.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 edx-django-utils==1.0.1 -edx-drf-extensions==1.7.0 +edx-drf-extensions==1.9.0 edx-enterprise==0.73.5 edx-i18n-tools==0.4.6 edx-milestones==0.1.13 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 13da663f12..1c0379ae33 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -136,7 +136,7 @@ edx-django-oauth2-provider==1.3.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 edx-django-utils==1.0.1 -edx-drf-extensions==1.7.0 +edx-drf-extensions==1.9.0 edx-enterprise==0.73.5 edx-i18n-tools==0.4.6 edx-lint==0.5.5 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 5337fbe1db..a1e82cd8df 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -131,7 +131,7 @@ edx-django-oauth2-provider==1.3.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 edx-django-utils==1.0.1 -edx-drf-extensions==1.7.0 +edx-drf-extensions==1.9.0 edx-enterprise==0.73.5 edx-i18n-tools==0.4.6 edx-lint==0.5.5