diff --git a/cms/urls.py b/cms/urls.py index 21c92da9fb..dcab9cf6c3 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -5,6 +5,7 @@ from ratelimitbackend import admin from cms.djangoapps.contentstore.views.program import ProgramAuthoringView, ProgramsIdTokenView from cms.djangoapps.contentstore.views.organization import OrganizationListView +from student.views import LogoutView admin.autodiscover() @@ -65,7 +66,7 @@ urlpatterns += patterns( # ajax view that actually does the work url(r'^login_post$', 'student.views.login_user', name='login_post'), - url(r'^logout$', 'student.views.logout_user', name='logout'), + url(r'^logout$', LogoutView.as_view(), name='logout'), ) # restful api diff --git a/common/djangoapps/microsite_configuration/context_processors.py b/common/djangoapps/microsite_configuration/context_processors.py new file mode 100644 index 0000000000..af3ce1552b --- /dev/null +++ b/common/djangoapps/microsite_configuration/context_processors.py @@ -0,0 +1,11 @@ +""" Django template ontext processors. """ + +from django.conf import settings + +from microsite_configuration import microsite + + +def microsite_context(request): # pylint: disable=missing-docstring,unused-argument + return { + 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME) + } diff --git a/common/djangoapps/microsite_configuration/tests/test_context_processors.py b/common/djangoapps/microsite_configuration/tests/test_context_processors.py new file mode 100644 index 0000000000..78de00aa22 --- /dev/null +++ b/common/djangoapps/microsite_configuration/tests/test_context_processors.py @@ -0,0 +1,22 @@ +""" Tests for Django template context processors. """ +from django.test import TestCase +from django.test.client import RequestFactory +from django.test.utils import override_settings + +from microsite_configuration.context_processors import microsite_context + +PLATFORM_NAME = 'Test Platform' + + +@override_settings(PLATFORM_NAME=PLATFORM_NAME) +class MicrositeContextProcessorTests(TestCase): + """ Tests for the microsite context processor. """ + + def setUp(self): + super(MicrositeContextProcessorTests, self).setUp() + request = RequestFactory().get('/') + self.context = microsite_context(request) + + def test_platform_name(self): + """ Verify the context includes the platform name. """ + self.assertEqual(self.context['platform_name'], PLATFORM_NAME) diff --git a/common/djangoapps/student/migrations/0006_logoutviewconfiguration.py b/common/djangoapps/student/migrations/0006_logoutviewconfiguration.py new file mode 100644 index 0000000000..33bb40038c --- /dev/null +++ b/common/djangoapps/student/migrations/0006_logoutviewconfiguration.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('student', '0005_auto_20160531_1653'), + ] + + operations = [ + migrations.CreateModel( + name='LogoutViewConfiguration', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + options={ + 'ordering': ('-change_date',), + 'abstract': False, + }, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index e874b190f6..8d8fd17db5 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -2216,3 +2216,11 @@ class UserAttribute(TimeStampedModel): return cls.objects.get(user=user, name=name).value except cls.DoesNotExist: return None + + +class LogoutViewConfiguration(ConfigurationModel): + """ Configuration for the logout view. """ + + def __unicode__(self): + """Unicode representation of the instance. """ + return u'Logout view configuration: {enabled}'.format(enabled=self.enabled) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 38a72d30fa..e6b64f1a19 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -1,20 +1,25 @@ """ Test the student dashboard view. """ -import ddt import unittest + +import ddt +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import TestCase +from edx_oauth2_provider.constants import AUTHORIZED_CLIENTS_SESSION_KEY +from edx_oauth2_provider.tests.factories import ClientFactory, TrustedClientFactory from mock import patch from pyquery import PyQuery as pq - -from django.core.urlresolvers import reverse -from django.conf import settings - -from student.tests.factories import UserFactory, CourseEnrollmentFactory -from student.models import CourseEnrollment -from student.helpers import DISABLE_UNENROLL_CERT_STATES from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from student.helpers import DISABLE_UNENROLL_CERT_STATES +from student.models import CourseEnrollment, LogoutViewConfiguration +from student.tests.factories import UserFactory, CourseEnrollmentFactory + +PASSWORD = 'test' + @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -22,9 +27,6 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): """ Test to ensure that the student dashboard does not show the unenroll button for users with certificates. """ - USERNAME = "Bob" - EMAIL = "bob@example.com" - PASSWORD = "edx" UNENROLL_ELEMENT_ID = "#actions-item-unenroll-0" @classmethod @@ -35,10 +37,10 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): def setUp(self): """ Create a course and user, then log in. """ super(TestStudentDashboardUnenrollments, self).setUp() - self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + self.user = UserFactory() CourseEnrollmentFactory(course_id=self.course.id, user=self.user) self.cert_status = None - self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.client.login(username=self.user.username, password=PASSWORD) def mock_cert(self, _user, _course_overview, _course_mode): """ Return a preset certificate status. """ @@ -107,3 +109,89 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): response = self.client.get(reverse('dashboard')) self.assertEqual(response.status_code, 200) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class LogoutTests(TestCase): + """ Tests for the logout functionality. """ + + def setUp(self): + """ Create a course and user, then log in. """ + super(LogoutTests, self).setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password=PASSWORD) + LogoutViewConfiguration.objects.create(enabled=True) + + def create_oauth_client(self): + """ Creates a trusted OAuth client. """ + client = ClientFactory(logout_uri='https://www.example.com/logout/') + TrustedClientFactory(client=client) + return client + + def assert_session_logged_out(self, oauth_client, **logout_headers): + """ Authenticates a user via OAuth 2.0, logs out, and verifies the session is logged out. """ + self.authenticate_with_oauth(oauth_client) + + # Logging out should remove the session variables, and send a list of logout URLs to the template. + # The template will handle loading those URLs and redirecting the user. That functionality is not tested here. + response = self.client.get(reverse('logout'), **logout_headers) + self.assertEqual(response.status_code, 200) + self.assertNotIn(AUTHORIZED_CLIENTS_SESSION_KEY, self.client.session) + + return response + + def authenticate_with_oauth(self, oauth_client): + """ Perform an OAuth authentication using the current web client. + + This should add an AUTHORIZED_CLIENTS_SESSION_KEY entry to the current session. + """ + data = { + 'client_id': oauth_client.client_id, + 'client_secret': oauth_client.client_secret, + 'response_type': 'code' + } + # Authenticate with OAuth to set the appropriate session values + self.client.post(reverse('oauth2:capture'), data, follow=True) + self.assertListEqual(self.client.session[AUTHORIZED_CLIENTS_SESSION_KEY], [oauth_client.client_id]) + + def assert_logout_redirects(self): + """ Verify logging out redirects the user to the homepage. """ + response = self.client.get(reverse('logout')) + self.assertRedirects(response, '/', fetch_redirect_response=False) + + def test_switch(self): + """ Verify the IDA logout functionality is disabled if the associated switch is disabled. """ + LogoutViewConfiguration.objects.create(enabled=False) + oauth_client = self.create_oauth_client() + self.authenticate_with_oauth(oauth_client) + self.assert_logout_redirects() + + def test_without_session_value(self): + """ Verify logout works even if the session does not contain an entry with + the authenticated OpenID Connect clients.""" + self.assert_logout_redirects() + + def test_client_logout(self): + """ Verify the context includes a list of the logout URIs of the authenticated OpenID Connect clients. + + The list should only include URIs of the clients for which the user has been authenticated. + """ + client = self.create_oauth_client() + response = self.assert_session_logged_out(client) + expected = { + 'logout_uris': [client.logout_uri + '?no_redirect=1'], # pylint: disable=no-member + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) # pylint: disable=no-member + + def test_filter_referring_service(self): + """ Verify that, if the user is directed to the logout page from a service, that service's logout URL + is not included in the context sent to the template. + """ + client = self.create_oauth_client() + response = self.assert_session_logged_out(client, HTTP_REFERER=client.logout_uri) # pylint: disable=no-member + expected = { + 'logout_uris': [], + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) # pylint: disable=no-member diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index c71c829439..6db07a4bac 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -7,12 +7,14 @@ import uuid import json import warnings from collections import defaultdict -from urlparse import urljoin +from urlparse import urljoin, urlsplit, parse_qs, urlunsplit +from django.views.generic import TemplateView from pytz import UTC from requests import HTTPError from ipware.ip import get_ip +import edx_oauth2_provider from django.conf import settings from django.contrib.auth import logout, authenticate, login from django.contrib.auth.models import User, AnonymousUser @@ -21,22 +23,21 @@ from django.contrib.auth.views import password_reset_confirm from django.contrib import messages from django.core.context_processors import csrf from django.core import mail -from django.core.urlresolvers import reverse, NoReverseMatch +from django.core.urlresolvers import reverse, NoReverseMatch, reverse_lazy from django.core.validators import validate_email, ValidationError from django.db import IntegrityError, transaction -from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, - HttpResponseServerError, Http404) +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError, Http404 from django.shortcuts import redirect from django.utils.encoding import force_bytes, force_text from django.utils.translation import ungettext -from django.utils.http import base36_to_int, urlsafe_base64_encode +from django.utils.http import base36_to_int, urlsafe_base64_encode, urlencode from django.utils.translation import ugettext as _, get_language from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.http import require_POST, require_GET from django.db.models.signals import post_save from django.dispatch import receiver, Signal from django.template.response import TemplateResponse - +from provider.oauth2.models import Client from ratelimitbackend.exceptions import RateLimitException from social.apps.django_app import utils as social_utils @@ -52,7 +53,8 @@ from student.models import ( PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, create_comments_service_user, PasswordHistory, UserSignupSource, - DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED) + DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED, + LogoutViewConfiguration) from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error @@ -68,7 +70,6 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locator import CourseLocator -from xmodule.modulestore import ModuleStoreEnum from collections import namedtuple @@ -733,7 +734,7 @@ def dashboard(request): 'denied_banner': denied_banner, 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, 'user': user, - 'logout_url': reverse(logout_user), + 'logout_url': reverse('logout'), 'platform_name': platform_name, 'enrolled_courses_either_paid': enrolled_courses_either_paid, 'provider_states': [], @@ -1357,27 +1358,6 @@ def login_oauth_token(request, backend): raise Http404 -@ensure_csrf_cookie -def logout_user(request): - """ - HTTP request to log out the user. Redirects to marketing page. - Deletes both the CSRF and sessionid cookies so the marketing - site can determine the logged in state of the user - """ - # We do not log here, because we have a handler registered - # to perform logging on successful logouts. - request.is_from_logout = True - logout(request) - if settings.FEATURES.get('AUTH_USE_CAS'): - target = reverse('cas-logout') - else: - target = '/' - response = redirect(target) - - delete_logged_in_cookies(response) - return response - - @require_GET @login_required @ensure_csrf_cookie @@ -2486,3 +2466,74 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali log.warning('Program structure is invalid, skipping display: %r', program) return programs_data + + +class LogoutView(TemplateView): + """ + Logs out user and redirects. + + The template should load iframes to log the user out of OpenID Connect services. + See http://openid.net/specs/openid-connect-logout-1_0.html. + """ + oauth_client_ids = [] + template_name = 'logout.html' + + # Keep track of the page to which the user should ultimately be redirected. + target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' + + def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring + # We do not log here, because we have a handler registered to perform logging on successful logouts. + request.is_from_logout = True + + # Get the list of authorized clients before we clear the session. + self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, []) + + logout(request) + + # If we don't need to deal with OIDC logouts, just redirect the user. + if LogoutViewConfiguration.current().enabled and self.oauth_client_ids: + response = super(LogoutView, self).dispatch(request, *args, **kwargs) + else: + response = redirect(self.target) + + # Clear the cookie used by the edx.org marketing site + delete_logged_in_cookies(response) + + return response + + def _build_logout_url(self, url): + """ + Builds a logout URL with the `no_redirect` query string parameter. + + Args: + url (str): IDA logout URL + + Returns: + str + """ + scheme, netloc, path, query_string, fragment = urlsplit(url) + query_params = parse_qs(query_string) + query_params['no_redirect'] = 1 + new_query_string = urlencode(query_params, doseq=True) + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + def get_context_data(self, **kwargs): + context = super(LogoutView, self).get_context_data(**kwargs) + + # Create a list of URIs that must be called to log the user out of all of the IDAs. + uris = Client.objects.filter(client_id__in=self.oauth_client_ids, + logout_uri__isnull=False).values_list('logout_uri', flat=True) + + referrer = self.request.META.get('HTTP_REFERER', '').strip('/') + logout_uris = [] + + for uri in uris: + if not referrer or (referrer and not uri.startswith(referrer)): + logout_uris.append(self._build_logout_url(uri)) + + context.update({ + 'target': self.target, + 'logout_uris': logout_uris, + }) + + return context diff --git a/lms/envs/common.py b/lms/envs/common.py index 01d86d119f..8f1588f1ce 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -499,6 +499,7 @@ TEMPLATES = [ 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', # this is required for admin 'django.template.context_processors.csrf', + 'microsite_configuration.context_processors.microsite_context', # Added for django-wiki 'django.template.context_processors.media', diff --git a/lms/static/js/jquery.allloaded.js b/lms/static/js/jquery.allloaded.js new file mode 100644 index 0000000000..3186d1c4a7 --- /dev/null +++ b/lms/static/js/jquery.allloaded.js @@ -0,0 +1,33 @@ +/* + * jQuery plugin that waits until all elements are loaded before executing the specified function. + * + * Adapted from http://stackoverflow.com/a/35777807/592820. + * + * Example: + * + * $('iframe').allLoaded(function () { + * window.alert('All iframes loaded!'); + * }); + * + */ + +;(function ($) { + 'use strict'; + + $.fn.extend({ + allLoaded: function (fn) { + var $elems = this; + var waiting = this.length; + + var handler = function () { + --waiting; + if (!waiting) { + fn.call(window); + } + this.unbind(handler); + }; + + return $elems.load(handler); + } + }); +})(jQuery); diff --git a/lms/static/js/logout.js b/lms/static/js/logout.js new file mode 100644 index 0000000000..c2db7a30f7 --- /dev/null +++ b/lms/static/js/logout.js @@ -0,0 +1,23 @@ +/** + * JS for the logout page. + * + * This script waits for all iframes on the page to load before redirecting the user + * to a specified URL. If there are no iframes on the page, the user is immediately redirected. + */ +(function ($) { + 'use strict'; + + $(function () { + var $iframeContainer = $('#iframeContainer'), + $iframes = $iframeContainer.find('iframe'), + redirectUrl = $iframeContainer.data('redirect-url'); + + if ($iframes.length === 0) { + window.location = redirectUrl; + } + + $iframes.allLoaded(function () { + window.location = redirectUrl; + }); + }); +})(jQuery); diff --git a/lms/templates/logout.html b/lms/templates/logout.html new file mode 100644 index 0000000000..8223e3a73f --- /dev/null +++ b/lms/templates/logout.html @@ -0,0 +1,23 @@ +{% extends "main_django.html" %} +{% load i18n staticfiles %} + +{% block title %}{% trans "Signed Out" %} | {{ block.super }}{% endblock %} + +{% block body %} +

{% trans "You have signed out." %}

+ +

+ {% blocktrans %} + If you are not redirected within 5 seconds, click here to go to the home page. + {% endblocktrans %} +

+ + + + + +{% endblock body %} diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html index 932ecdf3ae..b2dd8e6672 100644 --- a/lms/templates/main_django.html +++ b/lms/templates/main_django.html @@ -4,7 +4,7 @@ - {% block title %}{% platform_name %}{% endblock %} + {% block title %}{{ platform_name }}{% endblock %} diff --git a/lms/templates/wiki/base.html b/lms/templates/wiki/base.html index fe6112a369..f19e6c20b8 100644 --- a/lms/templates/wiki/base.html +++ b/lms/templates/wiki/base.html @@ -2,7 +2,9 @@ {% with online_help_token="wiki" %} {% load pipeline %}{% load sekizai_tags i18n microsite %}{% load url from future %}{% load staticfiles %} -{% block title %}{% block pagetitle %}{% endblock %} | {% trans "Wiki" %} | {% platform_name %}{% endblock %} +{% block title %} + {% block pagetitle %}{% endblock %} | {% trans "Wiki" %} | {% platform_name %} +{% endblock %} {% block bodyclass %}view-in-course view-wiki{% endblock %} diff --git a/lms/urls.py b/lms/urls.py index 3aabc1d0c2..3a100daba5 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -15,6 +15,7 @@ from config_models.views import ConfigurationModelCurrentAPIView from courseware.views.index import CoursewareIndex from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from student.views import LogoutView # Uncomment the next two lines to enable the admin: if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): @@ -42,7 +43,7 @@ urlpatterns = ( url(r'^accounts/disable_account_ajax$', 'student.views.disable_account_ajax', name="disable_account_ajax"), - url(r'^logout$', 'student.views.logout_user', name='logout'), + url(r'^logout$', LogoutView.as_view(), name='logout'), url(r'^create_account$', 'student.views.create_account', name='create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name="activate"), diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index dc1f991ce5..8667ecf50d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -41,9 +41,9 @@ djangorestframework-oauth==1.1.0 edx-ccx-keys==0.1.2 edx-drf-extensions==0.5.1 edx-lint==0.4.3 -edx-django-oauth2-provider==1.0.3 +edx-django-oauth2-provider==1.1.1 edx-django-sites-extensions==2.0.1 -edx-oauth2-provider==1.0.1 +edx-oauth2-provider==1.1.1 edx-opaque-keys==0.2.1 edx-organizations==0.4.1 edx-rest-api-client==1.2.1