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.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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -37,6 +37,17 @@
|
|||
</div>
|
||||
<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 #}
|
||||
<div class="message is-primary">
|
||||
<div class="message-body">{{ election.description|linebreaksbr }}</div>
|
||||
|
@ -46,7 +57,7 @@
|
|||
{% for q in election.questions.all %}
|
||||
<div class="panel" id="q_{{ q.pk }}">
|
||||
<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 %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-vote-yea"></i>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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:
|
||||
|
|
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 class="level-right pr-5">
|
||||
<div class="level-item">
|
||||
<a class="icon is-size-1 has-text-white" href="">
|
||||
{% if user.is_authenticated %}
|
||||
<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>
|
||||
</a>
|
||||
</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>
|
||||
</nav>
|
||||
{% block layout %}
|
||||
|
|
Loading…
Reference in a new issue