Commit 3f19cc02 by Clinton Blackburn

Updated logout view

In addition to logging the user out of LMS, the logout view also logs users out of the IDAs to which they previously authenticated.

ECOM-4610
parent 77f605b3
......@@ -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
......
......@@ -5,7 +5,7 @@ from django.conf import settings
from microsite_configuration import microsite
def microsite_context(request): # pylint: disable=unused-argument
def microsite_context(request): # pylint: disable=missing-docstring,unused-argument
return {
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME)
}
......@@ -13,6 +13,7 @@ class MicrositeContextProcessorTests(TestCase):
""" Tests for the microsite context processor. """
def setUp(self):
super(MicrositeContextProcessorTests, self).setUp()
request = RequestFactory().get('/')
self.context = microsite_context(request)
......
# -*- 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,
},
),
]
......@@ -2198,3 +2198,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)
"""
Test the student dashboard view.
"""
import ddt
import unittest
from mock import patch
from pyquery import PyQuery as pq
from django.core.urlresolvers import reverse
import ddt
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 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 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
......@@ -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
/*
* 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);
/**
* 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);
{% extends "main_django.html" %}
{% load i18n staticfiles %}
{% block title %}{% trans "Signed Out" %} | {{ block.super }}{% endblock %}
{% block body %}
<h1>{% trans "You have signed out." %}</h1>
<p style="text-align: center; margin-bottom: 20px;">
{% blocktrans %}
If you are not redirected within 5 seconds, <a href="{{ target }}">click here to go to the home page</a>.
{% endblocktrans %}
</p>
<div id="iframeContainer" style="visibility: hidden" data-redirect-url="{{ target }}">
{% for uri in logout_uris %}
<iframe src="{{ uri }}"></iframe>
{% endfor %}
</div>
<script type="text/javascript" src="{% static 'js/jquery.allLoaded.js' %}"></script>
<script type="text/javascript" src="{% static 'js/logout.js' %}"></script>
{% endblock body %}
......@@ -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<key>[^/]*)$', 'student.views.activate_account', name="activate"),
......
......@@ -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
......
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