First login/logout implementation

This commit is contained in:
Martin Pépin 2020-05-10 23:41:08 +02:00
parent 1b1049a73f
commit 591a61200b
7 changed files with 233 additions and 0 deletions

95
authens/backends.py Normal file
View file

@ -0,0 +1,95 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from authens.models import Clipper
from authens.utils import get_cas_client
UserModel = get_user_model()
class ENSCASError(Exception):
pass
def get_entrance_year(attributes):
"""Infer the entrance year of a clipper account holder from its home directory."""
home_dir = attributes.get("homeDirectory")
if home_dir is None:
raise ENSCASError("Entrance year not available")
dirs = home_dir.split("/")
if len(dirs) < 3 or not dirs[2].isdecimal():
raise ENSCASError("Invalid homeDirectory: {}".format(home_dir))
year = int(dirs[2])
# This will break in 2080.
if year >= 80:
return 1900 + year
else:
return 2000 + year
def find_available_username(clipper_uid):
"""Find an available username 'close' to a clipper uid."""
taken = UserModel.objects.filter(username__startswith=clipper_uid).values_list(
"username", flat=True
)
if clipper_uid not in taken:
return clipper_uid
else:
i = 2
while clipper_uid + str(i) in taken:
i += 1
return clipper_uid + str(i)
class ENSCASBackend:
"""ENSAuth authentication backend.
Implement standard CAS v3 authentication and handles username clashes with non-CAS
accounts and potential old CAS accounts.
Every user connecting via CAS is given a `authens.models.Clipper` instance which
remembers her clipper login and her entrance year (the year her clipper account was
created).
At each connection, we search for a Clipper account with the given clipper login
(uid) and create one if none exists. In case the Clipper account's entrance year
does not match the entrance year given by CAS, it means it is a old account and it
must be deleted. The corresponding user can still connect using regular Django
authentication.
"""
def authenticate(self, request, ticket=None):
cas_client = get_cas_client(request)
uid, attributes, _ = cas_client.verify_ticket(ticket)
if not uid:
# Authentication failed
return None
year = get_entrance_year(attributes)
return self._get_or_create(uid, year)
def _get_or_create(self, uid, entrance_year):
with transaction.atomic():
try:
user = UserModel.objects.get(clipper__uid=uid)
if user.clipper.entrance_year != entrance_year:
user.clipper.delete()
user = None
except UserModel.DoesNotExist:
user = None
if user is None:
username = find_available_username(uid)
user = UserModel.objects.create_user(username=username)
Clipper.objects.create(user=user, entrance_year=entrance_year, uid=uid)
return user
def get_user(self, user_id):
try:
return UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist:
return None

View file

@ -0,0 +1,17 @@
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>ENS Auth</title>
</head>
<body>
<p><a href="{% url "authens:login.cas" %}?next={{ next | urlencode }}">
{% trans "Login par CAS" %}
</a></p>
<p><a href="{% url "authens:login.pwd" %}?next={{ next | urlencode }}">
{% trans "Login par mot de passe" %}
</a></p>
</body>
</html>

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>ENS Auth</title>
</head>
<body>
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form method="post" action="{% url 'authens:login.pwd' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>
<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
</body>
</html>

12
authens/urls.py Normal file
View file

@ -0,0 +1,12 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from authens import views
app_name = "authens"
urlpatterns = [
path("login/choose", views.login_switch, name="login"),
path("login/cas", views.cas_login, name="login.cas"),
path("login/pwd", views.pwd_login, name="login.pwd"),
path("logout", auth_views.LogoutView.as_view(), name="logout"),
]

13
authens/utils.py Normal file
View file

@ -0,0 +1,13 @@
from cas import CASClient
from urllib.parse import urlunparse
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/",
)

57
authens/views.py Normal file
View file

@ -0,0 +1,57 @@
from django.conf import settings
from django.contrib import auth
from django.core.exceptions import PermissionDenied
from django.views.decorators.http import require_GET
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from authens.utils import get_cas_client
def _get_next_url(request):
"""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.
"""
next_page = request.GET.get("next")
if next_page is None and "CASNEXT" in request.session:
next_page = request.session["CASNEXT"]
del request.session["CASNEXT"]
if next_page is None:
next_page = settings.LOGIN_REDIRECT_URL
return next_page
@require_GET
def login_switch(request):
next_page = _get_next_url(request)
if request.user.is_authenticated:
return redirect(next_page)
return render(request, "authens/login_switch.html", {"next": next_page})
@require_GET
def cas_login(request):
next_page = _get_next_url(request)
ticket = request.GET.get("ticket")
# User's request: redirect the user to cas.eleves
if not ticket:
request.session["CASNEXT"] = next_page # remember next_page
cas_client = get_cas_client(request)
return redirect(cas_client.get_login_url())
# CAS' request: validate the ticket
user = auth.authenticate(request, ticket=ticket)
if user is None:
raise PermissionDenied(_("Connection échouée !"))
# Success: log the user in
auth.login(request, user)
return redirect(next_page)
pwd_login = auth.views.LoginView.as_view(template_name="authens/pwd_login.html")

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
python-cas==1.5.*