From 4bce9023d503b245e27deaff0ea257c81d4a7104 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Fri, 24 May 2019 14:27:23 -0400 Subject: [PATCH 1/9] Add callback function for server url, route to different login servers based on service value --- cas/backends.py | 8 ++++---- cas/models.py | 3 ++- cas/utils.py | 19 ++++++++++++++++++- cas/views.py | 5 +++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cas/backends.py b/cas/backends.py index cecc62f..de69c23 100644 --- a/cas/backends.py +++ b/cas/backends.py @@ -26,7 +26,7 @@ from cas.exceptions import CasTicketException from cas.models import Tgt, PgtIOU -from cas.utils import cas_response_callbacks +from cas.utils import cas_response_callbacks, get_cas_server_url __all__ = ['CASBackend'] @@ -44,7 +44,7 @@ def _verify_cas1(ticket, service): """ params = {'ticket': ticket, 'service': service} - url = (urljoin(settings.CAS_SERVER_URL, 'validate') + '?' + + url = (urljoin(get_cas_server_url(service), 'validate') + '?' + urlencode(params)) page = urlopen(url) @@ -82,7 +82,7 @@ def _internal_verify_cas(ticket, service, suffix): if settings.CAS_PROXY_CALLBACK: params['pgtUrl'] = settings.CAS_PROXY_CALLBACK - url = (urljoin(settings.CAS_SERVER_URL, suffix) + '?' + + url = (urljoin(get_cas_server_url(service), suffix) + '?' + urlencode(params)) page = urlopen(url) @@ -149,7 +149,7 @@ def verify_proxy_ticket(ticket, service): params = {'ticket': ticket, 'service': service} - url = (urljoin(settings.CAS_SERVER_URL, 'proxyValidate') + '?' + + url = (urljoin(get_cas_server_url(service), 'proxyValidate') + '?' + urlencode(params)) page = urlopen(url) diff --git a/cas/models.py b/cas/models.py index 54b1606..5ec1759 100644 --- a/cas/models.py +++ b/cas/models.py @@ -24,6 +24,7 @@ from cas.exceptions import CasTicketException, CasConfigException +from cas.utils import get_cas_server_url logger = logging.getLogger(__name__) @@ -47,7 +48,7 @@ def get_proxy_ticket_for(self, service): params = {'pgt': self.tgt, 'targetService': service} - url = (urljoin(settings.CAS_SERVER_URL, 'proxy') + '?' + + url = (urljoin(get_cas_server_url(service), 'proxy') + '?' + urlencode(params)) page = urlopen(url) diff --git a/cas/utils.py b/cas/utils.py index f03e1c5..1617402 100644 --- a/cas/utils.py +++ b/cas/utils.py @@ -1,7 +1,7 @@ import logging from django.conf import settings - +from django.utils.module_loading import import_string logger = logging.getLogger(__name__) @@ -24,3 +24,20 @@ def cas_response_callbacks(tree): logger.error("Attribute Error: %s" % e) raise e func(tree) + +def get_cas_server_url(service): + try: + cas_server_url_callback = settings.CAS_SERVER_URL_CALLBACK + except AttributeError: + pass + else: + try: + callback = import_string(cas_server_url_callback) + except ImportError: + raise RuntimeError( + "Invalid callback for CAS_SERVER_URL_CALLBACK: {}".format( + cas_server_url_callback + ) + ) + return callback(service) + return settings.CAS_SERVER_URL diff --git a/cas/views.py b/cas/views.py index 14e250b..5642627 100644 --- a/cas/views.py +++ b/cas/views.py @@ -23,6 +23,7 @@ from django.core.urlresolvers import reverse from cas.models import PgtIOU +from cas.utils import get_cas_server_url __all__ = ['login', 'logout'] @@ -130,7 +131,7 @@ def _login_url(service, ticket='ST', gateway=False): login_type = LOGINS.get(ticket[:2], 'login') - return urlparse.urljoin(settings.CAS_SERVER_URL, login_type) + '?' + urlencode(params) + return urlparse.urljoin(get_cas_server_url(service), login_type) + '?' + urlencode(params) def _logout_url(request, next_page=None): @@ -142,7 +143,7 @@ def _logout_url(request, next_page=None): """ - url = urlparse.urljoin(settings.CAS_SERVER_URL, 'logout') + url = urlparse.urljoin(get_cas_server_url(service), 'logout') if next_page and getattr(settings, 'CAS_PROVIDE_URL_TO_LOGOUT', True): parsed_url = urlparse.urlparse(next_page) From 77ed794958c3f41d0db60979f529f1d1de5c9546 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Fri, 24 May 2019 14:45:35 -0400 Subject: [PATCH 2/9] Added tests --- cas/tests/test_views.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/cas/tests/test_views.py b/cas/tests/test_views.py index a3b734f..b1d5831 100644 --- a/cas/tests/test_views.py +++ b/cas/tests/test_views.py @@ -1,9 +1,15 @@ -from django.test import TestCase, RequestFactory +from django.test import TestCase, RequestFactory, override_settings from django.test.utils import override_settings from cas.views import _redirect_url, _login_url, _logout_url, _service_url +def custom_cas_server_url(service): + if 'secret' in service: + return 'http://secret.cas.com' + return 'http://signin.cas.com/' + + class RequestFactoryRemix(RequestFactory): path = '/' @@ -61,5 +67,18 @@ def test_login_url(self): self.assertEqual(_login_url('http://localhost:8000/accounts/login/'), 'http://signin.cas.com/login?service=http%3A%2F%2Flocalhost%3A8000%2Faccounts%2Flogin%2F') - def test_logout_url(self): - self.assertEqual(_logout_url(self.request), 'http://signin.cas.com/logout') + + @override_settings(CAS_SERVER_URL_CALLBACK='cas.tests.test_views.custom_cas_server_url') + def test_login_url_custom(self): + self.assertEqual(_login_url('http://localhost:8000/accounts/login/?return_url=/secret/'), + 'http://secret.cas.com/login?service=http%3A%2F%2Flocalhost%3A8000%2Faccounts%2Flogin%2F%3Freturn_url%3D%2Fsecret%2F') + + @override_settings(CAS_SERVER_URL_CALLBACK='cas.tests.test_views.custom_cas_server_url') + def test_login_url_custom_normal(self): + self.assertEqual(_login_url('http://localhost:8000/accounts/login/?return_url=/normal/'), + 'http://signin.cas.com/login?service=http%3A%2F%2Flocalhost%3A8000%2Faccounts%2Flogin%2F%3Freturn_url%3D%2Fnormal%2F') + + @override_settings(CAS_SERVER_URL_CALLBACK='cas.nonexistent.callback') + def test_login_url_bad_callback_raises_exception(self): + with self.assertRaises(RuntimeError): + _ = _login_url('http://localhost:8000/accounts/login/?return_url=/normal/') From 5be8ba509fdd4b7f123be258167b0a22b5306fb9 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Mon, 27 May 2019 08:24:49 -0400 Subject: [PATCH 3/9] Upgrade travis tests; all versions listed were obsolete --- .travis.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index a18521b..1ed0be8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,14 +2,30 @@ language: python python: - "2.7" - - "3.3" - "3.4" + - "3.5" + - "3.6" + - "3.7" env: - - DJANGO_VERSION=Django==1.5 - - DJANGO_VERSION=Django==1.6 - - DJANGO_VERSION=Django==1.7 - - DJANGO_VERSION=Django==1.8 + - DJANGO_VERSION="Django<2" + - DJANGO_VERSION="Django<2.1" + - DJANGO_VERSION="Django<2.2" + - DJANGO_VERSION="Django<2.3" + + +matrix: + exclude: + - python: "3.7" + env: DJANGO_VERSION="Django<2" + - python: "2.7" + env: DJANGO_VERSION="Django<2.1" + - python: "2.7" + env: DJANGO_VERSION="Django<2.2" + - python: "2.7" + env: DJANGO_VERSION="Django<2.3" + - python: "3.4" + env: DJANGO_VERSION="Django<2.2" # command to install dependencies install: From 80b74ef8e0fdebea2ad83ec80643bf0cf1193d82 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Mon, 27 May 2019 08:39:54 -0400 Subject: [PATCH 4/9] 3.4 only supported < 2.1 --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index 1ed0be8..1ce5664 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,9 @@ env: matrix: + include: + - python: "3.7" + dist: xenial exclude: - python: "3.7" env: DJANGO_VERSION="Django<2" @@ -26,6 +29,8 @@ matrix: env: DJANGO_VERSION="Django<2.3" - python: "3.4" env: DJANGO_VERSION="Django<2.2" + - python: "3.4" + env: DJANGO_VERSION="Django<2.3" # command to install dependencies install: From ef29546895c6e9a3fd13d6a6f5445284fe957383 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Mon, 27 May 2019 08:39:54 -0400 Subject: [PATCH 5/9] 3.4 only supported < 2.1 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 1ce5664..e77e4a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +dist: xenial language: python python: From 8207af4996c5edf43f976c7f316ca47c2ddaf864 Mon Sep 17 00:00:00 2001 From: "Jordan Reiter (dev.editlib.org)" Date: Tue, 4 Jun 2019 18:08:15 -0400 Subject: [PATCH 6/9] Provide a fake value for service from the current URL --- cas/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cas/views.py b/cas/views.py index 5642627..d4d61fc 100644 --- a/cas/views.py +++ b/cas/views.py @@ -143,6 +143,9 @@ def _logout_url(request, next_page=None): """ + protocol = ('http://', 'https://')[request.is_secure()] + service = protocol + request.get_host() + request.path + url = urlparse.urljoin(get_cas_server_url(service), 'logout') if next_page and getattr(settings, 'CAS_PROVIDE_URL_TO_LOGOUT', True): From 74e5c214b3fc8f3e5a34b11cd8701ebf519e66b1 Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Sun, 22 Mar 2020 16:57:45 -0400 Subject: [PATCH 7/9] Removed admin from test settings INSTALLED_APPS admin is not being used in the tests and prevents the current settings from working correctly --- run_tests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/run_tests.py b/run_tests.py index 1daa5f4..f0bca1f 100644 --- a/run_tests.py +++ b/run_tests.py @@ -20,7 +20,6 @@ INSTALLED_APPS=('django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', - 'django.contrib.admin', 'cas',), CAS_SERVER_URL = 'http://signin.cas.com', ) @@ -35,7 +34,6 @@ INSTALLED_APPS=('django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', - 'django.contrib.admin', 'cas',), USE_TZ=True, CAS_SERVER_URL = 'http://signin.cas.com',) From f8143beaf6bfbd50e711405457ac526c938bcef8 Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Sun, 22 Mar 2020 16:58:45 -0400 Subject: [PATCH 8/9] mock is now included in python Available as `unittest.mock` --- cas/tests/test_backend.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cas/tests/test_backend.py b/cas/tests/test_backend.py index d7ad9f0..4cf2022 100644 --- a/cas/tests/test_backend.py +++ b/cas/tests/test_backend.py @@ -1,4 +1,7 @@ -import mock +try: + from unittest import mock +except ImportError: + import mock from django.test import TestCase from cas.backends import CASBackend From ae1154396970ad3535508dc09d26487b56d5fdc1 Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Sun, 22 Mar 2020 18:19:27 -0400 Subject: [PATCH 9/9] Compatibility with Django > 1.10 Changes to reflect changes after Django 1.10: - login and logout are now class-based views in `django.contrib.auth.views` - added test_middleware to confirm middleware match requests for login and logout views --- cas/middleware.py | 11 +++++++-- cas/tests/test_middleware.py | 45 ++++++++++++++++++++++++++++++++++++ cas/tests/urls.py | 5 ++++ run_tests.py | 4 ++-- 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 cas/tests/test_middleware.py create mode 100644 cas/tests/urls.py diff --git a/cas/middleware.py b/cas/middleware.py index 2a3ea5d..b513b91 100644 --- a/cas/middleware.py +++ b/cas/middleware.py @@ -11,8 +11,10 @@ try: from django.contrib.auth.views import login, logout -except: - from django.contrib.auth import login, logout +except ImportError: + from django.contrib.auth.views import LoginView, LogoutView + login = LoginView.as_view().view_class + logout = LogoutView.as_view().view_class from django.http import HttpResponseRedirect, HttpResponseForbidden from django.core.exceptions import ImproperlyConfigured @@ -57,6 +59,11 @@ def process_view(self, request, view_func, view_args, view_kwargs): logout. """ + try: + view_func = view_func.view_class + except AttributeError: + pass + if view_func == login: return cas_login(request, *view_args, **view_kwargs) elif view_func == logout: diff --git a/cas/tests/test_middleware.py b/cas/tests/test_middleware.py new file mode 100644 index 0000000..ea1b4c9 --- /dev/null +++ b/cas/tests/test_middleware.py @@ -0,0 +1,45 @@ +try: + from unittest import mock +except ImportError: + import mock + +from urllib.parse import quote_plus, urlencode + +from django.conf import settings +from django.test import TestCase, Client, override_settings, modify_settings + + + +@override_settings(MIDDLEWARE=[ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'cas.middleware.CASMiddleware' +]) +class CASBackendTest(TestCase): + + def setUp(self): + from cas.tests import factories + self.user = factories.UserFactory.create() + self.client = Client() + + def test_login_calls_cas_login(self): + resp = self.client.get('/login/') + self.assertTrue(resp.has_header('Location')) + expected_url = '{}/login?{}'.format( + settings.CAS_SERVER_URL, + urlencode({ + 'service': 'http://testserver/login/?next={}'.format(quote_plus('/')) + }) + ) + self.assertRedirects(resp, expected_url, fetch_redirect_response=False) + + def test_logout_calls_cas_logout(self): + resp = self.client.get('/logout/') + self.assertTrue(resp.has_header('Location')) + expected_url = '{}/logout?{}'.format( + settings.CAS_SERVER_URL, + urlencode({ + 'service': 'http://testserver/' + }) + ) + self.assertRedirects(resp, expected_url, fetch_redirect_response=False) \ No newline at end of file diff --git a/cas/tests/urls.py b/cas/tests/urls.py new file mode 100644 index 0000000..6a3ae9a --- /dev/null +++ b/cas/tests/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path('', include('django.contrib.auth.urls')), +] diff --git a/run_tests.py b/run_tests.py index f0bca1f..9efffae 100644 --- a/run_tests.py +++ b/run_tests.py @@ -16,7 +16,7 @@ 'ENGINE': 'django.db.backends.sqlite3', } }, - #ROOT_URLCONF='mailqueue.urls', + ROOT_URLCONF='cas.tests.urls', INSTALLED_APPS=('django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -30,7 +30,7 @@ 'ENGINE': 'django.db.backends.sqlite3', } }, - #ROOT_URLCONF='mailqueue.urls', + ROOT_URLCONF='cas.tests.urls', INSTALLED_APPS=('django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions',