diff --git a/README.md b/README.md index 0f3fb03..7bfb31d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ Plus précisément : choisira d'utiliser la connexion par mot de passe sur le site, typiquement après la fin de la scolarité lorsque le compte clipper est supprimé. - 2. Si, quelques années plus tard, après que `dupond` a terminé sa scolarité, le SPI donne le login `dupond` à une nouvelle personne, AuthENS détecte que le nouveau compte `dupond` n'est pas le même que l'ancien et crée un nouveau @@ -50,7 +49,7 @@ Django sous le nom `"authens:logout"`. - Ajouter `"authens"` dans les [`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.0/ref/settings/#installed-apps). - Ajouter `"authens.backends.ENSCASBackend"` dans les [`AUTHENTICATION_BACKENDS`](https://docs.djangoproject.com/en/3.0/ref/settings/#authentication-backends). - Si `AUTHENTICATION_BACKENDS` n'apparaît pas dans vos settings, utilisez : + Si `AUTHENTICATION_BACKENDS` n'apparaît pas dans vos settings, utiliser : ```python AUTHENTICATION_BACKENDS = [ diff --git a/authens/backends.py b/authens/backends.py index 60bba14..ebea500 100644 --- a/authens/backends.py +++ b/authens/backends.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from django.db import transaction -from authens.models import Clipper +from authens.models import CASAccount from authens.utils import get_cas_client UserModel = get_user_model() @@ -12,7 +12,11 @@ class ENSCASError(Exception): def get_entrance_year(attributes): - """Infer the entrance year of a clipper account holder from its home directory.""" + """Infer the entrance year of a CAS account holder from her home directory.""" + + # The home directory of a user is of the form /users/YEAR/DEPARTMENT/CAS_LOGIN where + # YEAR is a 2-digit number representing the entrance year of the student. We get the + # entrance year from there. home_dir = attributes.get("homeDirectory") if home_dir is None: @@ -22,8 +26,9 @@ def get_entrance_year(attributes): if len(dirs) < 3 or not dirs[2].isdecimal(): raise ENSCASError("Invalid homeDirectory: {}".format(home_dir)) - year = int(dirs[2]) + # Expand the 2-digit entrance year into 4 digits. # This will break in 2080. + year = int(dirs[2]) if year >= 80: return 1900 + year else: @@ -31,19 +36,18 @@ def get_entrance_year(attributes): class ENSCASBackend: - """ENSAuth authentication backend. + """AuthENS CAS 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 + Every user connecting via CAS is given an `authens.models.CASAccount` instance which + remembers her CAS login and her entrance year (the year her CAS 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. + At each connection, we search for a CAS account with the given CAS login and create + one if none exists. In case the CAS 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): @@ -57,8 +61,8 @@ class ENSCASBackend: year = get_entrance_year(attributes) return self._get_or_create(uid, year) - def get_free_username(self, clipper_uid): - """Find an available username for the new user. + def get_free_username(self, cas_login): + """Find an available username for a new user. If you override this method, make sure it returns a username that is not taken by any existing user. @@ -69,35 +73,46 @@ class ENSCASBackend: taken = UserModel.objects.values_list("username", flat=True) # This should handle most cases and produce a nice username. - prefered = [clipper_uid, "cas_" + clipper_uid] + prefered = [cas_login, "cas_" + cas_login] pref_taken = taken.filter(username__in=prefered) for name in prefered: if name not in pref_taken: return name - # Worst case: generate a username of the form clipper_uid + int - taken = taken.filter(username__startswith=clipper_uid) + # Worst case: generate a username of the form cas_login + int + taken = taken.filter(username__startswith=cas_login) i = 2 - while clipper_uid + str(i) in taken: + while cas_login + str(i) in taken: i += 1 - return clipper_uid + str(i) + return cas_login + str(i) + + def _get_or_create(self, cas_login, entrance_year): + """Handles account retrieval, creation and invalidation as described above. + + - If no CAS account exists, create one; + - If a CAS account exists, but with the wrong entrance year, remove it and + create a new one; + - If a matching CAS account exists, retrieve it. + """ - 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 = UserModel.objects.get(cas_account__cas_login=cas_login) + if user.cas_account.entrance_year != entrance_year: + user.cas_account.delete() user = None except UserModel.DoesNotExist: user = None if user is None: - username = self.get_free_username(uid) + username = self.get_free_username(cas_login) user = UserModel.objects.create_user(username=username) - Clipper.objects.create(user=user, entrance_year=entrance_year, uid=uid) + CASAccount.objects.create( + user=user, entrance_year=entrance_year, cas_login=cas_login + ) return user + # Django boilerplate. def get_user(self, user_id): try: return UserModel.objects.get(pk=user_id) diff --git a/authens/migrations/0001_initial.py b/authens/migrations/0001_initial.py index 4c5e246..60b14d3 100644 --- a/authens/migrations/0001_initial.py +++ b/authens/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.6 on 2020-05-10 19:14 +# Generated by Django 3.0.6 on 2020-05-17 11:58 from django.conf import settings from django.db import migrations, models @@ -15,7 +15,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="Clipper", + name="CASAccount", fields=[ ( "id", @@ -27,30 +27,30 @@ class Migration(migrations.Migration): ), ), ( - "uid", + "cas_login", models.CharField( - max_length=1023, unique=True, verbose_name="login clipper" + max_length=1023, unique=True, verbose_name="login CAS" ), ), ( "entrance_year", models.SmallIntegerField( - verbose_name="année de création du compte clipper" + verbose_name="année de création du compte CAS" ), ), ( "user", models.OneToOneField( on_delete=django.db.models.deletion.CASCADE, - related_name="clipper", + related_name="cas_account", to=settings.AUTH_USER_MODEL, verbose_name="utilisateurice", ), ), ], options={ - "verbose_name": "Compte clipper", - "verbose_name_plural": "Comptes clipper", + "verbose_name": "Compte CAS", + "verbose_name_plural": "Comptes CAS", }, ), ] diff --git a/authens/models.py b/authens/models.py index 36a67f9..90b107c 100644 --- a/authens/models.py +++ b/authens/models.py @@ -5,40 +5,35 @@ from django.utils.translation import gettext_lazy as _ User = get_user_model() -class Clipper(models.Model): - """Information about clipper accounts. +class CASAccount(models.Model): + """Information about CAS accounts. - A user is given an instance of this model iff it has a clipper account. The `uid` - field is the clipper login and is used for CAS authentication. + A user is given an instance of this model iff she has a CAS account. - At each connection, we check that the `entrance_year` we have is consistent with the - meta-data returned by the SPI's CAS. - - if both entrance years match, we connect `self.user` - - if not, we consider that this account is old and assume a new clipper account with - the same id has been created. We remove this instance and create a new user - associated with a new Clipper account. + Instances of this model should only be created by the `ENSCASBackend` authentication + backend. """ user = models.OneToOneField( User, verbose_name=_("utilisateurice"), on_delete=models.CASCADE, - related_name="clipper", + related_name="cas_account", ) - uid = models.CharField( - verbose_name=_("login clipper"), max_length=1023, blank=False, unique=True, + cas_login = models.CharField( + verbose_name=_("login CAS"), max_length=1023, blank=False, unique=True, ) entrance_year = models.SmallIntegerField( - verbose_name=_("année de création du compte clipper"), blank=False, null=False + verbose_name=_("année de création du compte CAS"), blank=False, null=False ) class Meta: - verbose_name = _("Compte clipper") - verbose_name_plural = _("Comptes clipper") + verbose_name = _("Compte CAS") + verbose_name_plural = _("Comptes CAS") def __str__(self): - return _("compte clipper %(uid)s@%(entrance_year)s lié à %(user)s") % { - "uid": self.uid, + return _("compte CAS %(cas_login) (promo %(entrance_year)s) lié à %(user)s") % { + "cas_login": self.cas_login, "entrance_year": self.entrance_year, "user": self.user.username, }