From 5644ea929096709f4a0362a4089f21d523212e6a Mon Sep 17 00:00:00 2001 From: Robin Champenois Date: Sun, 17 Jan 2021 23:48:40 +0100 Subject: [PATCH] =?UTF-8?q?Int=C3=A9gration=20AuthENS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avisstage/allauth_adapter.py | 57 ---------- avisstage/apps.py | 3 - .../management/commands/termine_scolarite.py | 12 ++ .../migrations/0005_normalien_en_scolarite.py | 18 +++ avisstage/models.py | 105 ++++++++++-------- .../templates/avisstage/detail/profil.html | 4 +- .../templates/avisstage/detail/stage.html | 2 +- avisstage/templates/avisstage/perso.html | 14 +-- avisstage/utils.py | 8 +- experiENS/auth.py | 15 ++- experiENS/settings_base.py | 23 +--- experiENS/urls.py | 1 - 12 files changed, 116 insertions(+), 146 deletions(-) delete mode 100644 avisstage/allauth_adapter.py create mode 100644 avisstage/management/commands/termine_scolarite.py create mode 100644 avisstage/migrations/0005_normalien_en_scolarite.py diff --git a/avisstage/allauth_adapter.py b/avisstage/allauth_adapter.py deleted file mode 100644 index 1935e47..0000000 --- a/avisstage/allauth_adapter.py +++ /dev/null @@ -1,57 +0,0 @@ -from allauth.account.adapter import DefaultAccountAdapter -from allauth.socialaccount.models import SocialAccount -from allauth_ens.adapter import LongTermClipperAccountAdapter, get_ldap_infos - -class AccountAdapter(DefaultAccountAdapter): - def is_open_for_signup(self, request): - return False - - -class SocialAccountAdapter(LongTermClipperAccountAdapter): - def is_open_for_signup(self, request, sociallogin): - # sociallogin.account is a SocialAccount instance. - # See https://github.com/pennersr/django-allauth/blob/master/allauth/socialaccount/models.py - - if sociallogin.account.provider == 'clipper': - return True - - # It returns AccountAdapter.is_open_for_signup(). - # See https://github.com/pennersr/django-allauth/blob/master/allauth/socialaccount/adapter.py - return super().is_open_for_signup(request, sociallogin) - - - # TODO : HOTFIX pour un bug d'allauth_ens - # On remplace la déduplication des comptes faites avec "entrance_year" - # par une déduplication sur le nom d'utilisateur - # (Copié de allauth_ens) - def pre_social_login(self, request, sociallogin): - if sociallogin.account.provider != "clipper": - return super(LongTermClipperAccountAdapter, - self).pre_social_login(request, sociallogin) - - clipper_uid = sociallogin.account.uid - try: - old_conn = SocialAccount.objects.get(provider='clipper_inactive', - uid=clipper_uid) - except SocialAccount.DoesNotExist: - return - - ldap_data = get_ldap_infos(clipper_uid) - sociallogin._ldap_data = ldap_data - - if ldap_data is None or 'entrance_year' not in ldap_data: - raise ValueError("No entrance year in LDAP data") - - old_conn_username = old_conn.user.username - - # HOTFIX ICI - if self.get_username(clipper_uid, ldap_data) != old_conn_username: - email = ldap_data.get('email', get_clipper_email(clipper_uid)) - remove_email(old_conn.user, email) - - return - - old_conn.provider = 'clipper' - old_conn.save() - - sociallogin.lookup() diff --git a/avisstage/apps.py b/avisstage/apps.py index afc1822..4fa2dd9 100644 --- a/avisstage/apps.py +++ b/avisstage/apps.py @@ -1,7 +1,4 @@ -from __future__ import unicode_literals - from django.apps import AppConfig - class AvisstageConfig(AppConfig): name = 'avisstage' diff --git a/avisstage/management/commands/termine_scolarite.py b/avisstage/management/commands/termine_scolarite.py new file mode 100644 index 0000000..b2b2637 --- /dev/null +++ b/avisstage/management/commands/termine_scolarite.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand, CommandError +from avisstage.models import Normalien + +class Command(BaseCommand): + help = 'Réinitialise les statuts "en scolarité" de tout le monde' + + def add_arguments(self, parser): + return + + def handle(self, *args, **options): + Normalien.objects.all().update(en_scolarite=False) + self.stdout.write(self.style.SUCCESS(u'Terminé')) diff --git a/avisstage/migrations/0005_normalien_en_scolarite.py b/avisstage/migrations/0005_normalien_en_scolarite.py new file mode 100644 index 0000000..da85767 --- /dev/null +++ b/avisstage/migrations/0005_normalien_en_scolarite.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2021-01-17 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('avisstage', '0004_allauth_to_authens'), + ] + + operations = [ + migrations.AddField( + model_name='normalien', + name='en_scolarite', + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/avisstage/models.py b/avisstage/models.py index 595cbf4..79f96f0 100644 --- a/avisstage/models.py +++ b/avisstage/models.py @@ -1,10 +1,3 @@ -# coding: utf-8 - -from __future__ import unicode_literals - -from allauth.account.models import EmailAddress -from allauth.socialaccount.models import SocialAccount - from django.db import models from django.db.models.signals import post_save from django.contrib.auth.models import User @@ -20,10 +13,14 @@ from django.utils.html import strip_tags from taggit_autosuggest.managers import TaggableManager from tinymce.models import HTMLField as RichTextField -from .utils import choices_length -from .statics import DEPARTEMENTS_DEFAUT, PAYS_OPTIONS, TYPE_LIEU_OPTIONS, TYPE_STAGE_OPTIONS, TYPE_LIEU_DICT, TYPE_STAGE_DICT, NIVEAU_SCOL_OPTIONS, NIVEAU_SCOL_DICT +from authens.signals import post_cas_connect +from authens.models import CASAccount -import ldap +from .utils import choices_length, is_email_ens +from .statics import ( + DEPARTEMENTS_DEFAUT, PAYS_OPTIONS, TYPE_LIEU_OPTIONS, TYPE_STAGE_OPTIONS, TYPE_LIEU_DICT, + TYPE_STAGE_DICT, NIVEAU_SCOL_OPTIONS, NIVEAU_SCOL_DICT +) # # Profil Normalien (extension du modèle User) @@ -40,7 +37,8 @@ class Normalien(models.Model): max_length=200, blank=True) contactez_moi = models.BooleanField(u"Inviter les visiteurs à me contacter", default=True) - bio = models.TextField(u"À propos de moi", blank=True, default=""); + bio = models.TextField(u"À propos de moi", blank=True, default="") + en_scolarite = models.BooleanField(default=False, blank=True) class Meta: verbose_name = u"Profil élève" @@ -53,53 +51,62 @@ class Normalien(models.Model): def stages_publics(self): return self.stages.filter(public=True).order_by('-date_debut') - @cached_property - def en_scolarite(self): - return SocialAccount.objects.filter(user_id=self.user_id, - provider="clipper").exists() - def has_nonENS_email(self): - a = EmailAddress.objects.filter(user_id=self.user_id, - verified=True) \ - .exclude(email__endswith="ens.fr") - return a.exists() + return not ( + is_email_ens(self.mail, True) + and is_email_ens(self.user.email, True) + ) + + def nom_complet(self): + if self.nom.strip(): + return self.nom + return self.user.username @property def preferred_email(self): - a = EmailAddress.objects.filter(user_id=self.user_id, - verified=True) \ - .exclude(email__endswith="ens.fr")\ - .order_by('-primary') - if len(a) == 0: - a = EmailAddress.objects.filter(user_id=self.user_id, - verified=True) \ - .order_by('-primary') - if len(a) == 0: - return "" - else: - return a[0].email + return self.user.email -# Hook à la création d'un nouvel utilisateur : récupération de ses infos par LDAP -def create_user_profile(sender, instance, created, **kwargs): +# Hook à la création d'un nouvel utilisateur : information de base +def create_basic_user_profile(sender, instance, created, **kwargs): if created: profil, created = Normalien.objects.get_or_create(user=instance) - try: - saccount = SocialAccount.objects.get(user=instance, - provider="clipper") - except SocialAccount.DoesNotExist: - profil.save() - return - edata = saccount.extra_data.get("ldap", {}) - dep = "" - if "department_code" in edata: - dep = dict(DEPARTEMENTS_DEFAUT).get( - edata["department_code"].lower(), '') - profil.promotion = "%s %s" % (dep, edata["entrance_year"]) - profil.nom = edata.get("name", "") - profil.save() + if not created and profil.promotion != "": + return + + if "@" in instance.username: + profil.promotion = instance.username.split("@")[1] + profil.save() -post_save.connect(create_user_profile, sender=User) +post_save.connect(create_basic_user_profile, sender=User) + +# Hook d'authENS : information du CAS +def handle_cas_connection(sender, instance, created, cas_login, attributes, **kwargs): + profil, created = Normalien.objects.get_or_create(user=instance) + + if not created: + if not profil.en_scolarite: + profil.en_scolarite = True + profil.save() + return + + dirs = attributes.get("homeDirectory", "").split("/") + if len(dirs) < 4: + print("HomeDirectory invalide", dirs) + return + + year = dirs[2] + departement = dirs[3] + print(departement, dirs) + + dep = dict(DEPARTEMENTS_DEFAUT).get(departement.lower(), "") + + profil.promotion = "%s %s" % (dep, year) + profil.nom = attributes.get("name", "") + profil.en_scolarite = True + profil.save() + +post_cas_connect.connect(handle_cas_connection, sender=User) # # Lieu de stage diff --git a/avisstage/templates/avisstage/detail/profil.html b/avisstage/templates/avisstage/detail/profil.html index 68281d9..75ca174 100644 --- a/avisstage/templates/avisstage/detail/profil.html +++ b/avisstage/templates/avisstage/detail/profil.html @@ -5,10 +5,10 @@ {% endblock %} -{% block title %}Profil de {{ object.nom }} - ExperiENS{% endblock %} +{% block title %}Profil de {{ object.nom_complet }} - ExperiENS{% endblock %} {% block content %} -

