diff --git a/authens/backends.py b/authens/backends.py index 6033aad..d5d1b74 100644 --- a/authens/backends.py +++ b/authens/backends.py @@ -60,6 +60,8 @@ class ENSCASBackend: cas_login = self.clean_cas_login(cas_login) year = get_entrance_year(attributes) + if request: + request.session["CASCONNECTED"] = True return self._get_or_create(cas_login, year) def clean_cas_login(self, cas_login): diff --git a/authens/tests/test_views.py b/authens/tests/test_views.py new file mode 100644 index 0000000..94d27c2 --- /dev/null +++ b/authens/tests/test_views.py @@ -0,0 +1,69 @@ +from unittest.mock import patch +from urllib.parse import quote as urlquote + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sessions.models import Session +from django.test import Client, TestCase +from django.urls import reverse + +from authens.models import CASAccount +from authens.tests.cas_utils import FakeCASClient + +UserModel = get_user_model() + + +class TestLogoutView(TestCase): + def test_regular_logout(self): + # Regular user (without a CAS account) + user = UserModel.objects.create_user(username="johndoe") + + # Log the user in + client = Client() + client.force_login(user) + + self.assertEqual(Session.objects.count(), 1) + response = client.get("/authens/logout") + self.assertEqual(Session.objects.count(), 0) # User is actually logged out. + self.assertRedirects(response, settings.LOGOUT_REDIRECT_URL) + + @patch("authens.backends.get_cas_client") + def test_cas_logout(self, mock_cas_client): + # Make `get_cas_client` return a dummy CAS client that skips ticket verification + # and always log in a user with CAS login 'johndoe'. + # This is only used for login. + mock_cas_client.return_value = FakeCASClient("johndoe", 2019) + + # CAS user + user = UserModel.objects.create_user(username="johndoe") + CASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2019) + + # Log the user in via CAS + client = Client() + client.get("/authens/login/cas?ticket=dummy-ticket") + + self.assertEqual(Session.objects.count(), 1) + response = client.get("/authens/logout") + self.assertEqual(Session.objects.count(), 0) # User is logged out… + self.assertRedirects( # … and redirected to the CAS logout page. + response, + "https://cas.eleves.ens.fr/logout?service={}".format( + urlquote("http://testserver" + reverse("authens:login")) + ), + fetch_redirect_response=False, + ) + + def test_regular_logout_on_cas_account(self): + # CAS user + user = UserModel.objects.create_user(username="johndoe", password="p4ssw0rd") + CASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2019) + + # Log the user in by password and *not* via CAS + client = Client() + client.login(username="johndoe", password="p4ssw0rd") + + self.assertEqual(Session.objects.count(), 1) + response = client.get("/authens/logout") + self.assertEqual(Session.objects.count(), 0) # User is logged out… + # … and not redirected to the CAS logout page. + self.assertRedirects(response, settings.LOGOUT_REDIRECT_URL) diff --git a/authens/urls.py b/authens/urls.py index f28100f..7b13310 100644 --- a/authens/urls.py +++ b/authens/urls.py @@ -1,4 +1,3 @@ -from django.contrib.auth import views as auth_views from django.urls import path from authens import views @@ -8,5 +7,5 @@ urlpatterns = [ path("login/choose", views.LoginSwitchView.as_view(), name="login"), path("login/cas", views.CASLoginView.as_view(), name="login.cas"), path("login/pwd", views.PasswordLoginView.as_view(), name="login.pwd"), - path("logout", auth_views.LogoutView.as_view(), name="logout"), + path("logout", views.LogoutView.as_view(), name="logout"), ] diff --git a/authens/views.py b/authens/views.py index 06b80d5..14ead4e 100644 --- a/authens/views.py +++ b/authens/views.py @@ -1,5 +1,8 @@ +from urllib.parse import urlparse, urlunparse + from django.conf import settings from django.contrib import auth +from django.contrib.auth import views as auth_views from django.core.exceptions import PermissionDenied from django.views.generic import TemplateView, View from django.shortcuts import redirect @@ -72,5 +75,37 @@ class CASLoginView(NextPageMixin, View): return redirect(self.get_next_url()) -class PasswordLoginView(auth.views.LoginView): +class PasswordLoginView(auth_views.LoginView): template_name = "authens/pwd_login.html" + + +class LogoutView(auth_views.LogoutView): + """Logout view of AuthENS. + + Tell Django to log the user out, then redirect to the CAS logout page if the user + logged in via CAS. + """ + + def setup(self, request): + super().setup(request) + if "CASCONNECTED" in request.session: + del request.session["CASCONNECTED"] + self.cas_connected = True + else: + self.cas_connected = False + + def get_next_page(self): + next_page = super().get_next_page() + if self.cas_connected: + cas_client = get_cas_client(self.request) + + # If the next_url is local (no hostname), make it absolute so that the user + # is correctly redirected from CAS. + if not urlparse(next_page).netloc: + request = self.request + next_page = urlunparse( + (request.scheme, request.get_host(), next_page, "", "", "") + ) + + next_page = cas_client.get_logout_url(redirect_url=next_page) + return next_page diff --git a/tests/settings.py b/tests/settings.py index 999e477..04168fd 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -8,6 +8,7 @@ SECRET_KEY = "dummy" INSTALLED_APPS = [ "django.contrib.contenttypes", "django.contrib.auth", + "django.contrib.sessions", "authens", "tests", ] @@ -17,6 +18,34 @@ AUTHENTICATION_BACKENDS = [ "authens.backends.ENSCASBackend", ] +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} +ROOT_URLCONF = "tests.urls" LOGIN_URL = reverse_lazy("authens:login") +LOGOUT_REDIRECT_URL = reverse_lazy("authens:login") diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..2b6ab10 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,4 @@ +from django.urls import include, path + + +urlpatterns = [path("authens/", include("authens.urls"))]