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

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