Début de l'authentification

This commit is contained in:
Tom Hubrecht 2020-12-21 00:07:07 +01:00
parent e704e2a155
commit fc695b9cc5
14 changed files with 339 additions and 6 deletions

View file

@ -1,3 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@ -51,9 +53,12 @@ class OpenElectionOnlyMixin(RestrictAccessMixin):
return filters return filters
class CreatorOnlyMixin(RestrictAccessMixin): class CreatorOnlyMixin(LoginRequiredMixin, RestrictAccessMixin):
"""Restreint l'accès au créateurice de l'élection""" """Restreint l'accès au créateurice de l'élection"""
def get_next_url(self):
return reverse("kadenios")
def get_filters(self): def get_filters(self):
filters = super().get_filters() filters = super().get_filters()
# TODO: change the way we collect the user according to the model used # TODO: change the way we collect the user according to the model used

View file

@ -82,6 +82,10 @@ class User(AbstractUser):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@property
def base_username(self):
return self.username.split("__")[-1]
def can_vote(self, election): def can_vote(self, election):
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
# ouvertes à tou·te·s # ouvertes à tou·te·s
@ -89,3 +93,8 @@ class User(AbstractUser):
return not election.restricted return not election.restricted
# Pour les élections restreintes, il faut y être associé # Pour les élections restreintes, il faut y être associé
return election.restricted and (self.election == election) return election.restricted and (self.election == election)
def connection_method(self):
if self.election is None:
return _("CAS")
return _("identifiants spécifiques")

View file

@ -37,6 +37,17 @@
</div> </div>
<hr> <hr>
{# Indications de connexion #}
<div class="message is-warning">
<div class="message-body">
{% 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 %}
</div>
</div>
{# Description de l'élection #} {# Description de l'élection #}
<div class="message is-primary"> <div class="message is-primary">
<div class="message-body">{{ election.description|linebreaksbr }}</div> <div class="message-body">{{ election.description|linebreaksbr }}</div>
@ -46,7 +57,7 @@
{% for q in election.questions.all %} {% for q in election.questions.all %}
<div class="panel" id="q_{{ q.pk }}"> <div class="panel" id="q_{{ q.pk }}">
<div class="panel-heading is-size-6"> <div class="panel-heading is-size-6">
{% 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 %}
<a class="tag is-small is-outlined is-light is-danger" href="{% url 'election.vote' q.pk %}"> <a class="tag is-small is-outlined is-light is-danger" href="{% url 'election.vote' q.pk %}">
<span class="icon"> <span class="icon">
<i class="fas fa-vote-yea"></i> <i class="fas fa-vote-yea"></i>

View file

@ -265,9 +265,17 @@ class ElectionView(DetailView):
model = Election model = Election
template_name = "elections/election.html" template_name = "elections/election.html"
def get_next_url(self):
return self.request.path
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update({"current_time": timezone.now()}) user = self.request.user
return super().get_context_data(**kwargs) 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): def get_queryset(self):
return ( return (
@ -282,6 +290,9 @@ class VoteView(OpenElectionOnlyMixin, DetailView):
model = Question model = Question
template_name = "elections/vote.html" template_name = "elections/vote.html"
def get_newt_url(self):
return reverse("election.view", args=[self.object.election.pk])
def get_success_url(self): def get_success_url(self):
questions = list(self.object.election.questions.all()) questions = list(self.object.election.questions.all())
q_index = questions.index(self.object) q_index = questions.index(self.object)

View file

@ -87,6 +87,12 @@ AUTH_PASSWORD_VALIDATORS = [
] ]
AUTH_USER_MODEL = "elections.User" AUTH_USER_MODEL = "elections.User"
AUTHENTICATION_BACKENDS = [
"shared.auth.backends.ENSCASBackend",
"shared.auth.backends.ElectionBackend",
]
LOGIN_URL = "auth.cas"
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/

View file

@ -8,6 +8,7 @@ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"), path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("elections/", include("elections.urls")), path("elections/", include("elections.urls")),
path("auth/", include("shared.auth.urls")),
] ]
if "debug_toolbar" in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:

83
shared/auth/backends.py Normal file
View file

@ -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

48
shared/auth/forms.py Normal file
View file

@ -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",
)

10
shared/auth/urls.py Normal file
View file

@ -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"),
]

14
shared/auth/utils.py Normal file
View file

@ -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/",
)

117
shared/auth/views.py Normal file
View file

@ -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

View file

@ -0,0 +1,3 @@
{% extends "base.html" %}
{% load i18n %}

View file

@ -0,0 +1,2 @@
{% extends "base.html" %}
{% load i18n %}

View file

@ -43,11 +43,24 @@
</div> </div>
</div> </div>
<div class="level-right pr-5"> <div class="level-right pr-5">
<div class="level-item"> {% if user.is_authenticated %}
<a class="icon is-size-1 has-text-white" href=""> <div class="level-item mr-5">
<div class="tag">
{% blocktrans with name=user.base_username connection=user.connection_method %}Connecté·e en tant que {{ name }} par {{ connection }}{% endblocktrans %}
</div>
</div>
<div class="level-item ml-3">
<a class="icon is-size-1 has-text-white" href="{% url 'auth.logout' %}?next={{ view.get_next_url }}">
<i class="fas fa-sign-out-alt"></i> <i class="fas fa-sign-out-alt"></i>
</a> </a>
</div> </div>
{% else %}
<div class="level-item">
<a class="icon is-size-1 has-text-white" href="{% url 'auth.select' %}?next={{ request.path }}">
<i class="fas fa-sign-in-alt"></i>
</a>
</div>
{% endif %}
</div> </div>
</nav> </nav>
{% block layout %} {% block layout %}