diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py index b508f93339..508da869f9 100644 --- a/common/djangoapps/student/cookies.py +++ b/common/djangoapps/student/cookies.py @@ -2,18 +2,43 @@ Utility functions for setting "logged in" cookies used by subdomains. """ -import time import json +import time -from django.dispatch import Signal - -from django.utils.http import cookie_date +import six +import urllib from django.conf import settings from django.core.urlresolvers import reverse, NoReverseMatch +from django.dispatch import Signal +from django.utils.http import cookie_date + +from student.models import CourseEnrollment CREATE_LOGON_COOKIE = Signal(providing_args=["user", "response"]) +def _get_cookie_settings(request): + """ Returns the common cookie settings (e.g. expiration time). """ + + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + + cookie_settings = { + 'max_age': max_age, + 'expires': expires, + 'domain': settings.SESSION_COOKIE_DOMAIN, + 'path': '/', + 'httponly': None, + } + + return cookie_settings + + def set_logged_in_cookies(request, response, user): """ Set cookies indicating that the user is logged in. @@ -49,21 +74,7 @@ def set_logged_in_cookies(request, response, user): HttpResponse """ - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - - cookie_settings = { - 'max_age': max_age, - 'expires': expires, - 'domain': settings.SESSION_COOKIE_DOMAIN, - 'path': '/', - 'httponly': None, - } + cookie_settings = _get_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. @@ -76,6 +87,41 @@ def set_logged_in_cookies(request, response, user): **cookie_settings ) + set_user_info_cookie(response, request, user) + + # 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, user): + """ Sets the user info cookie on the response. """ + cookie_settings = _get_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) + + response.set_cookie( + settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'), + urllib.quote(json.dumps(user_info)), + secure=user_info_cookie_is_secure, + **cookie_settings + ) + + +def get_user_info_cookie_data(request, user): + """ Returns information that wil 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, # we include information that's used to customize the "account" @@ -94,38 +140,24 @@ def set_logged_in_cookies(request, response, user): pass # Convert relative URL paths to absolute URIs - for url_name, url_path in header_urls.iteritems(): + for url_name, url_path in six.iteritems(header_urls): header_urls[url_name] = request.build_absolute_uri(url_path) + enrollments = [] + for enrollment in CourseEnrollment.enrollments_for_user(user): + enrollments.append({ + 'course_run_id': six.text_type(enrollment.course_id), + 'seat_type': enrollment.mode + }) + user_info = { 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, 'username': user.username, - 'email': user.email, 'header_urls': header_urls, + 'enrollments': enrollments, } - # 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() - - response.set_cookie( - settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'), - json.dumps(user_info), - secure=user_info_cookie_is_secure, - **cookie_settings - ) - - # give signal receivers a chance to add cookies - CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) - - return response + return user_info def delete_logged_in_cookies(response): diff --git a/common/djangoapps/student/tests/test_cookies.py b/common/djangoapps/student/tests/test_cookies.py new file mode 100644 index 0000000000..fc4ba276dc --- /dev/null +++ b/common/djangoapps/student/tests/test_cookies.py @@ -0,0 +1,66 @@ +# pylint: disable=missing-docstring +from __future__ import unicode_literals + +import six +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from student.cookies import get_user_info_cookie_data +from student.tests.factories import UserFactory, CourseEnrollmentFactory + + +class CookieTests(SharedModuleStoreTestCase): + @classmethod + def setUpClass(cls): + super(CookieTests, cls).setUpClass() + cls.course = CourseFactory() + + def setUp(self): + super(CookieTests, self).setUp() + self.user = UserFactory.create() + + def _get_expected_header_urls(self, request): + expected_header_urls = { + 'logout': reverse('logout'), + } + + # Studio (CMS) does not have the URLs below + if settings.ROOT_URLCONF == 'lms.urls': + expected_header_urls.update({ + 'account_settings': reverse('account_settings'), + 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}), + }) + + # 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) + + return expected_header_urls + + def test_get_user_info_cookie_data(self): + """ Verify the function returns data that """ + request = RequestFactory().get('/') + request.user = self.user + + enrollment_mode = 'verified' + course_id = self.course.id # pylint: disable=no-member + CourseEnrollmentFactory.create(user=self.user, course_id=course_id, mode=enrollment_mode) + + actual = get_user_info_cookie_data(request, self.user) + + expected_enrollments = [{ + 'course_run_id': six.text_type(course_id), + 'seat_type': enrollment_mode, + }] + + expected = { + 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + 'username': self.user.username, + 'header_urls': self._get_expected_header_urls(request), + 'enrollments': expected_enrollments, + } + + self.assertDictEqual(actual, expected) diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 6f4896516f..c833cd6699 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -4,6 +4,7 @@ Tests for student activation and login import json import unittest +import urllib from django.test import TestCase from django.test.client import Client from django.test.utils import override_settings @@ -169,14 +170,10 @@ class LoginTest(CacheIsolationTestCase): # Verify the format of the "user info" cookie set on login cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME] - user_info = json.loads(cookie.value) + user_info = json.loads(urllib.unquote(cookie.value)) - # Check that the version is set self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION) - - # Check that the username and email are set self.assertEqual(user_info["username"], self.user.username) - self.assertEqual(user_info["email"], self.user.email) # Check that the URLs are absolute for url in user_info["header_urls"].values(): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d744fd36f7..27b77cb39c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -94,6 +94,7 @@ requests-oauthlib==0.4.1 scipy==0.14.0 Shapely==1.2.16 singledispatch==3.4.0.2 +six>=1.10.0,<2.0.0 sorl-thumbnail==12.3 sortedcontainers==0.9.2 stevedore==1.10.0