Profil de {{ object.nom }} +

Profil de {{ object.nom_complet }} {% if object.user == user %} Modifier mes infos {% endif %} diff --git a/avisstage/templates/avisstage/detail/stage.html b/avisstage/templates/avisstage/detail/stage.html index 9ec7b37..cb0d587 100644 --- a/avisstage/templates/avisstage/detail/stage.html +++ b/avisstage/templates/avisstage/detail/stage.html @@ -72,7 +72,7 @@

{{ object.sujet }}

{{ object.date_debut|date:"Y" }}{{ object.date_debut|date:"d/m" }}{{ object.date_fin|date:"d/m" }}

-

{{ object.auteur.nom }} +

{{ object.auteur.nom_complet }} a fait {{ object.type_stage_fem|yesno:"cette,ce" }} {{ object.type_stage_fancy }} {% if object.niveau_scol %}{{ object.niveau_scol_fancy }},{% endif %} {% if object.structure %}au sein de {{ object.structure }}{% endif %}{% if object.encadrants %}, supervisé par {{ object.encadrants }}{% endif %}.

diff --git a/avisstage/templates/avisstage/perso.html b/avisstage/templates/avisstage/perso.html index a21d66c..adf992a 100644 --- a/avisstage/templates/avisstage/perso.html +++ b/avisstage/templates/avisstage/perso.html @@ -4,7 +4,7 @@ {% block title %}Espace personnel - ExperiENS{% endblock %} {% block content %} -

