From fc695b9cc5e5eb9e90301013c3fd7891c6654c60 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 21 Dec 2020 00:07:07 +0100 Subject: [PATCH] =?UTF-8?q?D=C3=A9but=20de=20l'authentification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elections/mixins.py | 7 +- elections/models.py | 9 ++ elections/templates/elections/election.html | 13 ++- elections/views.py | 15 ++- kadenios/settings_base.py | 6 + kadenios/urls.py | 1 + shared/auth/backends.py | 83 ++++++++++++++ shared/auth/forms.py | 48 ++++++++ shared/auth/urls.py | 10 ++ shared/auth/utils.py | 14 +++ shared/auth/views.py | 117 ++++++++++++++++++++ shared/templates/auth/login_select.html | 3 + shared/templates/auth/pwd_login.html | 2 + shared/templates/base.html | 17 ++- 14 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 shared/auth/backends.py create mode 100644 shared/auth/forms.py create mode 100644 shared/auth/urls.py create mode 100644 shared/auth/utils.py create mode 100644 shared/auth/views.py create mode 100644 shared/templates/auth/login_select.html create mode 100644 shared/templates/auth/pwd_login.html diff --git a/elections/mixins.py b/elections/mixins.py index cd3d15a..a54c583 100644 --- a/elections/mixins.py +++ b/elections/mixins.py @@ -1,3 +1,5 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse from django.utils import timezone from django.views.generic.detail import SingleObjectMixin @@ -51,9 +53,12 @@ class OpenElectionOnlyMixin(RestrictAccessMixin): return filters -class CreatorOnlyMixin(RestrictAccessMixin): +class CreatorOnlyMixin(LoginRequiredMixin, RestrictAccessMixin): """Restreint l'accès au créateurice de l'élection""" + def get_next_url(self): + return reverse("kadenios") + def get_filters(self): filters = super().get_filters() # TODO: change the way we collect the user according to the model used diff --git a/elections/models.py b/elections/models.py index e99a4d2..0eafee0 100644 --- a/elections/models.py +++ b/elections/models.py @@ -82,6 +82,10 @@ class User(AbstractUser): on_delete=models.CASCADE, ) + @property + def base_username(self): + return self.username.split("__")[-1] + def can_vote(self, election): # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # ouvertes à tou·te·s @@ -89,3 +93,8 @@ class User(AbstractUser): return not election.restricted # Pour les élections restreintes, il faut y être associé return election.restricted and (self.election == election) + + def connection_method(self): + if self.election is None: + return _("CAS") + return _("identifiants spécifiques") diff --git a/elections/templates/elections/election.html b/elections/templates/elections/election.html index f4338da..a726993 100644 --- a/elections/templates/elections/election.html +++ b/elections/templates/elections/election.html @@ -37,6 +37,17 @@
+{# Indications de connexion #} +
+
+ {% if election.restricted %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide des identifiants reçus par mail." %} + {% else %} + {% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %} + {% endif %} +
+
+ {# Description de l'élection #}
{{ election.description|linebreaksbr }}
@@ -46,7 +57,7 @@ {% for q in election.questions.all %}
- {% if election.start_date < current_time and election.end_date > current_time %} + {% if can_vote and election.start_date < current_time and election.end_date > current_time %} diff --git a/elections/views.py b/elections/views.py index f43355b..daecd29 100644 --- a/elections/views.py +++ b/elections/views.py @@ -265,9 +265,17 @@ class ElectionView(DetailView): model = Election template_name = "elections/election.html" + def get_next_url(self): + return self.request.path + def get_context_data(self, **kwargs): - kwargs.update({"current_time": timezone.now()}) - return super().get_context_data(**kwargs) + user = self.request.user + context = super().get_context_data(**kwargs) + context["current_time"] = timezone.now() + context["can_vote"] = user.is_authenticated and user.can_vote( + context["election"] + ) + return context def get_queryset(self): return ( @@ -282,6 +290,9 @@ class VoteView(OpenElectionOnlyMixin, DetailView): model = Question template_name = "elections/vote.html" + def get_newt_url(self): + return reverse("election.view", args=[self.object.election.pk]) + def get_success_url(self): questions = list(self.object.election.questions.all()) q_index = questions.index(self.object) diff --git a/kadenios/settings_base.py b/kadenios/settings_base.py index d1d201f..3f5a548 100644 --- a/kadenios/settings_base.py +++ b/kadenios/settings_base.py @@ -87,6 +87,12 @@ AUTH_PASSWORD_VALIDATORS = [ ] AUTH_USER_MODEL = "elections.User" +AUTHENTICATION_BACKENDS = [ + "shared.auth.backends.ENSCASBackend", + "shared.auth.backends.ElectionBackend", +] + +LOGIN_URL = "auth.cas" # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/kadenios/urls.py b/kadenios/urls.py index f547808..759e32b 100644 --- a/kadenios/urls.py +++ b/kadenios/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("", HomeView.as_view(), name="kadenios"), path("admin/", admin.site.urls), path("elections/", include("elections.urls")), + path("auth/", include("shared.auth.urls")), ] if "debug_toolbar" in settings.INSTALLED_APPS: diff --git a/shared/auth/backends.py b/shared/auth/backends.py new file mode 100644 index 0000000..36b7654 --- /dev/null +++ b/shared/auth/backends.py @@ -0,0 +1,83 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + +from .utils import get_cas_client + +UserModel = get_user_model() + + +class ENSCASBackend: + """ENS CAS authentication backend. + + Implement standard CAS v3 authentication + """ + + def authenticate(self, request, ticket=None): + cas_client = get_cas_client(request) + cas_login, attributes, _ = cas_client.verify_ticket(ticket) + + if cas_login is None: + # Authentication failed + return None + cas_login = self.clean_cas_login(cas_login) + + if request: + request.session["CASCONNECTED"] = True + + return self._get_or_create(cas_login, attributes) + + def clean_cas_login(self, cas_login): + return cas_login.strip().lower() + + def _get_or_create(self, cas_login, attributes): + """Handles account retrieval and creation for CAS authentication. + + - If no CAS account exists, create one; + - If a matching CAS account exists, retrieve it. + """ + + email = attributes.get("email") + + try: + user = UserModel.objects.get(username=cas_login) + except UserModel.DoesNotExist: + user = None + + if user is None: + user = UserModel.objects.create_user(username=cas_login, email=email) + return user + + # Django boilerplate. + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: + return None + + +class ElectionBackend(ModelBackend): + """Authentication for a specific election. + + Given a login and an election, we check if the user `{election.id}__{login}` + exists, and then if the password matches. + """ + + def authenticate(self, request, login=None, password=None, election_id=None): + if login is None or password is None or election_id is None: + return None + + try: + user = UserModel.objects.get(username=f"{election_id}__{login}") + except UserModel.DoesNotExist: + return None + + if user.check_password(password): + return user + return 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/shared/auth/forms.py b/shared/auth/forms.py new file mode 100644 index 0000000..a4e8765 --- /dev/null +++ b/shared/auth/forms.py @@ -0,0 +1,48 @@ +from django import forms +from django.contrib.auth import authenticate +from django.contrib.auth import forms as auth_forms +from django.utils.translation import gettext_lazy as _ + + +class ElectionAuthForm(forms.Form): + """Adapts Django's AuthenticationForm to allow for OldCAS login.""" + + login = auth_forms.UsernameField(label=_("Identifiant"), max_length=255) + password = forms.CharField(label=_("Mot de passe"), strip=False) + election_id = forms.IntegerField(widget=forms.HiddenInput()) + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + login = self.cleaned_data.get("cas_login") + password = self.cleaned_data.get("password") + election_id = self.cleaned_data.get("election_id") + + if login is not None and password: + self.user_cache = authenticate( + self.request, + login=login, + password=password, + election_id=election_id, + ) + 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·e électeur·ice avec cet identifiant et mot de passe n'existe " + "pour cette élection. Vérifiez que les informations rentrées sont " + "correctes, les champs sont sensibles à la casse." + ), + code="invalid_login", + ) diff --git a/shared/auth/urls.py b/shared/auth/urls.py new file mode 100644 index 0000000..bac6f01 --- /dev/null +++ b/shared/auth/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("login/select", views.LoginSelectView.as_view(), name="auth.select"), + path("login/cas", views.CASLoginView.as_view(), name="auth.cas"), + path("login/pwd", views.PasswordLoginView.as_view(), name="auth.pwd"), + path("logout", views.LogoutView.as_view(), name="auth.logout"), +] diff --git a/shared/auth/utils.py b/shared/auth/utils.py new file mode 100644 index 0000000..0d92b97 --- /dev/null +++ b/shared/auth/utils.py @@ -0,0 +1,14 @@ +from urllib.parse import urlunparse + +from cas import CASClient + + +def get_cas_client(request): + """Return a CAS client configured for SPI's CAS.""" + return CASClient( + version=3, + service_url=urlunparse( + (request.scheme, request.get_host(), request.path, "", "", "") + ), + server_url="https://cas.eleves.ens.fr/", + ) diff --git a/shared/auth/views.py b/shared/auth/views.py new file mode 100644 index 0000000..e9b0986 --- /dev/null +++ b/shared/auth/views.py @@ -0,0 +1,117 @@ +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.shortcuts import redirect +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView, View + +from .forms import ElectionAuthForm +from .utils import get_cas_client + + +class NextPageMixin: + def get_next_url(self): + """Decide where to go after a successful login. + + Look for (in order): + - a `next` GET parameter; + - a `CASNEXT` session variable; + - the `LOGIN_REDIRECT_URL` django setting. + """ + request = self.request + next_url = request.GET.get("next") + if next_url is None and "CASNEXT" in request.session: + next_url = request.session["CASNEXT"] + del request.session["CASNEXT"] + if next_url is None: + next_url = settings.LOGIN_REDIRECT_URL + return next_url + + +class LoginSelectView(NextPageMixin, TemplateView): + """Simple page letting the user choose between password and CAS authentication.""" + + template_name = "auth/login_select.html" + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect(self.get_next_url()) + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["next"] = self.get_next_url() + + return context + + +class CASLoginView(NextPageMixin, View): + """CAS authentication view. + + Implement the CAS authentication scheme: + + 1. We first redirect the user to the student CAS. + 2. The user comes back with a ticket, we validate it to make sure the user is legit + (validation is delegated to the ENSCASBackend). + 3. We redirect the user to the next page. + """ + + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + ticket = request.GET.get("ticket") + + if not ticket: + request.session["CASNEXT"] = self.get_next_url() + cas_client = get_cas_client(request) + return redirect(cas_client.get_login_url()) + + user = auth.authenticate(request, ticket=ticket) + if user is None: + raise PermissionDenied(_("Connexion échouée !")) + auth.login(request, user) + return redirect(self.get_next_url()) + + +class PasswordLoginView(auth_views.LoginView): + template_name = "auth/pwd_login.html" + authentication_form = ElectionAuthForm + + def get_initial(self): + return {"election_id": self.request.GET.get("election", None)} + + +class LogoutView(auth_views.LogoutView): + """Logout view. + + 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/shared/templates/auth/login_select.html b/shared/templates/auth/login_select.html new file mode 100644 index 0000000..37d50df --- /dev/null +++ b/shared/templates/auth/login_select.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% load i18n %} + diff --git a/shared/templates/auth/pwd_login.html b/shared/templates/auth/pwd_login.html new file mode 100644 index 0000000..c4c0b0f --- /dev/null +++ b/shared/templates/auth/pwd_login.html @@ -0,0 +1,2 @@ +{% extends "base.html" %} +{% load i18n %} diff --git a/shared/templates/base.html b/shared/templates/base.html index 2c58435..c46d9eb 100644 --- a/shared/templates/base.html +++ b/shared/templates/base.html @@ -43,11 +43,24 @@