Intégration AuthENS

This commit is contained in:
Robin Champenois 2021-01-17 23:48:40 +01:00
parent 79eb294ce5
commit 5644ea9290
12 changed files with 116 additions and 146 deletions

View file

@ -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()

View file

@ -1,7 +1,4 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class AvisstageConfig(AppConfig):
name = 'avisstage'

View file

@ -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é'))

View file

@ -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),
),
]

View file

@ -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

View file

@ -5,10 +5,10 @@
<script type="text/javascript" src="{% static "js/render.js" %}"></script>
{% endblock %}
{% block title %}Profil de {{ object.nom }} - ExperiENS{% endblock %}
{% block title %}Profil de {{ object.nom_complet }} - ExperiENS{% endblock %}
{% block content %}
<h1>Profil de {{ object.nom }}
<h1>Profil de {{ object.nom_complet }}
{% if object.user == user %}
<a href="{% url "avisstage:profil_edit" %}" class="btn edit-btn">Modifier mes infos</a>
{% endif %}

View file

@ -72,7 +72,7 @@
<h1>{{ object.sujet }}</h1>
<p class="dates"><span class="year">{{ object.date_debut|date:"Y" }}</span><span class="debut">{{ object.date_debut|date:"d/m" }}</span><span class="fin">{{ object.date_fin|date:"d/m" }}</span></p>
</div>
<p><a href="{% url "avisstage:profil" object.auteur.user.username %}">{{ object.auteur.nom }}</a>
<p><a href="{% url "avisstage:profil" object.auteur.user.username %}">{{ object.auteur.nom_complet }}</a>
a fait {{ object.type_stage_fem|yesno:"cette,ce" }} <b>{{ object.type_stage_fancy }}</b>
{% 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 %}.</p>

View file

@ -4,7 +4,7 @@
{% block title %}Espace personnel - ExperiENS{% endblock %}
{% block content %}
<h1>Bonjour {{ user.profil.nom }} !</h1>
<h1>Bonjour {{ user.profil.nom_complet }} !</h1>
<article>
<h2>Mon compte</h2>
@ -17,20 +17,20 @@
{% else %}
<h3 class="scolarite">Statut : Archicube</h3>
<p>Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.</p>
<p>Si vous êtes encore en scolarité, merci de vous <a href="{% url "clipper_login" %}?process=connect">reconnecter en passant par le serveur d'authentification de l'ENS</a> pour mettre à jour votre statut.</p>
<p>Si vous êtes encore en scolarité, merci de vous <a href="{% url "authens:login.cas" %}">reconnecter en passant par le serveur d'authentification de l'ENS</a> pour mettre à jour votre statut.</p>
{% endif %}
<p><i>Le statut est mis à jour automatiquement chaque année selon le mode de connexion que vous utilisez.</i></p>
</section>
<section class="profil">
<h3>Adresses e-mail</h3>
<h3>Adresse e-mail</h3>
{% if not user.profil.has_nonENS_email %}<p align="center" class="warning">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 !</p>{% endif %}
<p><a href="{% url "account_email" %}">Gérer les adresses e-mail liées à mon compte</a></p>
<p><a href="{% url "avisstage:perso" %}">Gérer les adresses e-mail liées à mon compte</a></p>
</section>
<section class="profil">
<h3>Mode de connexion</h3>
{% if user.profil.en_scolarite %}<p>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 }}</p>{% endif %}
{% if not user.password %}<p class="warning" align="center">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 !</p>{% endif %}
<p><a href="{% url "account_change_password" %}">Créer / changer mon mot de passe ExperiENS</a></p>
{% if user.profil.en_scolarite %}<p>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.</p>{% endif %}
{% if not user.password or not user.has_usable_password %}<p class="warning" align="center">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 !</p>{% endif %}
<p><a href="{% url "authens:reset.pwd" %}">Créer / changer mon mot de passe ExperiENS</a></p>
</section>
</article>

View file

@ -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")

View file

@ -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 @<promo> 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)

View file

@ -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,

View file

@ -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')),