Commit f0030334 by Clinton Blackburn

Added enrollment hash to login cookie

This will allow the marketing site to determine if locally-stored enrollment data needs to be refreshed.

ECOM-4896
parent bc551b9b
""" """
Utility functions for setting "logged in" cookies used by subdomains. Utility functions for setting "logged in" cookies used by subdomains.
""" """
from __future__ import unicode_literals
import time
import json import json
import time
from django.dispatch import Signal import six
from django.utils.http import cookie_date
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse, NoReverseMatch 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,
}
CREATE_LOGON_COOKIE = Signal(providing_args=["user", "response"]) return cookie_settings
def set_logged_in_cookies(request, response, user): def set_logged_in_cookies(request, response, user):
...@@ -31,7 +56,6 @@ def set_logged_in_cookies(request, response, user): ...@@ -31,7 +56,6 @@ def set_logged_in_cookies(request, response, user):
{ {
"version": 1, "version": 1,
"username": "test-user", "username": "test-user",
"email": "test-user@example.com",
"header_urls": { "header_urls": {
"account_settings": "https://example.com/account/settings", "account_settings": "https://example.com/account/settings",
"learner_profile": "https://example.com/u/test-user", "learner_profile": "https://example.com/u/test-user",
...@@ -49,21 +73,7 @@ def set_logged_in_cookies(request, response, user): ...@@ -49,21 +73,7 @@ def set_logged_in_cookies(request, response, user):
HttpResponse HttpResponse
""" """
if request.session.get_expire_at_browser_close(): cookie_settings = _get_cookie_settings(request)
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,
}
# Backwards compatibility: set the cookie indicating that the user # Backwards compatibility: set the cookie indicating that the user
# is logged in. This is just a boolean value, so it's not very useful. # is logged in. This is just a boolean value, so it's not very useful.
...@@ -76,6 +86,42 @@ def set_logged_in_cookies(request, response, user): ...@@ -76,6 +86,42 @@ def set_logged_in_cookies(request, response, user):
**cookie_settings **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 = _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)
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 get_user_info_cookie_data(request):
""" Returns information that wil populate the user info cookie. """
user = request.user
# Set a cookie with user info. This can be used by external sites # Set a cookie with user info. This can be used by external sites
# to customize content based on user information. Currently, # to customize content based on user information. Currently,
# we include information that's used to customize the "account" # we include information that's used to customize the "account"
...@@ -94,38 +140,17 @@ def set_logged_in_cookies(request, response, user): ...@@ -94,38 +140,17 @@ def set_logged_in_cookies(request, response, user):
pass pass
# Convert relative URL paths to absolute URIs # 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) header_urls[url_name] = request.build_absolute_uri(url_path)
user_info = { user_info = {
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
'username': user.username, 'username': user.username,
'email': user.email,
'header_urls': header_urls, 'header_urls': header_urls,
'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(user)
} }
# In production, TLS should be enabled so that this cookie is encrypted return user_info
# 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
def delete_logged_in_cookies(response): def delete_logged_in_cookies(response):
......
...@@ -10,55 +10,53 @@ file and check it in at the same time as your model changes. To do that, ...@@ -10,55 +10,53 @@ file and check it in at the same time as your model changes. To do that,
2. ./manage.py lms schemamigration student --auto description_of_your_change 2. ./manage.py lms schemamigration student --auto description_of_your_change
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
""" """
import hashlib
import json
import logging
import uuid
from collections import defaultdict, OrderedDict, namedtuple from collections import defaultdict, OrderedDict, namedtuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import total_ordering from functools import total_ordering
import hashlib
from importlib import import_module from importlib import import_module
import json
import logging
from pytz import UTC
from urllib import urlencode from urllib import urlencode
import uuid
import analytics import analytics
import dogstats_wrapper as dog_stats_api
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from django.utils.translation import ugettext_lazy as _
from django.conf import settings from django.conf import settings
from django.utils import timezone
from django.contrib.auth.models import User
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import User
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.db import models, IntegrityError, transaction from django.db import models, IntegrityError, transaction
from django.db.models import Count from django.db.models import Count
from django.db.models.signals import pre_save, post_save from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from django.core.cache import cache
from django_countries.fields import CountryField from django_countries.fields import CountryField
import dogstats_wrapper as dog_stats_api
from eventtracking import tracker from eventtracking import tracker
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from pytz import UTC
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from track import contexts
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager
import lms.lib.comment_client as cc
import request_cache
from certificates.models import GeneratedCertificate from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode from course_modes.models import CourseMode
from enrollment.api import _default_course_mode from enrollment.api import _default_course_mode
import lms.lib.comment_client as cc
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
import request_cache
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager
from track import contexts
from util.milestones_helpers import is_entrance_exams_enabled
from util.model_utils import emit_field_changed_events, get_changed_fields_dict from util.model_utils import emit_field_changed_events, get_changed_fields_dict
from util.query import use_read_replica_if_available from util.query import use_read_replica_if_available
from util.milestones_helpers import is_entrance_exams_enabled
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"]) UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"]) ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"])
...@@ -1030,6 +1028,13 @@ class CourseEnrollment(models.Model): ...@@ -1030,6 +1028,13 @@ class CourseEnrollment(models.Model):
"[CourseEnrollment] {}: {} ({}); active: ({})" "[CourseEnrollment] {}: {} ({}); active: ({})"
).format(self.user, self.course_id, self.created, self.is_active) ).format(self.user, self.course_id, self.created, self.is_active)
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
super(CourseEnrollment, self).save(force_insert=force_insert, force_update=force_update, using=using,
update_fields=update_fields)
# Delete the cached status hash, forcing the value to be recalculated the next time it is needed.
cache.delete(self.enrollment_status_hash_cache_key(self.user))
@classmethod @classmethod
@transaction.atomic @transaction.atomic
def get_or_create_enrollment(cls, user, course_key): def get_or_create_enrollment(cls, user, course_key):
...@@ -1449,6 +1454,47 @@ class CourseEnrollment(models.Model): ...@@ -1449,6 +1454,47 @@ class CourseEnrollment(models.Model):
def enrollments_for_user(cls, user): def enrollments_for_user(cls, user):
return cls.objects.filter(user=user, is_active=1) return cls.objects.filter(user=user, is_active=1)
@classmethod
def enrollment_status_hash_cache_key(cls, user):
""" Returns the cache key for the cached enrollment status hash.
Args:
user (User): User whose cache key should be returned.
Returns:
str: Cache key.
"""
return 'enrollment_status_hash_' + user.username
@classmethod
def generate_enrollment_status_hash(cls, user):
""" Generates a hash encoding the given user's *active* enrollments.
Args:
user (User): User whose enrollments should be hashed.
Returns:
str: Hash of the user's active enrollments. If the user is anonymous, `None` is returned.
"""
if user.is_anonymous():
return None
cache_key = cls.enrollment_status_hash_cache_key(user)
status_hash = cache.get(cache_key)
if not status_hash:
enrollments = cls.enrollments_for_user(user).values_list('course_id', 'mode')
enrollments = [(e[0].lower(), e[1].lower()) for e in enrollments]
enrollments = sorted(enrollments, key=lambda e: e[0])
hash_elements = [user.username]
hash_elements += ['{course_id}={mode}'.format(course_id=e[0], mode=e[1]) for e in enrollments]
status_hash = hashlib.md5('&'.join(hash_elements).encode('utf-8')).hexdigest()
# The hash is cached indefinitely. It will be invalidated when the user enrolls/unenrolls.
cache.set(cache_key, status_hash, None)
return status_hash
def is_paid_course(self): def is_paid_course(self):
""" """
Returns True, if course is paid Returns True, if course is paid
......
# 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.models import CourseEnrollment
from student.tests.factories import UserFactory
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):
request = RequestFactory().get('/')
request.user = self.user
actual = get_user_info_cookie_data(request)
expected = {
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
'username': self.user.username,
'header_urls': self._get_expected_header_urls(request),
'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(self.user)
}
self.assertDictEqual(actual, expected)
''' """
Tests for student activation and login Tests for student activation and login
''' """
import json import json
import unittest import unittest
...@@ -30,9 +30,9 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -30,9 +30,9 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class LoginTest(CacheIsolationTestCase): class LoginTest(CacheIsolationTestCase):
''' """
Test student.views.login_user() view Test student.views.login_user() view
''' """
ENABLED_CACHES = ['default'] ENABLED_CACHES = ['default']
...@@ -171,12 +171,8 @@ class LoginTest(CacheIsolationTestCase): ...@@ -171,12 +171,8 @@ class LoginTest(CacheIsolationTestCase):
cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME] cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME]
user_info = json.loads(cookie.value) user_info = json.loads(cookie.value)
# Check that the version is set
self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION) 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["username"], self.user.username)
self.assertEqual(user_info["email"], self.user.email)
# Check that the URLs are absolute # Check that the URLs are absolute
for url in user_info["header_urls"].values(): for url in user_info["header_urls"].values():
......
# pylint: disable=missing-docstring
import hashlib
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.db.models.functions import Lower
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
class CourseEnrollmentTests(SharedModuleStoreTestCase):
@classmethod
def setUpClass(cls):
super(CourseEnrollmentTests, cls).setUpClass()
cls.course = CourseFactory()
def setUp(self):
super(CourseEnrollmentTests, self).setUp()
self.user = UserFactory.create()
def test_enrollment_status_hash_cache_key(self):
username = 'test-user'
user = UserFactory(username=username)
expected = 'enrollment_status_hash_' + username
self.assertEqual(CourseEnrollment.enrollment_status_hash_cache_key(user), expected)
def assert_enrollment_status_hash_cached(self, user, expected_value):
self.assertEqual(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(user)), expected_value)
def test_generate_enrollment_status_hash(self):
""" Verify the method returns a hash of a user's current enrollments. """
# Return None for anonymous users
self.assertIsNone(CourseEnrollment.generate_enrollment_status_hash(AnonymousUser()))
# No enrollments
expected = hashlib.md5(self.user.username).hexdigest()
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
self.assert_enrollment_status_hash_cached(self.user, expected)
# No active enrollments
enrollment_mode = 'verified'
course_id = self.course.id # pylint: disable=no-member
enrollment = CourseEnrollmentFactory.create(user=self.user, course_id=course_id, mode=enrollment_mode,
is_active=False)
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
self.assert_enrollment_status_hash_cached(self.user, expected)
# One active enrollment
enrollment.is_active = True
enrollment.save()
expected = '{username}&{course_id}={mode}'.format(
username=self.user.username, course_id=str(course_id).lower(), mode=enrollment_mode.lower()
)
expected = hashlib.md5(expected).hexdigest()
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
self.assert_enrollment_status_hash_cached(self.user, expected)
# Multiple enrollments
CourseEnrollmentFactory.create(user=self.user)
enrollments = CourseEnrollment.enrollments_for_user(self.user).order_by(Lower('course_id'))
hash_elements = [self.user.username]
hash_elements += [
'{course_id}={mode}'.format(course_id=str(enrollment.course_id).lower(), mode=enrollment.mode.lower()) for
enrollment in enrollments]
expected = hashlib.md5('&'.join(hash_elements)).hexdigest()
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
self.assert_enrollment_status_hash_cached(self.user, expected)
def test_save_deletes_cached_enrollment_status_hash(self):
""" Verify the method deletes the cached enrollment status hash for the user. """
# There should be no cached value for a new user with no enrollments.
self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user)))
# Generating a status hash should cache the generated value.
status_hash = CourseEnrollment.generate_enrollment_status_hash(self.user)
self.assert_enrollment_status_hash_cached(self.user, status_hash)
# Modifying enrollments should delete the cached value.
CourseEnrollmentFactory.create(user=self.user)
self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user)))
...@@ -99,6 +99,7 @@ rules==1.1.1 ...@@ -99,6 +99,7 @@ rules==1.1.1
scipy==0.14.0 scipy==0.14.0
Shapely==1.2.16 Shapely==1.2.16
singledispatch==3.4.0.2 singledispatch==3.4.0.2
six>=1.10.0,<2.0.0
sorl-thumbnail==12.3 sorl-thumbnail==12.3
sortedcontainers==0.9.2 sortedcontainers==0.9.2
stevedore==1.10.0 stevedore==1.10.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment