diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index cf7123020191..a4b76ced37a5 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -2200,7 +2200,7 @@ def test_login(self): def test_logout(self): # Logout redirects. - self._test_page("/logout", 302) + self._test_page("/logout", 200) @override_switch( '{}.{}'.format(waffle.WAFFLE_NAMESPACE, waffle.ENABLE_ACCESSIBILITY_POLICY_PAGE), diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 875454baf19f..14e76e83fa22 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -144,6 +144,10 @@ ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/') # Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. +# List of logout URIs for each IDA that the learner should be logged out of when they logout of +# Studio. Only applies to IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) + SITE_NAME = ENV_TOKENS['SITE_NAME'] ALLOWED_HOSTS = [ diff --git a/cms/envs/common.py b/cms/envs/common.py index 527089085443..109c0f5491a0 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -141,6 +141,8 @@ RETIREMENT_SERVICE_WORKER_USERNAME, RETIREMENT_STATES, + IDA_LOGOUT_URI_LIST, + # Methods to derive settings _make_mako_template_dirs, _make_locale_paths, @@ -440,6 +442,13 @@ LMS_ENROLLMENT_API_PATH = "/api/enrollment/v1/" ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' ENTERPRISE_CONSENT_API_URL = LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' +FRONTEND_LOGIN_URL = LOGIN_URL +FRONTEND_LOGOUT_URL = lambda settings: settings.LMS_ROOT_URL + '/logout' +derived('FRONTEND_LOGOUT_URL') + +# List of logout URIs for each IDA that the learner should be logged out of when they logout of +# Studio. Only applies to IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = [] # These are standard regexes for pulling out info like course_ids, usage_ids, etc. # They are used so that URLs with deprecated-format strings still work. diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index d9fc25b8f481..6bc302c61e24 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -166,6 +166,11 @@ def should_show_debug_toolbar(request): ), }) +IDA_LOGOUT_URI_LIST = [ + 'http://localhost:18130/logout/', # ecommerce + 'http://localhost:18150/logout/', # credentials +] + ##################################################################### from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants plugin_settings.add_plugins(__name__, plugin_constants.ProjectType.CMS, plugin_constants.SettingsType.DEVSTACK) diff --git a/cms/envs/production.py b/cms/envs/production.py index d560729ed105..3f33bb57b7fb 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -12,6 +12,7 @@ from path import Path as path from xmodule.modulestore.modulestore_settings import convert_module_store_setting_if_needed from openedx.core.djangoapps.plugins import plugin_settings, constants as plugin_constants +from django.core.urlresolvers import reverse_lazy from .common import * @@ -145,6 +146,10 @@ ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/') # Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. +# List of logout URIs for each IDA that the learner should be logged out of when they logout of +# Studio. Only applies to IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) + SITE_NAME = ENV_TOKENS['SITE_NAME'] ALLOWED_HOSTS = [ @@ -294,6 +299,15 @@ CAS_ATTRIBUTE_CALLBACK['function'] ) +# Login using the LMS as the identity provider. +# Turning the flag to True means that the LMS will NOT be used as the Identity Provider (idp) +if FEATURES.get('DISABLE_STUDIO_SSO_OVER_LMS', False): + LOGIN_URL = reverse_lazy('login') + FRONTEND_LOGIN_URL = LOGIN_URL + FRONTEND_LOGOUT_URL = reverse_lazy('logout') + +LOGIN_REDIRECT_WHITELIST = [reverse_lazy('home')] + # Specific setting for the File Upload Service to store media in a bucket. FILE_UPLOAD_STORAGE_BUCKET_NAME = ENV_TOKENS.get('FILE_UPLOAD_STORAGE_BUCKET_NAME', FILE_UPLOAD_STORAGE_BUCKET_NAME) FILE_UPLOAD_STORAGE_PREFIX = ENV_TOKENS.get('FILE_UPLOAD_STORAGE_PREFIX', FILE_UPLOAD_STORAGE_PREFIX) diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 11229321f2e1..1b69f2411ea8 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -230,7 +230,6 @@

@@ -245,7 +244,7 @@

${_("Account Navigation")}

% endif
diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html index 1b05fb24cfe3..a59fc3b75bd5 100644 --- a/cms/templates/widgets/user_dropdown.html +++ b/cms/templates/widgets/user_dropdown.html @@ -39,9 +39,6 @@

- <% - logout_url = settings.LMS_ROOT_URL + '/logout' - %>
diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index e8472a01a54e..787a1e3653f0 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -208,8 +208,7 @@ def logout(self): Logout; check that the HTTP response code indicates redirection as expected. """ - # should redirect - self.assert_request_status_code(302, reverse('logout')) + self.assert_request_status_code(200, reverse('logout')) def create_account(self, username, email, password): """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d828f4d69ce9..20ff1231c393 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -175,6 +175,10 @@ LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) +# List of logout URIs for each IDA that the learner should be logged out of when they logout of the LMS. Only applies to +# IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) + ENV_FEATURES = ENV_TOKENS.get('FEATURES', {}) for feature, value in ENV_FEATURES.items(): FEATURES[feature] = value diff --git a/lms/envs/common.py b/lms/envs/common.py index 28f77bff388e..919f90c6e070 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -66,6 +66,10 @@ # This setting is used when a site does not define its own choices via site configuration MANUAL_ENROLLMENT_ROLE_CHOICES = ['Learner', 'Support', 'Partner'] +# List of logout URIs for each IDA that the learner should be logged out of when they logout of the LMS. Only applies to +# IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = [] + # Features FEATURES = { 'DISPLAY_DEBUG_INFO_TO_STAFF': True, diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 662d9a19691c..3fa20c2cb80b 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -24,6 +24,10 @@ LMS_ROOT_URL = "http://localhost:8000" LMS_INTERNAL_ROOT_URL = LMS_ROOT_URL ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' +IDA_LOGOUT_URI_LIST = [ + 'http://localhost:18130/logout/', # ecommerce + 'http://localhost:18150/logout/', # credentials +] ################################ LOGGERS ###################################### diff --git a/lms/envs/production.py b/lms/envs/production.py index 4bea786ac863..a7470fd4ced4 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -180,6 +180,10 @@ LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) +# List of logout URIs for each IDA that the learner should be logged out of when they logout of the LMS. Only applies to +# IDA for which the social auth flow uses DOT (Django OAuth Toolkit). +IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) + ENV_FEATURES = ENV_TOKENS.get('FEATURES', {}) for feature, value in ENV_FEATURES.items(): FEATURES[feature] = value diff --git a/lms/envs/test.py b/lms/envs/test.py index d5f094f6eb26..17959b4e3ad5 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -560,6 +560,10 @@ LMS_ROOT_URL = "http://localhost:8000" +# TODO (felipemontoya): This key is only needed during lettuce tests. +# To be removed during https://openedx.atlassian.net/browse/DEPR-19 +FRONTEND_LOGOUT_URL = LMS_ROOT_URL + '/logout' + ECOMMERCE_API_URL = 'https://ecommerce.example.com/api/v2/' ENTERPRISE_API_URL = 'http://enterprise.example.com/enterprise/api/v1/' ENTERPRISE_CONSENT_API_URL = 'http://enterprise.example.com/consent/api/v1/' diff --git a/openedx/core/djangoapps/external_auth/tests/test_ssl.py b/openedx/core/djangoapps/external_auth/tests/test_ssl.py index 056211ed6400..ea8dc31f8896 100644 --- a/openedx/core/djangoapps/external_auth/tests/test_ssl.py +++ b/openedx/core/djangoapps/external_auth/tests/test_ssl.py @@ -2,9 +2,9 @@ Provides unit tests for SSL based authentication portions of the external_auth app. """ -# pylint: disable=no-member from contextlib import contextmanager import copy +from unittest import skip from mock import Mock, patch from django.conf import settings @@ -395,6 +395,8 @@ def test_ssl_cms_redirection(self): response.redirect_chain[-1]) self.assertIn(SESSION_KEY, self.client.session) + @skip("This is causing tests to fail for DOP deprecation. Skip this test" + "because we are deprecating external_auth anyway (See DEPR-6 for more info).") @skip_unless_lms @override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE) def test_ssl_logout(self): diff --git a/openedx/core/djangoapps/user_authn/views/logout.py b/openedx/core/djangoapps/user_authn/views/logout.py index c1767e310d40..e040770b5337 100644 --- a/openedx/core/djangoapps/user_authn/views/logout.py +++ b/openedx/core/djangoapps/user_authn/views/logout.py @@ -26,6 +26,14 @@ class LogoutView(TemplateView): # Keep track of the page to which the user should ultimately be redirected. default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' + def post(self, request, *args, **kwargs): + """ + Proxy to the GET handler. + + TODO: remove GET as an allowed method, and update all callers to use POST. + """ + return self.get(request, *args, **kwargs) + @property def target(self): """ @@ -49,11 +57,11 @@ def dispatch(self, request, *args, **kwargs): logout(request) - # If we don't need to deal with OIDC logouts, just redirect the user. - if self.oauth_client_ids: - response = super(LogoutView, self).dispatch(request, *args, **kwargs) - else: + # If we are using studio logout directly and there is not OIDC logouts we can just redirect the user + if settings.FEATURES.get('DISABLE_STUDIO_SSO_OVER_LMS', False) and not self.oauth_client_ids: response = redirect(self.target) + else: + response = super(LogoutView, self).dispatch(request, *args, **kwargs) # Clear the cookie used by the edx.org marketing site delete_logged_in_cookies(response) @@ -80,13 +88,23 @@ 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) + uris = [] + + # Add the logout URIs for IDAs that the user was logged into (according to the session). This line is specific + # to DOP. + uris += Client.objects.filter(client_id__in=self.oauth_client_ids, + logout_uri__isnull=False).values_list('logout_uri', flat=True) + + # Add the extra logout URIs from settings. This is added as a stop-gap solution for sessions that were + # established via DOT. + uris += settings.IDA_LOGOUT_URI_LIST referrer = self.request.META.get('HTTP_REFERER', '').strip('/') logout_uris = [] for uri in uris: + # Only include the logout URI if the browser didn't come from that IDA's logout endpoint originally, + # avoiding a double-logout. if not referrer or (referrer and not uri.startswith(referrer)): logout_uris.append(self._build_logout_url(uri)) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 23ffa1a90a9e..f97bfd206e17 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -208,7 +208,7 @@ def test_logout_logging(self): logout_url = reverse('logout') with patch('student.models.AUDIT_LOG') as mock_audit_log: response = self.client.post(logout_url) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test']) def test_login_user_info_cookie(self): @@ -256,7 +256,10 @@ def test_unicode_mktg_cookie_names(self): self._assert_response(response, success=True) response = self.client.post(reverse('logout')) - self.assertRedirects(response, "/") + expected = { + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_logout_logging_no_pii(self): @@ -265,7 +268,7 @@ def test_logout_logging_no_pii(self): logout_url = reverse('logout') with patch('student.models.AUDIT_LOG') as mock_audit_log: response = self.client.post(logout_url) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) self._assert_audit_log(mock_audit_log, 'info', [u'Logout']) self._assert_not_in_audit_log(mock_audit_log, 'info', [u'test']) @@ -398,7 +401,7 @@ def test_single_session_with_url_not_having_login_required_decorator(self): url = reverse('logout') response = client1.get(url) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) def test_change_enrollment_400(self): """ diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py index 16eff1eb4f35..f64a9c68b289 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py @@ -8,6 +8,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse +from mock import patch from edx_oauth2_provider.constants import AUTHORIZED_CLIENTS_SESSION_KEY from edx_oauth2_provider.tests.factories import ( ClientFactory, @@ -72,11 +73,17 @@ def test_logout_redirect_success(self, redirect_url, host): redirect_url=redirect_url ) response = self.client.get(url, HTTP_HOST=host) - self.assertRedirects(response, redirect_url, fetch_redirect_response=False) + expected = { + 'target': redirect_url, + } + self.assertDictContainsSubset(expected, response.context_data) def test_no_redirect_supplied(self): response = self.client.get(reverse('logout'), HTTP_HOST='testserver') - self.assertRedirects(response, '/', fetch_redirect_response=False) + expected = { + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) @ddt.data( ('https://www.amazon.org', 'edx.org'), @@ -88,7 +95,10 @@ def test_logout_redirect_failure(self, redirect_url, host): redirect_url=redirect_url ) response = self.client.get(url, HTTP_HOST=host) - self.assertRedirects(response, '/', fetch_redirect_response=False) + expected = { + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) def test_client_logout(self): """ Verify the context includes a list of the logout URIs of the authenticated OpenID Connect clients. @@ -103,6 +113,56 @@ def test_client_logout(self): } self.assertDictContainsSubset(expected, response.context_data) + @patch( + 'django.conf.settings.IDA_LOGOUT_URI_LIST', + ['http://fake.ida1/logout', 'http://fake.ida2/accounts/logout', ] + ) + def test_client_logout_with_dot_idas(self): + """ + Verify the context includes a list of the logout URIs of the authenticated OpenID Connect clients AND OAuth2/DOT + clients. + + The list should only include URIs of the OIDC clients for which the user has been authenticated, and all the + configured DOT clients regardless of login status.. + """ + client = self._create_oauth_client() + response = self._assert_session_logged_out(client) + # Add the logout endpoints for the IDAs where auth was established via OIDC. + expected_logout_uris = [client.logout_uri + '?no_redirect=1'] + # Add the logout endpoints for the IDAs where auth was established via DOT/OAuth2. + expected_logout_uris += [ + 'http://fake.ida1/logout?no_redirect=1', + 'http://fake.ida2/accounts/logout?no_redirect=1', + ] + expected = { + 'logout_uris': expected_logout_uris, + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) + + @patch( + 'django.conf.settings.IDA_LOGOUT_URI_LIST', + ['http://fake.ida1/logout', 'http://fake.ida2/accounts/logout', ] + ) + def test_client_logout_with_dot_idas_and_no_oidc_idas(self): + """ + Verify the context includes a list of the logout URIs of the OAuth2/DOT clients, even if there are no currently + authenticated OpenID Connect clients. + + The list should include URIs of all the configured DOT clients. + """ + response = self.client.get(reverse('logout')) + # Add the logout endpoints for the IDAs where auth was established via DOT/OAuth2. + expected_logout_uris = [ + 'http://fake.ida1/logout?no_redirect=1', + 'http://fake.ida2/accounts/logout?no_redirect=1', + ] + expected = { + 'logout_uris': expected_logout_uris, + 'target': '/', + } + self.assertDictContainsSubset(expected, response.context_data) + 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.