diff --git a/authens/backends.py b/authens/backends.py index 4b489fa..f606f1d 100644 --- a/authens/backends.py +++ b/authens/backends.py @@ -142,7 +142,7 @@ class ENSCASBackend: class OldCASBackend: """Authentication backend for old CAS accounts. - Given a CAS login, an entrance year and a password, first finds the matching + Given a CAS login, an entrance year and a password, first finds the matching OldCASAccount instance (if it exists), then checks the given password with the user associated to this account. """ @@ -168,3 +168,10 @@ class OldCASBackend: # Taken from Django's ModelBackend is_active = getattr(user, "is_active", None) return is_active or is_active is None + + # Django boilerplate. + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: + return None diff --git a/authens/forms.py b/authens/forms.py new file mode 100644 index 0000000..7a4c2d3 --- /dev/null +++ b/authens/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.contrib.auth import forms as auth_forms, authenticate +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone + + +def promo_choices(): + return [(r, r) for r in range(2000, timezone.now().year + 1)] + + +class OldCASAuthForm(forms.Form): + """ Adapts Django's AuthenticationForm to allow for OldCAS login. + """ + + cas_login = auth_forms.UsernameField( + label=_("Ancien login clipper"), max_length=1023 + ) + password = forms.CharField( + label=_("Mot de passe"), + strip=False, + widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), + ) + entrance_year = forms.TypedChoiceField( + label=_("Promotion"), choices=promo_choices, coerce=int + ) + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + cas_login = self.cleaned_data.get("cas_login") + password = self.cleaned_data.get("password") + entrance_year = self.cleaned_data.get("entrance_year") + + if cas_login is not None and password: + self.user_cache = authenticate( + self.request, + cas_login=cas_login, + password=password, + entrance_year=entrance_year, + ) + if self.user_cache is None: + raise self.get_invalid_login_error() + + return self.cleaned_data + + def get_user(self): + # Necessary API for LoginView + return self.user_cache + + def get_invalid_login_error(self): + return forms.ValidationError( + _( + "Aucun utilisateur n'existe avec ce clipper, cette promo et/ou ce mot " + "de passe. Veuillez vérifier votre saisie. Attention, tous les champs " + "sont sensibles à la casse !" + ), + code="invalid_login", + ) diff --git a/authens/models.py b/authens/models.py index 9408167..eba5769 100644 --- a/authens/models.py +++ b/authens/models.py @@ -32,7 +32,9 @@ class CASAccount(models.Model): verbose_name_plural = _("Comptes CAS") def __str__(self): - return _("compte CAS %(cas_login) (promo %(entrance_year)s) lié à %(user)s") % { + return _( + "compte CAS %(cas_login)s (promo %(entrance_year)s) lié à %(user)s" + ) % { "cas_login": self.cas_login, "entrance_year": self.entrance_year, "user": self.user.username, @@ -74,7 +76,7 @@ class OldCASAccount(models.Model): def __str__(self): return _( - "Ancien compte CAS %(cas_login) (promo %(entrance_year)s) lié à %(user)s" + "Ancien compte CAS %(cas_login)s (promo %(entrance_year)s) lié à %(user)s" ) % { "cas_login": self.cas_login, "entrance_year": self.entrance_year, diff --git a/authens/static/authens/css/authens.css b/authens/static/authens/css/authens.css index 66379b1..89c5eea 100644 --- a/authens/static/authens/css/authens.css +++ b/authens/static/authens/css/authens.css @@ -59,7 +59,7 @@ form { } form table { - margin: auto; + margin: 20px auto; border-spacing: 0.3em; } @@ -90,6 +90,15 @@ input[type="submit"]:hover { border-color: white; } +select { + border: 0; + font-size: 1em; + background-color: white; + width: 100%; + padding:5px; + text-align: end; +} + a { flex: 1; height: 200px; diff --git a/authens/templates/authens/login_switch.html b/authens/templates/authens/login_switch.html index 16917d1..08c52fc 100644 --- a/authens/templates/authens/login_switch.html +++ b/authens/templates/authens/login_switch.html @@ -13,11 +13,9 @@ {% trans "Mot de passe" %} - {% comment %} TODO: https://git.eleves.ens.fr/klub-dev-ens/authens/issues/9 - +
{% trans "Vieilleux" %}
- {% endcomment %} {% endblock %} diff --git a/authens/templates/authens/old_cas_login.html b/authens/templates/authens/old_cas_login.html new file mode 100644 index 0000000..2e790f9 --- /dev/null +++ b/authens/templates/authens/old_cas_login.html @@ -0,0 +1,27 @@ +{% extends "authens/base.html" %} +{% load i18n %} + +{% block content %} +

{% if request.site.name %}{{ request.site.name }}{% else %}AuthENS{% endif %} - {% trans "Connexion vieilleux" %}

+ + {% if form.errors %} +

{% trans "Login CAS, promotion et/ou mot de passe incorrect" %}

