Début de l'authentification
This commit is contained in:
parent
e704e2a155
commit
fc695b9cc5
14 changed files with 339 additions and 6 deletions
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
|
Loading…
Add table
Add a link
Reference in a new issue