Bonjour {{ user.profil.nom }} !

+

Bonjour {{ user.profil.nom_complet }} !

Mon compte

@@ -17,20 +17,20 @@ {% else %}

Statut : Archicube

Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.

-

Si vous êtes encore en scolarité, merci de vous reconnecter en passant par le serveur d'authentification de l'ENS pour mettre à jour votre statut.

+

Si vous êtes encore en scolarité, merci de vous reconnecter en passant par le serveur d'authentification de l'ENS pour mettre à jour votre statut.

{% endif %}

Le statut est mis à jour automatiquement chaque année selon le mode de connexion que vous utilisez.

-

Adresses e-mail

+

Adresse e-mail

{% if not user.profil.has_nonENS_email %}

Vous n'avez pas renseigné d'adresse mail autre que celle de l'ENS. Pensez à le faire, pour que les générations futures puissent toujours vous contacter !

{% endif %} -

Gérer les adresses e-mail liées à mon compte

+

Gérer les adresses e-mail liées à mon compte

Mode de connexion

- {% if user.profil.en_scolarite %}

En scolarité, utilisez le serveur central d'authentification pour vous connecter. Quand vous n'aurez plus de compte clipper, vous devrez vous connecter directement via l'accès archicubes, avec l'identifiant {{ user.username }}

{% endif %} - {% if not user.password %}

Vous n'avez pas créé de mot de passe interne à ExperiENS. Pensez-y pour garder l'accès au site quand vous n'aurez plus de compte clipper !

{% endif %} -

Créer / changer mon mot de passe ExperiENS

