Meilleurs noms de variables, meilleure doc

This commit is contained in:
Martin Pépin 2020-05-17 14:02:33 +02:00
parent 470bca4d1f
commit e9e8fe8d56
4 changed files with 61 additions and 52 deletions

View file

@ -16,7 +16,6 @@ Plus précisément :
choisira d'utiliser la connexion par mot de passe sur le site, typiquement 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é. 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 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 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 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"` dans les [`INSTALLED_APPS`](https://docs.djangoproject.com/en/3.0/ref/settings/#installed-apps).
- Ajouter `"authens.backends.ENSCASBackend"` dans les - Ajouter `"authens.backends.ENSCASBackend"` dans les
[`AUTHENTICATION_BACKENDS`](https://docs.djangoproject.com/en/3.0/ref/settings/#authentication-backends). [`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 ```python
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [

View file

@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from authens.models import Clipper from authens.models import CASAccount
from authens.utils import get_cas_client from authens.utils import get_cas_client
UserModel = get_user_model() UserModel = get_user_model()
@ -12,7 +12,11 @@ class ENSCASError(Exception):
def get_entrance_year(attributes): 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") home_dir = attributes.get("homeDirectory")
if home_dir is None: if home_dir is None:
@ -22,8 +26,9 @@ def get_entrance_year(attributes):
if len(dirs) < 3 or not dirs[2].isdecimal(): if len(dirs) < 3 or not dirs[2].isdecimal():
raise ENSCASError("Invalid homeDirectory: {}".format(home_dir)) 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. # This will break in 2080.
year = int(dirs[2])
if year >= 80: if year >= 80:
return 1900 + year return 1900 + year
else: else:
@ -31,19 +36,18 @@ def get_entrance_year(attributes):
class ENSCASBackend: class ENSCASBackend:
"""ENSAuth authentication backend. """AuthENS CAS authentication backend.
Implement standard CAS v3 authentication and handles username clashes with non-CAS Implement standard CAS v3 authentication and handles username clashes with non-CAS
accounts and potential old CAS accounts. accounts and potential old CAS accounts.
Every user connecting via CAS is given a `authens.models.Clipper` instance which Every user connecting via CAS is given an `authens.models.CASAccount` instance which
remembers her clipper login and her entrance year (the year her clipper account was remembers her CAS login and her entrance year (the year her CAS account was
created). created).
At each connection, we search for a Clipper account with the given clipper login At each connection, we search for a CAS account with the given CAS login and create
(uid) and create one if none exists. In case the Clipper account's entrance year one if none exists. In case the CAS account's entrance year does not match the
does not match the entrance year given by CAS, it means it is a old account and it entrance year given by CAS, it means it is a old account and it must be deleted. The
must be deleted. The corresponding user can still connect using regular Django corresponding user can still connect using regular Django authentication.
authentication.
""" """
def authenticate(self, request, ticket=None): def authenticate(self, request, ticket=None):
@ -57,8 +61,8 @@ class ENSCASBackend:
year = get_entrance_year(attributes) year = get_entrance_year(attributes)
return self._get_or_create(uid, year) return self._get_or_create(uid, year)
def get_free_username(self, clipper_uid): def get_free_username(self, cas_login):
"""Find an available username for the new user. """Find an available username for a new user.
If you override this method, make sure it returns a username that is not taken If you override this method, make sure it returns a username that is not taken
by any existing user. by any existing user.
@ -69,35 +73,46 @@ class ENSCASBackend:
taken = UserModel.objects.values_list("username", flat=True) taken = UserModel.objects.values_list("username", flat=True)
# This should handle most cases and produce a nice username. # 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) pref_taken = taken.filter(username__in=prefered)
for name in prefered: for name in prefered:
if name not in pref_taken: if name not in pref_taken:
return name return name
# Worst case: generate a username of the form clipper_uid + int # Worst case: generate a username of the form cas_login + int
taken = taken.filter(username__startswith=clipper_uid) taken = taken.filter(username__startswith=cas_login)
i = 2 i = 2
while clipper_uid + str(i) in taken: while cas_login + str(i) in taken:
i += 1 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(): with transaction.atomic():
try: try:
user = UserModel.objects.get(clipper__uid=uid) user = UserModel.objects.get(cas_account__cas_login=cas_login)
if user.clipper.entrance_year != entrance_year: if user.cas_account.entrance_year != entrance_year:
user.clipper.delete() user.cas_account.delete()
user = None user = None
except UserModel.DoesNotExist: except UserModel.DoesNotExist:
user = None user = None
if user is 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) 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 return user
# Django boilerplate.
def get_user(self, user_id): def get_user(self, user_id):
try: try:
return UserModel.objects.get(pk=user_id) return UserModel.objects.get(pk=user_id)

View file

@ -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.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -15,7 +15,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Clipper", name="CASAccount",
fields=[ fields=[
( (
"id", "id",
@ -27,30 +27,30 @@ class Migration(migrations.Migration):
), ),
), ),
( (
"uid", "cas_login",
models.CharField( models.CharField(
max_length=1023, unique=True, verbose_name="login clipper" max_length=1023, unique=True, verbose_name="login CAS"
), ),
), ),
( (
"entrance_year", "entrance_year",
models.SmallIntegerField( models.SmallIntegerField(
verbose_name="année de création du compte clipper" verbose_name="année de création du compte CAS"
), ),
), ),
( (
"user", "user",
models.OneToOneField( models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="clipper", related_name="cas_account",
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
verbose_name="utilisateurice", verbose_name="utilisateurice",
), ),
), ),
], ],
options={ options={
"verbose_name": "Compte clipper", "verbose_name": "Compte CAS",
"verbose_name_plural": "Comptes clipper", "verbose_name_plural": "Comptes CAS",
}, },
), ),
] ]

View file

@ -5,40 +5,35 @@ from django.utils.translation import gettext_lazy as _
User = get_user_model() User = get_user_model()
class Clipper(models.Model): class CASAccount(models.Model):
"""Information about clipper accounts. """Information about CAS accounts.
A user is given an instance of this model iff it has a clipper account. The `uid` A user is given an instance of this model iff she has a CAS account.
field is the clipper login and is used for CAS authentication.
At each connection, we check that the `entrance_year` we have is consistent with the Instances of this model should only be created by the `ENSCASBackend` authentication
meta-data returned by the SPI's CAS. backend.
- 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.
""" """
user = models.OneToOneField( user = models.OneToOneField(
User, User,
verbose_name=_("utilisateurice"), verbose_name=_("utilisateurice"),
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="clipper", related_name="cas_account",
) )
uid = models.CharField( cas_login = models.CharField(
verbose_name=_("login clipper"), max_length=1023, blank=False, unique=True, verbose_name=_("login CAS"), max_length=1023, blank=False, unique=True,
) )
entrance_year = models.SmallIntegerField( 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: class Meta:
verbose_name = _("Compte clipper") verbose_name = _("Compte CAS")
verbose_name_plural = _("Comptes clipper") verbose_name_plural = _("Comptes CAS")
def __str__(self): def __str__(self):
return _("compte clipper %(uid)s@%(entrance_year)s lié à %(user)s") % { return _("compte CAS %(cas_login) (promo %(entrance_year)s) lié à %(user)s") % {
"uid": self.uid, "cas_login": self.cas_login,
"entrance_year": self.entrance_year, "entrance_year": self.entrance_year,
"user": self.user.username, "user": self.user.username,
} }