+ {% endif %} + +
+ {% csrf_token %} + + + {% for field in form %} + + + + + {% endfor %} + +
{{ field.label_tag }}{{ field }}
+ + +
+{% endblock %} + diff --git a/authens/tests/test_backend.py b/authens/tests/test_backend.py index 486db62..a0d06b6 100644 --- a/authens/tests/test_backend.py +++ b/authens/tests/test_backend.py @@ -115,10 +115,11 @@ class TestCASBackend(TestCase): class TestOldCASBackend(TestCase): def test_simple_auth(self): user = UserModel.objects.create_user(username="johndoe31", password="password") - wrong_user = UserModel.objects.create_user("johndoe", "password") - OldCASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2019) + # Decoy user that may be authenticated by mistake + UserModel.objects.create_user(username="johndoe", password="password") + OldCASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2014) auth_user = authenticate( - None, cas_login="johndoe", entrance_year=2019, password="password" + None, cas_login="johndoe", entrance_year=2014, password="password" ) self.assertEqual(auth_user, user) diff --git a/authens/tests/test_views.py b/authens/tests/test_views.py index 54cb47a..313348b 100644 --- a/authens/tests/test_views.py +++ b/authens/tests/test_views.py @@ -7,7 +7,7 @@ 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.models import CASAccount, OldCASAccount from authens.tests.cas_utils import FakeCASClient UserModel = get_user_model() @@ -44,6 +44,39 @@ class TestLoginViews(TestCase): response = Client().get(reverse("authens:login")) self.assertEqual(response.status_code, 200) + def test_oldcas_login(self): + url = reverse("authens:login.oldcas") + client = Client() + + user = UserModel.objects.create_user(username="johndoe31", password="password") + # Decoy user that may be authenticated by mistake + UserModel.objects.create_user(username="johndoe", password="password") + OldCASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2014) + + post_data = dict(cas_login="johndoe", password="password", entrance_year=2014) + response = client.post(url, post_data) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) + + def test_oldcas_login_error(self): + url = reverse("authens:login.oldcas") + client = Client() + + user = UserModel.objects.create_user(username="johndoe31", password="password") + OldCASAccount.objects.create(user=user, cas_login="johndoe", entrance_year=2014) + + wrong_year = dict(cas_login="johndoe", password="password", entrance_year=2015) + wrong_login = dict( + cas_login="johndoe31", password="password", entrance_year=2014 + ) + + response = client.post(url, wrong_year, follow=True) + non_field_errors = response.context["form"].non_field_errors().as_data() + self.assertEqual(non_field_errors[0].code, "invalid_login") + + response = client.post(url, wrong_login, follow=True) + non_field_errors = response.context["form"].non_field_errors().as_data() + self.assertEqual(non_field_errors[0].code, "invalid_login") + class TestLogoutView(TestCase): def test_regular_logout(self): diff --git a/authens/urls.py b/authens/urls.py index 7b13310..1fe4eb9 100644 --- a/authens/urls.py +++ b/authens/urls.py @@ -7,5 +7,6 @@ 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("login/oldcas", views.OldCASLoginView.as_view(), name="login.oldcas"), path("logout", views.LogoutView.as_view(), name="logout"), ] diff --git a/authens/views.py b/authens/views.py index 14ead4e..d5641a5 100644 --- a/authens/views.py +++ b/authens/views.py @@ -9,6 +9,7 @@ from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ from authens.utils import get_cas_client +from authens.forms import OldCASAuthForm class NextPageMixin: @@ -79,6 +80,11 @@ class PasswordLoginView(auth_views.LoginView): template_name = "authens/pwd_login.html" +class OldCASLoginView(auth_views.LoginView): + template_name = "authens/old_cas_login.html" + authentication_form = OldCASAuthForm + + class LogoutView(auth_views.LogoutView): """Logout view of AuthENS. diff --git a/example_site/example_site/settings.py b/example_site/example_site/settings.py index 3148128..a2eb594 100644 --- a/example_site/example_site/settings.py +++ b/example_site/example_site/settings.py @@ -118,13 +118,14 @@ STATIC_URL = "/static/" # The only modifications to the default settings are here # # ------------------------------------------------------- # -from django.urls import reverse_lazy # noqa +from django.urls import reverse_lazy # noqa # This is mandatory INSTALLED_APPS += ["example", "authens"] AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "authens.backends.ENSCASBackend", + "authens.backends.OldCASBackend", ] LOGIN_URL = reverse_lazy("authens:login") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ec50b39 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,23 @@ +[flake8] +exclude = migrations +max-line-length = 88 +ignore = + # whitespace before ':' (not PEP8-compliant for slicing) + E203, + # lambda expression + E731, + # line break before binary operator (not PEP8-compliant) + W503 + +[isort] +# For black compat: https://github.com/ambv/black#how-black-wraps-lines +combine_as_imports = true +default_section = THIRDPARTY +force_grid_wrap = 0 +include_trailing_comma = true +known_django = django +known_first_party = authens,tests +line_length = 88 +multi_line_output = 3 +not_skip = __init__.py +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER