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
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user