diff --git a/AUTHORS.md b/AUTHORS.md index 202f2c1296..59e31f90f6 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -15,7 +15,12 @@ A list of much-appreciated contributors who have submitted patches and reported * Loic Bonavent, University of Montpellier * Guillaume Condesse, University of Bordeaux * Franck Charneau and Joshua Baubry, University of La Rochelle -* Olivier Bado, University Cote d'Azur +* Olivier Bado-Faustin, University Cote d'Azur * Frederic Sene, INSA Rennes * Nicolas Lahoche, University of Lille (design and template) * Charlotte Benard (Logo and color) + +Pictures credits +---------------- +* default.svg: adapted from Play button Icon by [Freepik](https://www.freepik.com/free-vector) - Freepik License +* cookie.svg: [Broken oatmeal cookie created by pch.vector](https://www.freepik.com/vectors/logo) - Freepik License diff --git a/README.md b/README.md index 0a16da4d80..9ff2843ffc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,3 @@ Le projet et la plateforme qui porte le même nom ont pour but de faciliter la m | | Ministère de lʼEnseignement supérieur, de la Recherche et de lʼInnovation :-----:|:-----:|:----: - -## Crédits tiers -* static/default.svg : Icon made by [Freepik](https://www.freepik.com) diff --git a/pod/authentication/shibmiddleware.py b/pod/authentication/shibmiddleware.py index c00bb04657..5bd85cdb53 100644 --- a/pod/authentication/shibmiddleware.py +++ b/pod/authentication/shibmiddleware.py @@ -3,6 +3,93 @@ REMOTE_USER_HEADER = getattr(settings, "REMOTE_USER_HEADER", "REMOTE_USER") +SHIBBOLETH_ATTRIBUTE_MAP = getattr( + settings, + "SHIBBOLETH_ATTRIBUTE_MAP", + { + "REMOTE_USER": (True, "username"), + "Shibboleth-givenName": (True, "first_name"), + "Shibboleth-sn": (False, "last_name"), + "Shibboleth-mail": (False, "email"), + "Shibboleth-primary-affiliation": (False, "affiliation"), + "Shibboleth-unscoped-affiliation": (False, "affiliations"), + }, +) + +SHIBBOLETH_STAFF_ALLOWED_DOMAINS = getattr( + settings, "SHIBBOLETH_STAFF_ALLOWED_DOMAINS", None +) + +AFFILIATION = getattr( + settings, + "AFFILIATION", + ( + ("student", ""), + ("faculty", ""), + ("staff", ""), + ("employee", ""), + ("member", ""), + ("affiliate", ""), + ("alum", ""), + ("library-walk-in", ""), + ("researcher", ""), + ("retired", ""), + ("emeritus", ""), + ("teacher", ""), + ("registered-reader", ""), + ), +) + +AFFILIATION_STAFF = getattr( + settings, "AFFILIATION_STAFF", ("employee", "faculty", "staff") +) + class ShibbMiddleware(ShibbolethRemoteUserMiddleware): header = REMOTE_USER_HEADER + + def check_user_meta(self, user, shib_meta): + """Check shibboleth access rights with user's meta + + Args: + user: User, + shib_meta dict + Returns: + bool + """ + return ( + user + and user.owner + and shib_meta["affiliation"] in [A[0] for A in AFFILIATION] + and user.owner.affiliation != shib_meta["affiliation"] + ) + + def is_staffable(self, user): + """Check that given user, his domain is in authorized domains of shibboleth staff + + Args: + user: User + Returns: + bool + """ + if ( + SHIBBOLETH_STAFF_ALLOWED_DOMAINS is None + or len(SHIBBOLETH_STAFF_ALLOWED_DOMAINS) == 0 + ): + return True + for d in SHIBBOLETH_STAFF_ALLOWED_DOMAINS: + if user.username.endswith("@" + d): + return True + return False + + def make_profile(self, user, shib_meta): + if ("affiliation" in shib_meta) and self.check_user_meta(user, shib_meta): + user.owner.affiliation = shib_meta["affiliation"] + user.owner.save() + if self.is_staffable(user) and "affiliations" in shib_meta: + for affiliation in shib_meta["affiliations"].split(";"): + if affiliation in AFFILIATION_STAFF: + user.is_staff = True + user.save() + break + return diff --git a/pod/authentication/templates/authentication/login.html b/pod/authentication/templates/authentication/login.html index da92835aa5..63fe9b8bd8 100644 --- a/pod/authentication/templates/authentication/login.html +++ b/pod/authentication/templates/authentication/login.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load i18n static custom_tags %} {% block page_extra_head %} - + {% endblock %} {% block page_title %}{% trans 'Authentication' %}{% endblock %} diff --git a/pod/authentication/templates/registration/login.html b/pod/authentication/templates/registration/login.html index 4096349e45..e08300ef12 100644 --- a/pod/authentication/templates/registration/login.html +++ b/pod/authentication/templates/registration/login.html @@ -2,8 +2,10 @@ {% load i18n static %} {% block page_extra_head %} -{{ block.super }} -{{ form.media }} + {{ block.super }} + + + {{ form.media }} {% endblock %} {% block page_title %}{% trans 'Log in' %}{% endblock %} diff --git a/pod/authentication/tests/test_populated.py b/pod/authentication/tests/test_populated.py index 435be36a46..45dad80bc5 100644 --- a/pod/authentication/tests/test_populated.py +++ b/pod/authentication/tests/test_populated.py @@ -3,6 +3,9 @@ from django.test import TestCase, override_settings from pod.authentication.models import Owner, AccessGroup from pod.authentication import populatedCASbackend +from pod.authentication import shibmiddleware +from pod.authentication.backends import ShibbBackend +from django.test import RequestFactory from django.contrib.auth.models import User from importlib import reload from xml.etree import ElementTree as ET @@ -46,6 +49,35 @@ }, ) +REMOTE_USER_HEADER = getattr(settings, "REMOTE_USER_HEADER", "REMOTE_USER") + +SHIBBOLETH_ATTRIBUTE_MAP = getattr( + settings, + "SHIBBOLETH_ATTRIBUTE_MAP", + { + "REMOTE_USER": (True, "username"), + "Shibboleth-givenName": (True, "first_name"), + "Shibboleth-sn": (False, "last_name"), + "Shibboleth-mail": (False, "email"), + "Shibboleth-primary-affiliation": (False, "affiliation"), + "Shibboleth-unscoped-affiliation": (False, "affiliations"), + }, +) + +SHIBBOLETH_STAFF_ALLOWED_DOMAINS = getattr( + settings, "SHIBBOLETH_STAFF_ALLOWED_DOMAINS", None +) + +AFFILIATION_STAFF = getattr( + settings, "AFFILIATION_STAFF", ("employee", "faculty", "staff") +) + +SHIB_URL = getattr(settings, "SHIB_URL", "https://univ.fr/Shibboleth.sso/WAYF") + +SHIB_LOGOUT_URL = getattr( + settings, "SHIB_LOGOUT_URL", "https://univ.fr/Shibboleth.sso/Logout" +) + class PopulatedCASTestCase(TestCase): # populate_user_from_tree(user, owner, tree) @@ -276,3 +308,137 @@ def test_populate_user_from_entry_affiliation_group(self): " ---> test_populate_user_from_entry_affiliation_group" " of PopulatedLDAPTestCase : OK !" ) + + +class PopulatedShibTestCase(TestCase): + def setUp(self): + """setUp PopulatedShibTestCase create user pod""" + self.hmap = {} + for a in SHIBBOLETH_ATTRIBUTE_MAP: + self.hmap[SHIBBOLETH_ATTRIBUTE_MAP[a][1]] = a + # print(SHIBBOLETH_ATTRIBUTE_MAP[a][1] + ' > ' + a) + + print(" ---> SetUp of PopulatedShibTestCase : OK !") + + def _authenticate_shib_user(self, u): + """Simulate shibboleth header""" + fake_shib_header = { + "REMOTE_USER": u["username"], + self.hmap["username"]: u["username"], + self.hmap["email"]: u["email"], + self.hmap["first_name"]: u["first_name"], + self.hmap["last_name"]: u["last_name"], + } + if "groups" in u.keys(): + fake_shib_header[self.hmap["groups"]] = u["groups"] + if "affiliations" in u.keys(): + fake_shib_header[self.hmap["affiliation"]] = u["affiliations"].split(";")[0] + fake_shib_header[self.hmap["affiliations"]] = u["affiliations"] + + """ Get valid shib_meta from simulated shibboleth header """ + request = RequestFactory().get("/", REMOTE_USER=u["username"]) + request.META.update(**fake_shib_header) + shib_meta, error = shibmiddleware.ShibbMiddleware.parse_attributes(request) + self.assertFalse(error, "Generating shibboleth attribute mapping contains errors") + + """ Check user authentication """ + user = ShibbBackend.authenticate( + ShibbBackend(), + request=request, + remote_user=u["username"], + shib_meta=shib_meta, + ) + self.assertTrue(user.is_authenticated()) + + return (user, shib_meta) + + @override_settings(DEBUG=False) + def test_make_profile(self): + """Test if user attributes are retreived""" + user, shib_meta = self._authenticate_shib_user( + { + "username": "jdo@univ.fr", + "first_name": "John", + "last_name": "Do", + "email": "john.do@univ.fr", + "affiliations": "teacher;staff;member", + } + ) + self.assertEqual(user.username, "jdo@univ.fr") + self.assertEqual(user.email, "john.do@univ.fr") + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Do") + + """ Test if user can be staff if SHIBBOLETH_STAFF_ALLOWED_DOMAINS is None """ + settings.SHIBBOLETH_STAFF_ALLOWED_DOMAINS = None + reload(shibmiddleware) + shibmiddleware.ShibbMiddleware.make_profile( + shibmiddleware.ShibbMiddleware(), user, shib_meta + ) + self.assertTrue(user.is_staff) + + owner = Owner.objects.get(user__username="jdo@univ.fr") + self.assertEqual(owner.affiliation, "teacher") + + """ Test if user can be staff when SHIBBOLETH_STAFF_ALLOWED_DOMAINS + is restricted """ + settings.SHIBBOLETH_STAFF_ALLOWED_DOMAINS = ( + "univ-a.fr", + "univ-b.fr", + ) + user.is_staff = False # Staff status is not remove + user.save() + reload(shibmiddleware) + shibmiddleware.ShibbMiddleware.make_profile( + shibmiddleware.ShibbMiddleware(), user, shib_meta + ) + self.assertFalse(user.is_staff) + + """ Test if user become staff when SHIBBOLETH_STAFF_ALLOWED_DOMAINS + is restrict and contains his domain """ + settings.SHIBBOLETH_STAFF_ALLOWED_DOMAINS = ("univ.fr",) + reload(shibmiddleware) + shibmiddleware.ShibbMiddleware.make_profile( + shibmiddleware.ShibbMiddleware(), user, shib_meta + ) + self.assertTrue(user.is_staff) + + """ Test if same user with new unstaffable affiliation keep his staff status """ + unstaffable_affiliation = ["member", "unprobable"] + for a in unstaffable_affiliation: + self.assertFalse(a in AFFILIATION_STAFF) + user, shib_meta = self._authenticate_shib_user( + { + "username": "jdo@univ.fr", + "first_name": "Jean", + "last_name": "Do", + "email": "jean.do@univ.fr", + "affiliations": ";".join(unstaffable_affiliation), + } + ) + shibmiddleware.ShibbMiddleware.make_profile( + shibmiddleware.ShibbMiddleware(), user, shib_meta + ) + self.assertTrue(user.is_staff) # Staff status is not remove + + """ Test if the main affiliation of this same user + with new unstaffable affiliation has changed """ + owner = Owner.objects.get(user__username="jdo@univ.fr") + self.assertEqual(owner.affiliation, "member") + + """ Test if a new user with same unstaffable affiliations has no staff status""" + user, shib_meta = self._authenticate_shib_user( + { + "username": "ada@univ.fr", + "first_name": "Ada", + "last_name": "Da", + "email": "ada.da@univ.fr", + "affiliations": ";".join(unstaffable_affiliation), + } + ) + shibmiddleware.ShibbMiddleware.make_profile( + shibmiddleware.ShibbMiddleware(), user, shib_meta + ) + self.assertFalse(user.is_staff) + + print(" ---> test_make_profile" " of PopulatedShibTestCase : OK !") diff --git a/pod/authentication/tests/test_views.py b/pod/authentication/tests/test_views.py index 699d3a1894..549f2a18d9 100644 --- a/pod/authentication/tests/test_views.py +++ b/pod/authentication/tests/test_views.py @@ -1,9 +1,12 @@ -""" -Unit tests for authentication views +"""Unit tests for authentication views. + +* run with 'python manage.py test pod.authentication.tests.test_views' """ from django.test import TestCase from django.test import Client from django.contrib.auth.models import User +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ class authenticationViewsTestCase(TestCase): @@ -13,7 +16,7 @@ class authenticationViewsTestCase(TestCase): def setUp(self): User.objects.create(username="pod", password="podv2") - print(" ---> SetUp of authenticationViewsTestCase : OK !") + print(" ---> SetUp of authenticationViewsTestCase: OK!") def test_authentication_login_gateway(self): self.client = Client() @@ -27,26 +30,28 @@ def test_authentication_login_gateway(self): print( " ---> test_authentication_login_gateway \ - of authenticationViewsTestCase : OK !" + of authenticationViewsTestCase: OK!" ) def test_authentication_login(self): + """Test authentication login page.""" self.client = Client() self.user = User.objects.get(username="pod") + login_url = settings.LOGIN_URL # User already authenticated self.client.force_login(self.user) - response = self.client.get("/authentication_login/") + response = self.client.get(login_url) self.assertRedirects(response, "/") # User not authenticated and CAS are valued to False self.client.logout() - response = self.client.get("/authentication_login/") + response = self.client.get(login_url) self.assertRedirects(response, "/accounts/login/?next=/") print( " ---> test_authentication_login \ - of authenticationViewsTestCase : OK !" + of authenticationViewsTestCase: OK!" ) def test_authentication_logout(self): @@ -57,7 +62,7 @@ def test_authentication_logout(self): print( " ---> test_authentication_logout \ - of authenticationViewsTestCase : OK !" + of authenticationViewsTestCase: OK!" ) def test_userpicture(self): @@ -89,9 +94,9 @@ def test_userpicture(self): messages = list(response.wsgi_request._messages) self.assertEqual(len(messages), 1) self.assertEqual( - str(messages[0]), "One or more errors have been found in the form." + str(messages[0]), _("One or more errors have been found in the form.") ) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "userpicture/userpicture.html") - print(" ---> test_userpicture of authenticationViewsTestCase : OK !") + print(" ---> test_userpicture of authenticationViewsTestCase: OK!") diff --git a/pod/authentication/utils.py b/pod/authentication/utils.py index 70a6226ab0..74f5f62e9d 100644 --- a/pod/authentication/utils.py +++ b/pod/authentication/utils.py @@ -6,7 +6,8 @@ def get_owners(search, limit, offset): - """Return owners filtered by GET parameters 'q' + """Return owners filtered by GET parameters 'q'. + With limit and offset Args: diff --git a/pod/authentication/views.py b/pod/authentication/views.py index a38f652cd5..2f994b2f71 100644 --- a/pod/authentication/views.py +++ b/pod/authentication/views.py @@ -1,3 +1,4 @@ +"""Authentication views.""" from django.http import HttpResponse from django.shortcuts import render from django.shortcuts import redirect @@ -49,10 +50,12 @@ def authentication_login_gateway(request): else: def authentication_login_gateway(request): + """Login gateway when CAS_GATEWAY is not defined.""" return HttpResponse("You must set CAS_GATEWAY to True to use this view") def authentication_login(request): + """Handle authentication login attempt.""" referrer = request.GET["referrer"] if request.GET.get("referrer") else "/" host = ( "https://%s" % request.get_host() @@ -66,7 +69,7 @@ def authentication_login(request): return redirect(referrer) if USE_CAS and CAS_GATEWAY: url = reverse("authentication_login_gateway") - url += "?%snext=%s" % (iframe_param, referrer) + url += "?%snext=%s" % (iframe_param, referrer.replace("&", "%26")) return redirect(url) elif USE_CAS or USE_SHIB or USE_OIDC: return render( @@ -83,17 +86,19 @@ def authentication_login(request): ) else: url = reverse("local-login") - url += "?%snext=%s" % (iframe_param, referrer) + url += "?%snext=%s" % (iframe_param, referrer.replace("&", "%26")) return redirect(url) def local_logout(request): + """Logout a user connected locally.""" url = reverse("local-logout") url += "?next=/" return redirect(url) def authentication_logout(request): + """Logout a user.""" if request.user.is_anonymous(): return local_logout(request) if request.user.owner.auth_type == "CAS": diff --git a/pod/chapter/templates/video_chapter.html b/pod/chapter/templates/video_chapter.html index 313f622513..6e8769b0d6 100644 --- a/pod/chapter/templates/video_chapter.html +++ b/pod/chapter/templates/video_chapter.html @@ -8,7 +8,7 @@ {% include 'videos/video-header.html' %} {% endblock page_extra_head %} {% block breadcrumbs %} @@ -55,9 +55,9 @@ {% endblock page_content %} {% block page_aside %} {% if video.owner == request.user or request.user.is_superuser or perms.chapter.add_chapter or request.user in video.additional_owners.all %} -
{% trans "Load Existing Caption File [optional]:" %} -
-