First login/logout implementation
This commit is contained in:
parent
1b1049a73f
commit
591a61200b
7 changed files with 233 additions and 0 deletions
95
authens/backends.py
Normal file
95
authens/backends.py
Normal 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
|
17
authens/templates/authens/login_switch.html
Normal file
17
authens/templates/authens/login_switch.html
Normal 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>
|
38
authens/templates/authens/pwd_login.html
Normal file
38
authens/templates/authens/pwd_login.html
Normal 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
12
authens/urls.py
Normal 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
13
authens/utils.py
Normal 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
57
authens/views.py
Normal 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
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
python-cas==1.5.*
|
Loading…
Reference in a new issue