Début de l'authentification
This commit is contained in:
parent
e704e2a155
commit
fc695b9cc5
14 changed files with 339 additions and 6 deletions
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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/
|
||||||
|
|
|
@ -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
83
shared/auth/backends.py
Normal 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
48
shared/auth/forms.py
Normal 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
10
shared/auth/urls.py
Normal 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
14
shared/auth/utils.py
Normal 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
117
shared/auth/views.py
Normal 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
|
3
shared/templates/auth/login_select.html
Normal file
3
shared/templates/auth/login_select.html
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
2
shared/templates/auth/pwd_login.html
Normal file
2
shared/templates/auth/pwd_login.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
|
@ -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 %}
|
||||||
|
|
Loading…
Reference in a new issue