+ {% if user.profil.en_scolarite %}

En scolarité, utilisez le serveur central d'authentification pour vous connecter. Quand vous n'aurez plus de compte clipper, vous devrez vous connecter directement via l'accès archicubes, avec votre login {{ user.cas_account.cas_login }} et le mot de passe spécifique à ExperiENS que vous aurez défini.

{% endif %} + {% if not user.password or not user.has_usable_password %}

Vous n'avez pas créé de mot de passe interne à ExperiENS. Pensez-y pour garder l'accès au site quand vous n'aurez plus de compte clipper !

{% endif %} +

Créer / changer mon mot de passe ExperiENS

diff --git a/avisstage/utils.py b/avisstage/utils.py index 9aad26d..57e8410 100644 --- a/avisstage/utils.py +++ b/avisstage/utils.py @@ -1,6 +1,3 @@ -# coding: utf-8 - -from allauth.socialaccount.models import SocialAccount from functools import reduce from math import cos, radians, sqrt @@ -18,3 +15,8 @@ def approximate_distance(a, b): dlat = (lat_a - lat_b) distance = 6371000 * sqrt(dlon*dlon + dlat*dlat) return distance + +def is_email_ens(mail, none=False): + if mail is None: + return none + return mail.endswith(".ens.fr") or mail.endswith(".ens.psl.eu") diff --git a/experiENS/auth.py b/experiENS/auth.py index e310a39..b8f8d41 100644 --- a/experiENS/auth.py +++ b/experiENS/auth.py @@ -1,5 +1,12 @@ -from django_cas_ng.backends import CASBackend +from authens.backends import ENSCASBackend as AuthENSBackend +from authens.utils import parse_entrance_year -class ENSCASBackend(CASBackend): - def clean_username(self, username): - return username.lower().strip() +class ENSCASBackend(AuthENSBackend): + # Override AuthENS backend user creation to implement the @ logic + + def get_free_username(self, cas_login, attributes): + entrance_year = parse_entrance_year(attributes.get("homeDirectory")) + if entrance_year is None: + return super().get_free_username(cas_login, attributes) + entrance_year %= 100 + return "%s@%02d" % (cas_login, entrance_year) diff --git a/experiENS/settings_base.py b/experiENS/settings_base.py index 0962539..7208020 100644 --- a/experiENS/settings_base.py +++ b/experiENS/settings_base.py @@ -36,14 +36,6 @@ INSTALLED_APPS = [ 'django_elasticsearch_dsl', 'widget_tweaks', - 'allauth_ens', - - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'allauth_cas', - - 'allauth_ens.providers.clipper', 'authens', 'tastypie', @@ -118,24 +110,17 @@ STATIC_URL = '/static/' AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', - 'authens.backends.ENSCASBackend', + 'experiENS.auth.ENSCASBackend', ) CAS_SERVER_URL = "https://cas.eleves.ens.fr/" #SPI CAS -CAS_VERIFY_URL = "https://cas.eleves.ens.fr/" -CAS_IGNORE_REFERER = True -CAS_REDIRECT_URL = reverse_lazy('avisstage:perso') -CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" -CAS_FORCE_CHANGE_USERNAME_CASE = "lower" -CAS_VERSION = 'CAS_2_SAML_1_0' -ACCOUNT_ADAPTER = 'avisstage.allauth_adapter.AccountAdapter' -SOCIALACCOUNT_ADAPTER = 'avisstage.allauth_adapter.SocialAccountAdapter' +AUTHENS_USE_OLDCAS = False LOGIN_URL = reverse_lazy('authens:login') LOGOUT_URL = reverse_lazy('authens:logout') -LOGIN_REDIRECT_URL = reverse_lazy('avisstage:perso') -ACCOUNT_HOME_URL = reverse_lazy('avisstage:index') +LOGIN_REDIRECT_URL = "/perso/"#reverse_lazy('avisstage:perso') +LOGOUT_REDIRECT_URL = "/" LOGGING = { 'version': 1, diff --git a/experiENS/urls.py b/experiENS/urls.py index 4a8e6f7..4bb2bfb 100644 --- a/experiENS/urls.py +++ b/experiENS/urls.py @@ -7,7 +7,6 @@ urlpatterns = [ path("authens/", include("authens.urls")), - path('account/', include('allauth_ens.urls')), path('tinymce/', include('tinymce.urls')), path('taggit_autosuggest/', include('taggit_autosuggest.urls')),