From 591a61200ba74ac5a55e1aee81dc2a1d0c5333c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Sun, 10 May 2020 23:41:08 +0200 Subject: [PATCH] First login/logout implementation --- authens/backends.py | 95 +++++++++++++++++++++ authens/templates/authens/login_switch.html | 17 ++++ authens/templates/authens/pwd_login.html | 38 +++++++++ authens/urls.py | 12 +++ authens/utils.py | 13 +++ authens/views.py | 57 +++++++++++++ requirements.txt | 1 + 7 files changed, 233 insertions(+) create mode 100644 authens/backends.py create mode 100644 authens/templates/authens/login_switch.html create mode 100644 authens/templates/authens/pwd_login.html create mode 100644 authens/urls.py create mode 100644 authens/utils.py create mode 100644 authens/views.py create mode 100644 requirements.txt diff --git a/authens/backends.py b/authens/backends.py new file mode 100644 index 0000000..5a734ba --- /dev/null +++ b/authens/backends.py @@ -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 diff --git a/authens/templates/authens/login_switch.html b/authens/templates/authens/login_switch.html new file mode 100644 index 0000000..e63c9db --- /dev/null +++ b/authens/templates/authens/login_switch.html @@ -0,0 +1,17 @@ +{% load i18n %} + + + + + + ENS Auth + + +

+ {% trans "Login par CAS" %} +

+

+ {% trans "Login par mot de passe" %} +

+ + diff --git a/authens/templates/authens/pwd_login.html b/authens/templates/authens/pwd_login.html new file mode 100644 index 0000000..960d5b8 --- /dev/null +++ b/authens/templates/authens/pwd_login.html @@ -0,0 +1,38 @@ + + + + + ENS Auth + + + {% if form.errors %} +

Your username and password didn't match. Please try again.

+ {% endif %} + + {% if next %} + {% if user.is_authenticated %} +

Your account doesn't have access to this page. To proceed, + please login with an account that has access.

+ {% else %} +

Please login to see this page.

+ {% endif %} + {% endif %} + +
+ {% csrf_token %} + + + + + + + + + +
{{ form.username.label_tag }}{{ form.username }}
{{ form.password.label_tag }}{{ form.password }}
+ + + +
+ + diff --git a/authens/urls.py b/authens/urls.py new file mode 100644 index 0000000..3c2cded --- /dev/null +++ b/authens/urls.py @@ -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"), +] diff --git a/authens/utils.py b/authens/utils.py new file mode 100644 index 0000000..a2a5499 --- /dev/null +++ b/authens/utils.py @@ -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/", + ) diff --git a/authens/views.py b/authens/views.py new file mode 100644 index 0000000..e378d04 --- /dev/null +++ b/authens/views.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6421f80 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-cas==1.5.*