From 021d50fadeb755054ccc6104702e3c60293ec8cf Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 22 Apr 2018 15:31:41 +0200 Subject: [PATCH 01/21] An adapter to handle the 'end of scolarity' problem --- allauth_ens/adapter.py | 132 ++++++++++++++++++ allauth_ens/management/__init__.py | 0 allauth_ens/management/commands/__init__.py | 0 .../management/commands/deprecate_clippers.py | 14 ++ allauth_ens/providers/clipper/provider.py | 37 +---- example/adapter.py | 4 +- 6 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 allauth_ens/adapter.py create mode 100644 allauth_ens/management/__init__.py create mode 100644 allauth_ens/management/commands/__init__.py create mode 100644 allauth_ens/management/commands/deprecate_clippers.py diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py new file mode 100644 index 0000000..e99287d --- /dev/null +++ b/allauth_ens/adapter.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +import ldap + +from allauth.account.utils import user_email, user_field, user_username +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.models import SocialAccount + +DEPARTMENTS_LIST = ( + ('phy', u'Physique'), + ('maths', u'Maths'), + ('bio', u'Biologie'), + ('chimie', u'Chimie'), + ('geol', u'Géosciences'), + ('dec', u'DEC'), + ('info', u'Informatique'), + ('litt', u'Littéraire'), + ('guests', u'Pensionnaires étrangers'), + ('pei', u'PEI'), +) + +def get_ldap_infos(clipper): + assert clipper.isalnum() + data = {'email':'{}@clipper.ens.fr'.format(clipper.strip().lower())} + try: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, + ldap.OPT_X_TLS_NEVER) + l = ldap.initialize("ldaps://ldap.spi.ens.fr:636") + l.set_option(ldap.OPT_REFERRALS, 0) + l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + l.set_option(ldap.OPT_X_TLS_DEMAND, True) + l.set_option(ldap.OPT_DEBUG_LEVEL, 255) + l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) + l.set_option(ldap.OPT_TIMEOUT, 10) + + info = l.search_s('dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ('(uid=%s)' % (clipper,)), + [str("cn"), + str("mailRoutingAddress"), + str("homeDirectory") ]) + + if len(info) > 0: + infos = info[0][1] + + # Name + data['name'] = infos.get('cn', [''])[0].decode("utf-8") + + # Parsing homeDirectory to get entrance year and departments + annee = '00' + promotion = 'Inconnue' + + if 'homeDirectory' in infos: + dirs = infos['homeDirectory'][0].split('/') + if dirs[1] == 'users': + annee = dirs[2] + dep = dirs[3] + dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') + promotion = u'%s %s' % (dep, annee) + data['annee'] = annee + data['promotion'] = promotion + + # Mail + pmail = infos.get('mailRoutingAddress', []) + if len(pmail) > 0 : + data['email'] = pmail[0] + + except ldap.LDAPError: + pass + + return data + + +class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): + """ + A class to manage the fact that people loose their account at the end of + their scolarity and that their clipper login might be reused later + """ + + def pre_social_login(self, request, sociallogin): + clipper = sociallogin.account.uid + try: + a = SocialAccount.objects.get(provider='old_clipper', + uid=clipper) + # An account with that uid was registered, but potentially + # deprecated at the beginning of the year + # We need to check that the user is still the same as before + ldap_data = get_ldap_infos(clipper) + self._ldap_data = ldap_data + if a.extra_data.get('annee', '-1') != ldap_data.get('annee', '00'): + # The admission year is different + # We need a new SocialAccount + return + + # The admission year is the same, we can update the model + a.provider = 'clipper' + a.save() + sociallogin.lookup() + except SocialAccount.DoesNotExist: + return + + def create_username(self, clipper, data): + return "{}:{}".format(clipper, data.get('annee', '00')) + + def populate_user(self, request, sociallogin, data): + clipper = sociallogin.account.uid + ldap_data = self._ldap_data if hasattr(self, '_ldap_data') else get_ldap_infos(clipper) + # Save extra data (only once) + sociallogin.account.extra_data = sociallogin.extra_data = ldap_data + username = self.create_username(clipper, data) + first_name = data.get('first_name') + last_name = data.get('last_name') + email = ldap_data.get('email') + name = ldap_data.get('name') + user = sociallogin.user + user_username(user, username or '') + user_email(user, email or '') + name_parts = (name or '').split(' ') + user_field(user, 'first_name', first_name or name_parts[0]) + user_field(user, 'last_name', last_name or ' '.join(name_parts[1:])) + print(user.username, user) + return user + +def deprecate_clippers(): + clippers = SocialAccount.objects.filter(provider='clipper') + c_uids = clippers.values_list('uid', flat=True) + + # Clear old clipper accounts that wer replaced by new ones (o avoid conflicts) + SocialAccount.objects.filter(provider='old_clipper', uid__in=c_uids).delete() + + # Deprecate accounts + clippers.update(provider='old_clipper') diff --git a/allauth_ens/management/__init__.py b/allauth_ens/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/allauth_ens/management/commands/__init__.py b/allauth_ens/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/allauth_ens/management/commands/deprecate_clippers.py b/allauth_ens/management/commands/deprecate_clippers.py new file mode 100644 index 0000000..a824fd8 --- /dev/null +++ b/allauth_ens/management/commands/deprecate_clippers.py @@ -0,0 +1,14 @@ +#coding: utf-8 +from django.core.management.base import BaseCommand, CommandError + +from allauth_ens.adapter import deprecate_clippers + +class Command(BaseCommand): + help = 'Deprecates clipper SocialAccounts so as to avoid conflicts' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + deprecate_clippers() + self.stdout.write(self.style.SUCCESS(u'Clippers deprecation successful')) diff --git a/allauth_ens/providers/clipper/provider.py b/allauth_ens/providers/clipper/provider.py index d14bee2..fa057b2 100644 --- a/allauth_ens/providers/clipper/provider.py +++ b/allauth_ens/providers/clipper/provider.py @@ -21,41 +21,14 @@ class ClipperProvider(CASProvider): uid, extra = data return '{}@clipper.ens.fr'.format(uid.strip().lower()) + def extract_uid(self, data): + uid, _ = data + uid = uid.lower().strip() + return uid + def extract_common_fields(self, data): - def get_names(clipper): - assert clipper.isalnum() - try: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, - ldap.OPT_X_TLS_NEVER) - l = ldap.initialize("ldaps://ldap.spi.ens.fr:636") - l.set_option(ldap.OPT_REFERRALS, 0) - l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) - l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) - l.set_option(ldap.OPT_X_TLS_DEMAND, True) - l.set_option(ldap.OPT_DEBUG_LEVEL, 255) - l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) - l.set_option(ldap.OPT_TIMEOUT, 10) - - info = l.search_s('dc=spi,dc=ens,dc=fr', - ldap.SCOPE_SUBTREE, - ('(uid=%s)' % (clipper,)), - [str("cn"), ]) - - if len(info) > 0: - fullname = info[0][1].get('cn', [''])[0].decode("utf-8") - first_name, last_name = fullname.split(' ', 1) - return first_name, last_name - - except ldap.LDAPError: - pass - - return '', '' - common = super(ClipperProvider, self).extract_common_fields(data) - fn, ln = get_names(common['username']) common['email'] = self.extract_email(data) - common['name'] = fn - common['last_name'] = ln return common def extract_email_addresses(self, data): diff --git a/example/adapter.py b/example/adapter.py index 3e288e5..f1079f5 100644 --- a/example/adapter.py +++ b/example/adapter.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- from allauth.account.adapter import DefaultAccountAdapter +from allauth_ens.adapter import LongTermClipperAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter - class AccountAdapter(DefaultAccountAdapter): pass -class SocialAccountAdapter(DefaultSocialAccountAdapter): +class SocialAccountAdapter(LongTermClipperAccountAdapter): pass From cdb9c3c722784247f03da32ba5f56e5df845428d Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 22 Apr 2018 17:22:03 +0200 Subject: [PATCH 02/21] Mail address disambiguation, debug, only one ldap query --- allauth_ens/adapter.py | 67 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index e99287d..92fe990 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -2,7 +2,8 @@ import ldap from allauth.account.utils import user_email, user_field, user_username -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.account.models import EmailAddress +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter from allauth.socialaccount.models import SocialAccount DEPARTMENTS_LIST = ( @@ -87,38 +88,74 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): # We need to check that the user is still the same as before ldap_data = get_ldap_infos(clipper) self._ldap_data = ldap_data - if a.extra_data.get('annee', '-1') != ldap_data.get('annee', '00'): + + if a.user.username != self.get_username(clipper, ldap_data): # The admission year is different # We need a new SocialAccount + # But before that, we need to invalidate the email address of + # the previous user + email = ldap_data.get('email') + print(email, 'deprecate') + u_mails = EmailAddress.objects.filter(user=a.user) + try: + clipper_mail = u_mails.get(email=email) + if clipper_mail.primary: + n_mails = u_mails.filter(primary=False) + if n_mails.exists(): + n_mails[0].set_as_primary() + else: + user_email(a.user, '') + a.user.save() + clipper_mail.delete() + except EmailAddress.DoesNotExist: + pass return - # The admission year is the same, we can update the model + # The admission year is the same, we can update the model and keep + # the previous SocialAccount instance a.provider = 'clipper' a.save() + + # Redo the thing that had failed just before sociallogin.lookup() + except SocialAccount.DoesNotExist: return - def create_username(self, clipper, data): - return "{}:{}".format(clipper, data.get('annee', '00')) + def get_username(self, clipper, data): + """ + Util function to generate a unique username, by default 'clipper@promo' + This is used to disambiguate and recognize if the person is the same + """ + return "{}@{}".format(clipper, data.get('annee', '00')) + + def save_user(self, request, sociallogin, form=None): + print("populate user", sociallogin.account.uid) + user = sociallogin.user + user.set_unusable_password() - def populate_user(self, request, sociallogin, data): clipper = sociallogin.account.uid - ldap_data = self._ldap_data if hasattr(self, '_ldap_data') else get_ldap_infos(clipper) - # Save extra data (only once) - sociallogin.account.extra_data = sociallogin.extra_data = ldap_data - username = self.create_username(clipper, data) - first_name = data.get('first_name') - last_name = data.get('last_name') + ldap_data = self._ldap_data if hasattr(self, '_ldap_data') \ + else get_ldap_infos(clipper) + + username = self.get_username(clipper, ldap_data) email = ldap_data.get('email') name = ldap_data.get('name') - user = sociallogin.user user_username(user, username or '') user_email(user, email or '') name_parts = (name or '').split(' ') - user_field(user, 'first_name', first_name or name_parts[0]) - user_field(user, 'last_name', last_name or ' '.join(name_parts[1:])) + user_field(user, 'first_name', name_parts[0]) + user_field(user, 'last_name', ' '.join(name_parts[1:])) print(user.username, user) + + # Ignore form + get_account_adapter().populate_username(request, user) + + # Save extra data (only once) + sociallogin.account.extra_data = sociallogin.extra_data = ldap_data + sociallogin.save(request) + sociallogin.account.save() + return user def deprecate_clippers(): From 5a78561d175a869f5212a2bec797ec31fc6183ee Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 22 Apr 2018 20:13:42 +0200 Subject: [PATCH 03/21] WIP tests --- allauth_ens/adapter.py | 32 ++++++++----- allauth_ens/tests.py | 103 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index 92fe990..b7a22ab 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -5,6 +5,9 @@ from allauth.account.utils import user_email, user_field, user_username from allauth.account.models import EmailAddress from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter from allauth.socialaccount.models import SocialAccount +from django.conf import settings + +import six DEPARTMENTS_LIST = ( ('phy', u'Physique'), @@ -19,20 +22,25 @@ DEPARTMENTS_LIST = ( ('pei', u'PEI'), ) +def _init_ldap(): + server = getattr(settings, "LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, + ldap.OPT_X_TLS_NEVER) + l = ldap.initialize(server) + l.set_option(ldap.OPT_REFERRALS, 0) + l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + l.set_option(ldap.OPT_X_TLS_DEMAND, True) + l.set_option(ldap.OPT_DEBUG_LEVEL, 255) + l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) + l.set_option(ldap.OPT_TIMEOUT, 10) + return l + def get_ldap_infos(clipper): assert clipper.isalnum() data = {'email':'{}@clipper.ens.fr'.format(clipper.strip().lower())} try: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, - ldap.OPT_X_TLS_NEVER) - l = ldap.initialize("ldaps://ldap.spi.ens.fr:636") - l.set_option(ldap.OPT_REFERRALS, 0) - l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) - l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) - l.set_option(ldap.OPT_X_TLS_DEMAND, True) - l.set_option(ldap.OPT_DEBUG_LEVEL, 255) - l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) - l.set_option(ldap.OPT_TIMEOUT, 10) + l = _init_ldap() info = l.search_s('dc=spi,dc=ens,dc=fr', ldap.SCOPE_SUBTREE, @@ -163,7 +171,7 @@ def deprecate_clippers(): c_uids = clippers.values_list('uid', flat=True) # Clear old clipper accounts that wer replaced by new ones (o avoid conflicts) - SocialAccount.objects.filter(provider='old_clipper', uid__in=c_uids).delete() + SocialAccount.objects.filter(provider='clipper_inactive', uid__in=c_uids).delete() # Deprecate accounts - clippers.update(provider='old_clipper') + clippers.update(provider='clipper_inactive') diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index ef2a98c..ee69c3e 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -6,6 +6,17 @@ from django.contrib.sites.models import Site from django.core import mail from django.test import TestCase, override_settings +from mock import patch +from fakeldap import MockLDAP + + +from allauth_cas.test.testcases import CASTestCase, CASViewTestCase +from .adapter import deprecate_clippers +from allauth.socialaccount.models import SocialAccount + +_mock_ldap = MockLDAP() +ldap_patcher = patch('allauth_ens.adapter.ldap.initialize', lambda x: _mock_ldap) + if django.VERSION >= (1, 10): from django.urls import reverse else: @@ -27,11 +38,11 @@ def prevent_logout_pwd_change(client, user): session[HASH_SESSION_KEY] = user.get_session_auth_hash() session.save() - +""" class ViewsTests(TestCase): - """ + "" Checks (barely) that templates do not contain errors. - """ + "" def setUp(self): self.u = User.objects.create_user('user', 'user@mail.net', 'user') @@ -135,3 +146,89 @@ class ViewsTests(TestCase): def test_account_reset_password_from_key_done(self): r = self.client.get(reverse('account_reset_password_from_key_done')) self.assertEqual(r.status_code, 200) +""" + +class LongTermClipperTests(CASTestCase): + + def setUp(self): + ldap_patcher.start() + + def tearDown(self): + _mock_ldap.reset() + + def _setup_ldap(self, promo=12): + _mock_ldap.set_return_value('search_s', + ('dc=spi,dc=ens,dc=fr,uid=test'), + ( + ('cn', ('John Smith')), + ('mailRoutingAddress', ('test@clipper.ens.fr')), + ('homeDirectory', ("/users/%d/phy/test/" % promo)) + )) + + + def test_new_connexion(self): + self._setup_ldap() + + r = self.client_cas_login(self.client, provider_id="clipper", username="test") + u = r.context['user'] + + self.assertEqual(u.username, "test@12") + self.assertEqual(u.first_name, "John") + self.assertEqual(u.last_name, "Smith") + self.assertEqual(u.email, "test@clipper.ens.fr") + + sa = list(SocialAccount.objects.all())[-1] + self.assertEqual(sa.user.id, u.id) + + def test_second_connexion(self): + self._setup_ldap() + + self.client_cas_login(self.client, provider_id="clipper", username="test") + self.client.logout() + + nu = User.objects.count() + + self.client_cas_login(self.client, provider_id="clipper", username="test") + self.assertEqual(User.objects.count(), nu) + + def test_deprecation(self): + self._setup_ldap() + self.client_cas_login(self.client, provider_id="clipper", username="test") + deprecate_clippers() + + sa = SocialAccount.objects.all()[0] + self.assertEqual(sa.provider, "clipper_inactive") + + def test_reconnect_after_deprecation(self): + self._setup_ldap() + self.client_cas_login(self.client, provider_id="clipper", username="test") + nsa = SocialAccount.objects.count() + nu = User.objects.count() + self.client.logout() + + deprecate_clippers() + self.client_cas_login(self.client, provider_id="clipper", username="test") + + sa = SocialAccount.objects.all() + self.assertEqual(len(sa), nsa) + u = User.objects.all() + self.assertEqual(len(u), nu) + self.assertEqual(sa[-1].user.id, u[-1].id) + + def test_override_inactive_account(self): + self._setup_ldap(12) + self.client_cas_login(self.client, provider_id="clipper", username="test") + nsa = SocialAccount.objects.count() + nu = User.objects.count() + self.client.logout() + + deprecate_clippers() + + self._setup_ldap(13) + self.client_cas_login(self.client, provider_id="clipper", username="test") + + sa = SocialAccount.objects.all() + self.assertEqual(len(sa), nsa+1) + u = User.objects.all() + self.assertEqual(len(u), nu+1) + self.assertEqual(sa[-1].user.id, u[-1].id) From dc8873cafc68bc4159880c330b8321fcd8146d70 Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 00:01:27 +0200 Subject: [PATCH 04/21] Tests for adapter ready --- allauth_ens/adapter.py | 7 ++--- allauth_ens/tests.py | 69 ++++++++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index b7a22ab..c08382f 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -89,7 +89,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): def pre_social_login(self, request, sociallogin): clipper = sociallogin.account.uid try: - a = SocialAccount.objects.get(provider='old_clipper', + a = SocialAccount.objects.get(provider='clipper_inactive', uid=clipper) # An account with that uid was registered, but potentially # deprecated at the beginning of the year @@ -103,7 +103,6 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): # But before that, we need to invalidate the email address of # the previous user email = ldap_data.get('email') - print(email, 'deprecate') u_mails = EmailAddress.objects.filter(user=a.user) try: clipper_mail = u_mails.get(email=email) @@ -138,7 +137,6 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): return "{}@{}".format(clipper, data.get('annee', '00')) def save_user(self, request, sociallogin, form=None): - print("populate user", sociallogin.account.uid) user = sociallogin.user user.set_unusable_password() @@ -154,8 +152,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): name_parts = (name or '').split(' ') user_field(user, 'first_name', name_parts[0]) user_field(user, 'last_name', ' '.join(name_parts[1:])) - print(user.username, user) - + # Ignore form get_account_adapter().populate_username(request, user) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index ee69c3e..d64d229 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -157,13 +157,13 @@ class LongTermClipperTests(CASTestCase): _mock_ldap.reset() def _setup_ldap(self, promo=12): - _mock_ldap.set_return_value('search_s', - ('dc=spi,dc=ens,dc=fr,uid=test'), - ( - ('cn', ('John Smith')), - ('mailRoutingAddress', ('test@clipper.ens.fr')), - ('homeDirectory', ("/users/%d/phy/test/" % promo)) - )) + import ldap + _mock_ldap.directory['dc=spi,dc=ens,dc=fr']={ + 'uid': ['test'], + 'cn': ['John Smith'], + 'mailRoutingAddress' : ['test@clipper.ens.fr'], + 'homeDirectory': ["/users/%d/phy/test/" % promo], + } def test_new_connexion(self): @@ -180,6 +180,15 @@ class LongTermClipperTests(CASTestCase): sa = list(SocialAccount.objects.all())[-1] self.assertEqual(sa.user.id, u.id) + def test_connect_disconnect(self): + self._setup_ldap() + r0 = self.client_cas_login(self.client, provider_id="clipper", username="test") + self.assertIn("_auth_user_id", self.client.session) + self.assertIn('user', r0.context) + + r1 = self.client.logout() + self.assertNotIn("_auth_user_id", self.client.session) + def test_second_connexion(self): self._setup_ldap() @@ -201,34 +210,42 @@ class LongTermClipperTests(CASTestCase): def test_reconnect_after_deprecation(self): self._setup_ldap() - self.client_cas_login(self.client, provider_id="clipper", username="test") - nsa = SocialAccount.objects.count() - nu = User.objects.count() + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") + user0 = r.context['user'] + n_sa0 = SocialAccount.objects.count() + n_u0 = User.objects.count() self.client.logout() deprecate_clippers() - self.client_cas_login(self.client, provider_id="clipper", username="test") - - sa = SocialAccount.objects.all() - self.assertEqual(len(sa), nsa) - u = User.objects.all() - self.assertEqual(len(u), nu) - self.assertEqual(sa[-1].user.id, u[-1].id) + + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") + user1 = r.context['user'] + sa1 = list(SocialAccount.objects.all()) + n_u1 = User.objects.count() + self.assertEqual(len(sa1), n_sa0) + self.assertEqual(n_u1, n_u0) + self.assertEqual(user1.id, user0.id) def test_override_inactive_account(self): self._setup_ldap(12) - self.client_cas_login(self.client, provider_id="clipper", username="test") - nsa = SocialAccount.objects.count() - nu = User.objects.count() + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") + user0 = r.context['user'] + n_sa0 = SocialAccount.objects.count() + n_u0 = User.objects.count() self.client.logout() deprecate_clippers() self._setup_ldap(13) - self.client_cas_login(self.client, provider_id="clipper", username="test") + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") - sa = SocialAccount.objects.all() - self.assertEqual(len(sa), nsa+1) - u = User.objects.all() - self.assertEqual(len(u), nu+1) - self.assertEqual(sa[-1].user.id, u[-1].id) + user1 = r.context['user'] + sa1 = list(SocialAccount.objects.all()) + n_u1 = User.objects.count() + self.assertEqual(len(sa1), n_sa0+1) + self.assertEqual(n_u1, n_u0+1) + self.assertNotEqual(user1.id, user0.id) From 54963e9f915c922dc5ed4787e8f4ab5fbe2ee02b Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 00:22:50 +0200 Subject: [PATCH 05/21] Fix other tests --- allauth_ens/providers/clipper/tests.py | 22 +++++++++++----------- allauth_ens/tests.py | 25 +++++++++++++++---------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/allauth_ens/providers/clipper/tests.py b/allauth_ens/providers/clipper/tests.py index 657982c..32266ac 100644 --- a/allauth_ens/providers/clipper/tests.py +++ b/allauth_ens/providers/clipper/tests.py @@ -8,23 +8,23 @@ User = get_user_model() class ClipperProviderTests(CASTestCase): def setUp(self): - self.u = User.objects.create_user('user', 'user@mail.net', 'user') + pass def test_auto_signup(self): self.client_cas_login( - self.client, provider_id='clipper', username='clipper_uid') + self.client, provider_id='clipper', username='clipperuid') - u = User.objects.get(username='clipper_uid') - self.assertEqual(u.email, 'clipper_uid@clipper.ens.fr') + u = User.objects.get(username__contains='clipperuid') + self.assertEqual(u.email, 'clipperuid@clipper.ens.fr') class ClipperViewsTests(CASViewTestCase): def test_login_view(self): - r = self.client.get('/accounts/clipper/login/') + r = self.client.get('/account/clipper/login/') expected = ( "https://cas.eleves.ens.fr/login?service=http%3A%2F%2Ftestserver" - "%2Faccounts%2Fclipper%2Flogin%2Fcallback%2F" + "%2Faccount%2Fclipper%2Flogin%2Fcallback%2F" ) self.assertRedirects( r, expected, @@ -33,20 +33,20 @@ class ClipperViewsTests(CASViewTestCase): def test_callback_view(self): # Required to initialize a SocialLogin. - r = self.client.get('/accounts/clipper/login/') + r = self.client.get('/account/clipper/login/') # Tests. self.patch_cas_response(valid_ticket='__all__') - r = self.client.get('/accounts/clipper/login/callback/', { + r = self.client.get('/account/clipper/login/callback/', { 'ticket': '123456', }) - self.assertLoginSuccess(r) + self.assertLoginSuccess(r, "/user/") def test_logout_view(self): - r = self.client.get('/accounts/clipper/logout/') + r = self.client.get('/account/clipper/logout/') expected = ( "https://cas.eleves.ens.fr/logout?service=http%3A%2F%2Ftestserver" - "%2F" + "%2Fview%2F" ) self.assertRedirects( r, expected, diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index d64d229..96d95fb 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -38,14 +38,19 @@ def prevent_logout_pwd_change(client, user): session[HASH_SESSION_KEY] = user.get_session_auth_hash() session.save() -""" -class ViewsTests(TestCase): - "" - Checks (barely) that templates do not contain errors. - "" - def setUp(self): - self.u = User.objects.create_user('user', 'user@mail.net', 'user') +class ViewsTests(TestCase): + """ + Checks (barely) that templates do not contain errors. + """ + def setUp(self): + try: + self.u = User.objects.get(username="user") + self.u.email = "user@mail.net" + self.u.save() + except User.DoesNotExist: + self.u = User.objects.create_user('user', 'user@mail.net', 'user') + Site.objects.filter(pk=1).update(domain='testserver') def _login(self, client=None): @@ -55,14 +60,14 @@ class ViewsTests(TestCase): def _get_confirm_email_link(self, email_msg): m = re.search( - r'http://testserver(/accounts/confirm-email/.*/)', + r'http://testserver(/account/confirm-email/.*/)', email_msg.body, ) return m.group(1) def _get_reset_password_link(self, email_msg): m = re.search( - r'http://testserver(/accounts/password/reset/key/.*/)', + r'http://testserver(/account/password/reset/key/.*/)', email_msg.body, ) return m.group(1) @@ -146,7 +151,7 @@ class ViewsTests(TestCase): def test_account_reset_password_from_key_done(self): r = self.client.get(reverse('account_reset_password_from_key_done')) self.assertEqual(r.status_code, 200) -""" + class LongTermClipperTests(CASTestCase): From 1142db73f77ae3788ea590a007b6952e1085a8bd Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 00:35:57 +0200 Subject: [PATCH 06/21] Revert "Fix other tests" This reverts commit 54963e9f915c922dc5ed4787e8f4ab5fbe2ee02b. --- allauth_ens/providers/clipper/tests.py | 22 +++++++++++----------- allauth_ens/tests.py | 21 ++++++++------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/allauth_ens/providers/clipper/tests.py b/allauth_ens/providers/clipper/tests.py index 32266ac..657982c 100644 --- a/allauth_ens/providers/clipper/tests.py +++ b/allauth_ens/providers/clipper/tests.py @@ -8,23 +8,23 @@ User = get_user_model() class ClipperProviderTests(CASTestCase): def setUp(self): - pass + self.u = User.objects.create_user('user', 'user@mail.net', 'user') def test_auto_signup(self): self.client_cas_login( - self.client, provider_id='clipper', username='clipperuid') + self.client, provider_id='clipper', username='clipper_uid') - u = User.objects.get(username__contains='clipperuid') - self.assertEqual(u.email, 'clipperuid@clipper.ens.fr') + u = User.objects.get(username='clipper_uid') + self.assertEqual(u.email, 'clipper_uid@clipper.ens.fr') class ClipperViewsTests(CASViewTestCase): def test_login_view(self): - r = self.client.get('/account/clipper/login/') + r = self.client.get('/accounts/clipper/login/') expected = ( "https://cas.eleves.ens.fr/login?service=http%3A%2F%2Ftestserver" - "%2Faccount%2Fclipper%2Flogin%2Fcallback%2F" + "%2Faccounts%2Fclipper%2Flogin%2Fcallback%2F" ) self.assertRedirects( r, expected, @@ -33,20 +33,20 @@ class ClipperViewsTests(CASViewTestCase): def test_callback_view(self): # Required to initialize a SocialLogin. - r = self.client.get('/account/clipper/login/') + r = self.client.get('/accounts/clipper/login/') # Tests. self.patch_cas_response(valid_ticket='__all__') - r = self.client.get('/account/clipper/login/callback/', { + r = self.client.get('/accounts/clipper/login/callback/', { 'ticket': '123456', }) - self.assertLoginSuccess(r, "/user/") + self.assertLoginSuccess(r) def test_logout_view(self): - r = self.client.get('/account/clipper/logout/') + r = self.client.get('/accounts/clipper/logout/') expected = ( "https://cas.eleves.ens.fr/logout?service=http%3A%2F%2Ftestserver" - "%2Fview%2F" + "%2F" ) self.assertRedirects( r, expected, diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index 96d95fb..d64d229 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -38,19 +38,14 @@ def prevent_logout_pwd_change(client, user): session[HASH_SESSION_KEY] = user.get_session_auth_hash() session.save() - +""" class ViewsTests(TestCase): - """ + "" Checks (barely) that templates do not contain errors. - """ + "" def setUp(self): - try: - self.u = User.objects.get(username="user") - self.u.email = "user@mail.net" - self.u.save() - except User.DoesNotExist: - self.u = User.objects.create_user('user', 'user@mail.net', 'user') - + self.u = User.objects.create_user('user', 'user@mail.net', 'user') + Site.objects.filter(pk=1).update(domain='testserver') def _login(self, client=None): @@ -60,14 +55,14 @@ class ViewsTests(TestCase): def _get_confirm_email_link(self, email_msg): m = re.search( - r'http://testserver(/account/confirm-email/.*/)', + r'http://testserver(/accounts/confirm-email/.*/)', email_msg.body, ) return m.group(1) def _get_reset_password_link(self, email_msg): m = re.search( - r'http://testserver(/account/password/reset/key/.*/)', + r'http://testserver(/accounts/password/reset/key/.*/)', email_msg.body, ) return m.group(1) @@ -151,7 +146,7 @@ class ViewsTests(TestCase): def test_account_reset_password_from_key_done(self): r = self.client.get(reverse('account_reset_password_from_key_done')) self.assertEqual(r.status_code, 200) - +""" class LongTermClipperTests(CASTestCase): From 6fc012cb3926658168d6c548b6dccd5536adb872 Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 00:38:37 +0200 Subject: [PATCH 07/21] Fix my tests --- allauth_ens/tests.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index d64d229..6a86c6b 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -9,7 +9,6 @@ from django.test import TestCase, override_settings from mock import patch from fakeldap import MockLDAP - from allauth_cas.test.testcases import CASTestCase, CASViewTestCase from .adapter import deprecate_clippers from allauth.socialaccount.models import SocialAccount @@ -38,11 +37,10 @@ def prevent_logout_pwd_change(client, user): session[HASH_SESSION_KEY] = user.get_session_auth_hash() session.save() -""" class ViewsTests(TestCase): - "" + """ Checks (barely) that templates do not contain errors. - "" + """ def setUp(self): self.u = User.objects.create_user('user', 'user@mail.net', 'user') @@ -146,8 +144,8 @@ class ViewsTests(TestCase): def test_account_reset_password_from_key_done(self): r = self.client.get(reverse('account_reset_password_from_key_done')) self.assertEqual(r.status_code, 200) -""" +@override_settings(SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter') class LongTermClipperTests(CASTestCase): def setUp(self): From 9c3fc72ec09c45f77ce9f20f3f848ebca5d43d33 Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 01:13:03 +0200 Subject: [PATCH 08/21] One more test case --- allauth_ens/tests.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index 6a86c6b..cafac82 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -154,10 +154,10 @@ class LongTermClipperTests(CASTestCase): def tearDown(self): _mock_ldap.reset() - def _setup_ldap(self, promo=12): + def _setup_ldap(self, promo=12, username="test"): import ldap _mock_ldap.directory['dc=spi,dc=ens,dc=fr']={ - 'uid': ['test'], + 'uid': [username], 'cn': ['John Smith'], 'mailRoutingAddress' : ['test@clipper.ens.fr'], 'homeDirectory': ["/users/%d/phy/test/" % promo], @@ -247,3 +247,31 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(len(sa1), n_sa0+1) self.assertEqual(n_u1, n_u0+1) self.assertNotEqual(user1.id, user0.id) + + def test_multiple_deprecation(self): + self._setup_ldap(12) + self._setup_ldap(15, "truc") + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") + self.client.logout() + r = self.client_cas_login(self.client, provider_id="clipper", + username="truc") + self.client.logout() + sa0 = SocialAccount.objects.count() + + deprecate_clippers() + + self._setup_ldap(13) + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") + self.client.logout() + + sa1 = SocialAccount.objects.count() + + deprecate_clippers() + sa2 = SocialAccount.objects.count() + + # Older "test" inactive SocialAccount gets erased by new one + # while "truc" remains + self.assertEqual(sa0, sa2) + self.assertEqual(sa1, sa0+1) From b6f5acaa46d234c18af2454a3ae8c1343c13fabd Mon Sep 17 00:00:00 2001 From: Evarin Date: Mon, 23 Apr 2018 01:13:12 +0200 Subject: [PATCH 09/21] Readme --- README.rst | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 754fdf4..84f0a4c 100644 --- a/README.rst +++ b/README.rst @@ -158,13 +158,36 @@ Configuration }, } - + Auto-signup - Poulated data + Populated data - username: ```` - email (primary and verified): ``@clipper.ens.fr`` +Long Term Clipper Adapter +========================= + +We provide an easy-to-use SocialAccountAdapter to handle the fact that Clipper Accounts are not eternal, and that there is no guarantee that the clipper usernames won't be reused later. + +This adapter also handles getting basic information about the user from SPI's LDAP. + +Configuration + Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` in `settings.py` + +Auto-signup + Populated data + - username: ``@`` + - email: from LDAP's `mailRoutingAddress` field, or ``@clipper.ens.fr`` + - first_name, last_name from LDAP's `cn` field + - extra_data in SociallAccount instance, containing these field, plus `anne` and `promotion` from LDAP's `homeDirectory` field (available only on first connection) + +Account deprecation + At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be set on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. + +Customize + You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. + ********* Demo Site ********* @@ -201,7 +224,11 @@ Tests Local environment ----------------- -``$ ./runtests.py`` +Requirements + * fakeldap and mock, install with ``$ pip install mock fakeldap`` + +Run + * ``$ ./runtests.py`` All --- From f0a73f6ef686998444c734a7bc189ef8cc8926c1 Mon Sep 17 00:00:00 2001 From: Evarin Date: Tue, 24 Apr 2018 00:19:42 +0200 Subject: [PATCH 10/21] Util management command to install longtermadapter + fixes --- README.rst | 9 +- allauth_ens/adapter.py | 111 ++++++++++++++---- .../management/commands/install_longterm.py | 27 +++++ allauth_ens/tests.py | 46 +++++++- 4 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 allauth_ens/management/commands/install_longterm.py diff --git a/README.rst b/README.rst index 84f0a4c..d77cd6f 100644 --- a/README.rst +++ b/README.rst @@ -178,15 +178,18 @@ Configuration Auto-signup Populated data - username: ``@`` - - email: from LDAP's `mailRoutingAddress` field, or ``@clipper.ens.fr`` - - first_name, last_name from LDAP's `cn` field - - extra_data in SociallAccount instance, containing these field, plus `anne` and `promotion` from LDAP's `homeDirectory` field (available only on first connection) + - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` + - first_name, last_name from LDAP's *cn* field + - extra_data in SociallAccount instance, containing these field, plus *annee* and *promotion* parsed from LDAP's *homeDirectory* field (available only on first connection) Account deprecation At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be set on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. + +Initial migration + If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, and (in the second case) to create the SocialAccount instances to link CAS and Users. This can be done easily with ``$ python manage.py install_longterm``. ********* Demo Site diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index c08382f..b96f2de 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -3,9 +3,12 @@ import ldap from allauth.account.utils import user_email, user_field, user_username from allauth.account.models import EmailAddress -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter, get_adapter from allauth.socialaccount.models import SocialAccount from django.conf import settings +from django.contrib.auth import get_user_model + +User = get_user_model() import six @@ -36,6 +39,30 @@ def _init_ldap(): l.set_option(ldap.OPT_TIMEOUT, 10) return l +def _extract_infos_from_ldap(infos, data={}): + # Name + data['name'] = infos.get('cn', [''])[0].decode("utf-8") + + # Parsing homeDirectory to get entrance year and departments + annee = '00' + promotion = 'Inconnue' + + if 'homeDirectory' in infos: + dirs = infos['homeDirectory'][0].split('/') + if dirs[1] == 'users': + annee = dirs[2] + dep = dirs[3] + dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') + promotion = u'%s %s' % (dep, annee) + data['annee'] = annee + data['promotion'] = promotion + + # Mail + pmail = infos.get('mailRoutingAddress', []) + if len(pmail) > 0 : + data['email'] = pmail[0] + return data + def get_ldap_infos(clipper): assert clipper.isalnum() data = {'email':'{}@clipper.ens.fr'.format(clipper.strip().lower())} @@ -50,30 +77,8 @@ def get_ldap_infos(clipper): str("homeDirectory") ]) if len(info) > 0: - infos = info[0][1] - - # Name - data['name'] = infos.get('cn', [''])[0].decode("utf-8") - - # Parsing homeDirectory to get entrance year and departments - annee = '00' - promotion = 'Inconnue' - - if 'homeDirectory' in infos: - dirs = infos['homeDirectory'][0].split('/') - if dirs[1] == 'users': - annee = dirs[2] - dep = dirs[3] - dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') - promotion = u'%s %s' % (dep, annee) - data['annee'] = annee - data['promotion'] = promotion - - # Mail - pmail = infos.get('mailRoutingAddress', []) - if len(pmail) > 0 : - data['email'] = pmail[0] - + data = _extract_infos_from_ldap(info[0][1], data) + except ldap.LDAPError: pass @@ -87,6 +92,9 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ def pre_social_login(self, request, sociallogin): + if sociallogin.account.provider != "clipper": + return Super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) + clipper = sociallogin.account.uid try: a = SocialAccount.objects.get(provider='clipper_inactive', @@ -137,6 +145,8 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): return "{}@{}".format(clipper, data.get('annee', '00')) def save_user(self, request, sociallogin, form=None): + if sociallogin.account.provider != "clipper": + return Super(LongTermClipperAccountAdapter, self).save_user(request, sociallogin, form) user = sociallogin.user user.set_unusable_password() @@ -164,6 +174,11 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): return user def deprecate_clippers(): + """ + Marks all the SocialAccount with clipper as deprecated, by setting their + provider to 'clipper_inactive' + """ + clippers = SocialAccount.objects.filter(provider='clipper') c_uids = clippers.values_list('uid', flat=True) @@ -172,3 +187,49 @@ def deprecate_clippers(): # Deprecate accounts clippers.update(provider='clipper_inactive') + +def install_longterm_adapter(fake=False): + """ + Manages the transition from an older django_cas or an allauth_ens installation + without LongTermClipperAccountAdapter + """ + + accounts = {u.username: u for u in User.objects.all() if u.username.isalnum()} + l = _init_ldap() + ltc_adapter = get_adapter() + + info = l.search_s('dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ("(|{})".format(''.join(("(uid=%s)" % (un,)) + for un in accounts.keys()))), + [str("uid"), + str("cn"), + str("mailRoutingAddress"), + str("homeDirectory") ]) + + logs = {"created": [], "updated": []} + cases = [] + + for userinfo in info: + infos = userinfo[1] + data = _extract_infos_from_ldap(infos) + clipper = infos["uid"][0] + user = accounts.get(clipper, None) + if user is None: + continue + user.username = ltc_adapter.get_username(clipper, data) + if fake: + cases.append(clipper) + else: + user.save() + cases.append(user.username) + if SocialAccount.objects.filter(provider='clipper', uid=clipper).exists(): + logs["updated"].append((clipper, user.username)) + continue + sa = SocialAccount(user=user, provider='clipper', uid=clipper, extra_data=data) + if not fake: + sa.save() + logs["created"].append((clipper, user.username)) + + logs["unmodified"] = User.objects.exclude(username__in=cases).values_list("username", flat=True) + return logs diff --git a/allauth_ens/management/commands/install_longterm.py b/allauth_ens/management/commands/install_longterm.py new file mode 100644 index 0000000..f733010 --- /dev/null +++ b/allauth_ens/management/commands/install_longterm.py @@ -0,0 +1,27 @@ +#coding: utf-8 +from django.core.management.base import BaseCommand, CommandError + +from allauth_ens.adapter import install_longterm_adapter + +class Command(BaseCommand): + help = 'Manages the transition from an older django_cas or an allauth_ens installation without LongTermClipperAccountAdapter' + + def add_arguments(self, parser): + parser.add_argument( + '--fake', + action='store_true', + default=False, + help='Does not save the models created/updated, only shows the list', + ) + pass + + def handle(self, *args, **options): + logs = install_longterm_adapter(options.get("fake", False)) + self.stdout.write("Social accounts created : %d" % len(logs["created"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["created"])) + self.stdout.write("Social accounts displaced : %d" % len(logs["updated"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["updated"])) + self.stdout.write("User accounts unmodified : %d" % len(logs["unmodified"])) + self.stdout.write(" ".join(logs["unmodified"])) + + self.stdout.write(self.style.SUCCESS(u'LongTermClipper migration successful')) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index cafac82..ae5ba68 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -10,7 +10,7 @@ from mock import patch from fakeldap import MockLDAP from allauth_cas.test.testcases import CASTestCase, CASViewTestCase -from .adapter import deprecate_clippers +from .adapter import deprecate_clippers, install_longterm_adapter from allauth.socialaccount.models import SocialAccount _mock_ldap = MockLDAP() @@ -250,10 +250,11 @@ class LongTermClipperTests(CASTestCase): def test_multiple_deprecation(self): self._setup_ldap(12) - self._setup_ldap(15, "truc") r = self.client_cas_login(self.client, provider_id="clipper", username="test") self.client.logout() + + self._setup_ldap(15, "truc") r = self.client_cas_login(self.client, provider_id="clipper", username="truc") self.client.logout() @@ -275,3 +276,44 @@ class LongTermClipperTests(CASTestCase): # while "truc" remains self.assertEqual(sa0, sa2) self.assertEqual(sa1, sa0+1) + + def test_longterm_installer_from_allauth(self): + self._setup_ldap(12) + with self.settings(SOCIALACCOUNT_ADAPTER=\ + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user0 = r.context["user"] + nsa0 = SocialAccount.objects.count() + self.assertEqual(user0.username, "test") + self.client.logout() + + l = install_longterm_adapter() + + self.assertEqual(l["updated"], [("test", "test@12")]) + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user1 = r.context["user"] + nsa1 = SocialAccount.objects.count() + self.assertEqual(user1.id, user0.id) + self.assertEqual(nsa1, nsa0) + self.assertEqual(user1.username, "test@12") + + def test_longterm_installer_from_djangocas(self): + with self.settings(SOCIALACCOUNT_ADAPTER=\ + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + user0 = User.objects.create_user('test', 'test@clipper.ens.fr', 'test') + nsa0 = SocialAccount.objects.count() + + self._setup_ldap(12) + + l = install_longterm_adapter() + + self.assertEqual(l["created"], [("test", "test@12")]) + r = self.client_cas_login(self.client, provider_id="clipper", + username='test') + user1 = r.context["user"] + nsa1 = SocialAccount.objects.count() + self.assertEqual(user1.id, user0.id) + self.assertEqual(nsa1, nsa0+1) + self.assertEqual(user1.username, "test@12") From a1671a3dd76cb9e9626826c7b2a0f955627134ef Mon Sep 17 00:00:00 2001 From: Evarin Date: Sat, 28 Apr 2018 16:26:52 +0200 Subject: [PATCH 11/21] Fixes from Elarnon's review --- README.rst | 2 +- allauth_ens/adapter.py | 116 +++++++++++++++++++++-------------------- 2 files changed, 60 insertions(+), 58 deletions(-) diff --git a/README.rst b/README.rst index d77cd6f..2a35879 100644 --- a/README.rst +++ b/README.rst @@ -183,7 +183,7 @@ Auto-signup - extra_data in SociallAccount instance, containing these field, plus *annee* and *promotion* parsed from LDAP's *homeDirectory* field (available only on first connection) Account deprecation - At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be set on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. + At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be put on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index b96f2de..e0cc484 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -12,18 +12,18 @@ User = get_user_model() import six -DEPARTMENTS_LIST = ( - ('phy', u'Physique'), - ('maths', u'Maths'), - ('bio', u'Biologie'), - ('chimie', u'Chimie'), - ('geol', u'Géosciences'), - ('dec', u'DEC'), - ('info', u'Informatique'), - ('litt', u'Littéraire'), - ('guests', u'Pensionnaires étrangers'), - ('pei', u'PEI'), -) +DEPARTMENTS_LIST = { + 'phy': u'Physique', + 'maths': u'Maths', + 'bio': u'Biologie', + 'chimie': u'Chimie', + 'geol': u'Géosciences', + 'dec': u'DEC', + 'info': u'Informatique', + 'litt': u'Littéraire', + 'guests': u'Pensionnaires étrangers', + 'pei': u'PEI', +} def _init_ldap(): server = getattr(settings, "LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") @@ -41,7 +41,7 @@ def _init_ldap(): def _extract_infos_from_ldap(infos, data={}): # Name - data['name'] = infos.get('cn', [''])[0].decode("utf-8") + data['name'] = infos.get('cn', [b''])[0].decode("utf-8") # Parsing homeDirectory to get entrance year and departments annee = '00' @@ -49,17 +49,18 @@ def _extract_infos_from_ldap(infos, data={}): if 'homeDirectory' in infos: dirs = infos['homeDirectory'][0].split('/') - if dirs[1] == 'users': + if len(dirs) >= 4 and dirs[1] == 'users': + # Assume template "/users///clipper/" annee = dirs[2] dep = dirs[3] - dep = dict(DEPARTMENTS_LIST).get(dep.lower(), '') + dep = DEPARTMENTS_LIST.get(dep.lower(), '') promotion = u'%s %s' % (dep, annee) data['annee'] = annee data['promotion'] = promotion # Mail pmail = infos.get('mailRoutingAddress', []) - if len(pmail) > 0 : + if pmail: data['email'] = pmail[0] return data @@ -72,9 +73,9 @@ def get_ldap_infos(clipper): info = l.search_s('dc=spi,dc=ens,dc=fr', ldap.SCOPE_SUBTREE, ('(uid=%s)' % (clipper,)), - [str("cn"), - str("mailRoutingAddress"), - str("homeDirectory") ]) + ['cn', + 'mailRoutingAddress', + 'homeDirectory' ]) if len(info) > 0: data = _extract_infos_from_ldap(info[0][1], data) @@ -93,49 +94,50 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): def pre_social_login(self, request, sociallogin): if sociallogin.account.provider != "clipper": - return Super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) + return super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) clipper = sociallogin.account.uid try: a = SocialAccount.objects.get(provider='clipper_inactive', uid=clipper) - # An account with that uid was registered, but potentially - # deprecated at the beginning of the year - # We need to check that the user is still the same as before - ldap_data = get_ldap_infos(clipper) - self._ldap_data = ldap_data - - if a.user.username != self.get_username(clipper, ldap_data): - # The admission year is different - # We need a new SocialAccount - # But before that, we need to invalidate the email address of - # the previous user - email = ldap_data.get('email') - u_mails = EmailAddress.objects.filter(user=a.user) - try: - clipper_mail = u_mails.get(email=email) - if clipper_mail.primary: - n_mails = u_mails.filter(primary=False) - if n_mails.exists(): - n_mails[0].set_as_primary() - else: - user_email(a.user, '') - a.user.save() - clipper_mail.delete() - except EmailAddress.DoesNotExist: - pass - return - - # The admission year is the same, we can update the model and keep - # the previous SocialAccount instance - a.provider = 'clipper' - a.save() - - # Redo the thing that had failed just before - sociallogin.lookup() - except SocialAccount.DoesNotExist: return + + # An account with that uid was registered, but potentially + # deprecated at the beginning of the year + # We need to check that the user is still the same as before + ldap_data = get_ldap_infos(clipper) + sociallogin._ldap_data = ldap_data + + if a.user.username != self.get_username(clipper, ldap_data): + # The admission year is different + # We need a new SocialAccount + # But before that, we need to invalidate the email address of + # the previous user + email = ldap_data.get('email') + u_mails = EmailAddress.objects.filter(user=a.user) + try: + clipper_mail = u_mails.get(email=email) + if clipper_mail.primary: + n_mails = u_mails.filter(primary=False) + if n_mails.exists(): + n_mails[0].set_as_primary() + else: + user_email(a.user, '') + a.user.save() + clipper_mail.delete() + except EmailAddress.DoesNotExist: + pass + return + + # The admission year is the same, we can update the model and keep + # the previous SocialAccount instance + a.provider = 'clipper' + a.save() + + # Redo the thing that had failed just before + sociallogin.lookup() + def get_username(self, clipper, data): """ @@ -151,7 +153,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): user.set_unusable_password() clipper = sociallogin.account.uid - ldap_data = self._ldap_data if hasattr(self, '_ldap_data') \ + ldap_data = sociallogin._ldap_data if hasattr(sociallogin, '_ldap_data') \ else get_ldap_infos(clipper) username = self.get_username(clipper, ldap_data) @@ -182,7 +184,7 @@ def deprecate_clippers(): clippers = SocialAccount.objects.filter(provider='clipper') c_uids = clippers.values_list('uid', flat=True) - # Clear old clipper accounts that wer replaced by new ones (o avoid conflicts) + # Clear old clipper accounts that were replaced by new ones (to avoid conflicts) SocialAccount.objects.filter(provider='clipper_inactive', uid__in=c_uids).delete() # Deprecate accounts From bfc0bb42ad48e127d3e8d54d9c57f9171855b884 Mon Sep 17 00:00:00 2001 From: Evarin Date: Sat, 28 Apr 2018 16:33:08 +0200 Subject: [PATCH 12/21] Add ldap query counting to tests --- allauth_ens/tests.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index ae5ba68..5272614 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -162,7 +162,11 @@ class LongTermClipperTests(CASTestCase): 'mailRoutingAddress' : ['test@clipper.ens.fr'], 'homeDirectory': ["/users/%d/phy/test/" % promo], } - + + def _count_ldap_queries(self): + queries = _mock_ldap.ldap_methods_called() + count = len([l for l in queries if l != 'set_option']) + return count def test_new_connexion(self): self._setup_ldap() @@ -174,6 +178,7 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(u.first_name, "John") self.assertEqual(u.last_name, "Smith") self.assertEqual(u.email, "test@clipper.ens.fr") + self.assertEqual(self._count_ldap_queries(), 1) sa = list(SocialAccount.objects.all())[-1] self.assertEqual(sa.user.id, u.id) @@ -197,6 +202,7 @@ class LongTermClipperTests(CASTestCase): self.client_cas_login(self.client, provider_id="clipper", username="test") self.assertEqual(User.objects.count(), nu) + self.assertEqual(self._count_ldap_queries(), 1) def test_deprecation(self): self._setup_ldap() @@ -225,6 +231,7 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(len(sa1), n_sa0) self.assertEqual(n_u1, n_u0) self.assertEqual(user1.id, user0.id) + self.assertEqual(self._count_ldap_queries(), 2) def test_override_inactive_account(self): self._setup_ldap(12) From 787efe96d04c0362a0fc676f233cfd66bc1c1b7d Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 29 Apr 2018 01:28:09 +0200 Subject: [PATCH 13/21] LDAP error propagation + README + tox update and corrections --- README.rst | 1 + allauth_ens/adapter.py | 113 ++++++++++------- .../management/commands/deprecate_clippers.py | 8 +- .../management/commands/install_longterm.py | 28 +++-- allauth_ens/providers/clipper/provider.py | 5 +- allauth_ens/tests.py | 115 +++++++++++------- example/adapter.py | 8 +- tox.ini | 3 +- 8 files changed, 171 insertions(+), 110 deletions(-) diff --git a/README.rst b/README.rst index 2a35879..5b30afa 100644 --- a/README.rst +++ b/README.rst @@ -187,6 +187,7 @@ Account deprecation Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. + By default, ``get_username`` raises a ``ValueError`` when the connexion to the LDAP failed or did not allow to retrieve the user's entrance year. Overriding ``get_username`` (as done in the example website) allows to get rid of that behaviour, and for instance attribute a default entrance year. Initial migration If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, and (in the second case) to create the SocialAccount instances to link CAS and Users. This can be done easily with ``$ python manage.py install_longterm``. diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index e0cc484..dadbd2a 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -1,16 +1,18 @@ # -*- coding: utf-8 -*- -import ldap -from allauth.account.utils import user_email, user_field, user_username -from allauth.account.models import EmailAddress -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_account_adapter, get_adapter -from allauth.socialaccount.models import SocialAccount from django.conf import settings from django.contrib.auth import get_user_model -User = get_user_model() +from allauth.account.models import EmailAddress +from allauth.account.utils import user_email, user_field, user_username +from allauth.socialaccount.adapter import ( + DefaultSocialAccountAdapter, get_account_adapter, get_adapter, +) +from allauth.socialaccount.models import SocialAccount -import six +import ldap + +User = get_user_model() DEPARTMENTS_LIST = { 'phy': u'Physique', @@ -25,6 +27,7 @@ DEPARTMENTS_LIST = { 'pei': u'PEI', } + def _init_ldap(): server = getattr(settings, "LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, @@ -39,6 +42,7 @@ def _init_ldap(): l.set_option(ldap.OPT_TIMEOUT, 10) return l + def _extract_infos_from_ldap(infos, data={}): # Name data['name'] = infos.get('cn', [b''])[0].decode("utf-8") @@ -48,7 +52,7 @@ def _extract_infos_from_ldap(infos, data={}): promotion = 'Inconnue' if 'homeDirectory' in infos: - dirs = infos['homeDirectory'][0].split('/') + dirs = infos['homeDirectory'][0].decode("utf-8").split('/') if len(dirs) >= 4 and dirs[1] == 'users': # Assume template "/users///clipper/" annee = dirs[2] @@ -61,12 +65,13 @@ def _extract_infos_from_ldap(infos, data={}): # Mail pmail = infos.get('mailRoutingAddress', []) if pmail: - data['email'] = pmail[0] + data['email'] = pmail[0].decode("utf-8") return data + def get_ldap_infos(clipper): assert clipper.isalnum() - data = {'email':'{}@clipper.ens.fr'.format(clipper.strip().lower())} + data = {} try: l = _init_ldap() @@ -75,11 +80,11 @@ def get_ldap_infos(clipper): ('(uid=%s)' % (clipper,)), ['cn', 'mailRoutingAddress', - 'homeDirectory' ]) + 'homeDirectory']) if len(info) > 0: data = _extract_infos_from_ldap(info[0][1], data) - + except ldap.LDAPError: pass @@ -88,21 +93,22 @@ def get_ldap_infos(clipper): class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ - A class to manage the fact that people loose their account at the end of + A class to manage the fact that people loose their account at the end of their scolarity and that their clipper login might be reused later """ def pre_social_login(self, request, sociallogin): if sociallogin.account.provider != "clipper": - return super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) - + return super(LongTermClipperAccountAdapter, + self).pre_social_login(request, sociallogin) + clipper = sociallogin.account.uid try: a = SocialAccount.objects.get(provider='clipper_inactive', uid=clipper) except SocialAccount.DoesNotExist: return - + # An account with that uid was registered, but potentially # deprecated at the beginning of the year # We need to check that the user is still the same as before @@ -114,7 +120,8 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): # We need a new SocialAccount # But before that, we need to invalidate the email address of # the previous user - email = ldap_data.get('email') + email = ldap_data.get('email', '{}@clipper.ens.fr'.format( + clipper.strip().lower())) u_mails = EmailAddress.objects.filter(user=a.user) try: clipper_mail = u_mails.get(email=email) @@ -137,34 +144,38 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): # Redo the thing that had failed just before sociallogin.lookup() - def get_username(self, clipper, data): """ Util function to generate a unique username, by default 'clipper@promo' This is used to disambiguate and recognize if the person is the same """ + if data is None or 'annee' not in data: + raise ValueError("No entrance year in LDAP data") return "{}@{}".format(clipper, data.get('annee', '00')) - + def save_user(self, request, sociallogin, form=None): if sociallogin.account.provider != "clipper": - return Super(LongTermClipperAccountAdapter, self).save_user(request, sociallogin, form) + return super(LongTermClipperAccountAdapter, + self).save_user(request, sociallogin, form) user = sociallogin.user user.set_unusable_password() - + clipper = sociallogin.account.uid - ldap_data = sociallogin._ldap_data if hasattr(sociallogin, '_ldap_data') \ - else get_ldap_infos(clipper) - + ldap_data = sociallogin._ldap_data if hasattr(sociallogin, + '_ldap_data') \ + else get_ldap_infos(clipper) + username = self.get_username(clipper, ldap_data) - email = ldap_data.get('email') + email = ldap_data.get('email', '{}@clipper.ens.fr'.format( + clipper.strip().lower())) name = ldap_data.get('name') user_username(user, username or '') user_email(user, email or '') name_parts = (name or '').split(' ') user_field(user, 'first_name', name_parts[0]) user_field(user, 'last_name', ' '.join(name_parts[1:])) - + # Ignore form get_account_adapter().populate_username(request, user) @@ -172,46 +183,51 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): sociallogin.account.extra_data = sociallogin.extra_data = ldap_data sociallogin.save(request) sociallogin.account.save() - + return user + def deprecate_clippers(): """ - Marks all the SocialAccount with clipper as deprecated, by setting their + Marks all the SocialAccount with clipper as deprecated, by setting their provider to 'clipper_inactive' """ - + clippers = SocialAccount.objects.filter(provider='clipper') c_uids = clippers.values_list('uid', flat=True) - - # Clear old clipper accounts that were replaced by new ones (to avoid conflicts) - SocialAccount.objects.filter(provider='clipper_inactive', uid__in=c_uids).delete() - + + # Clear old clipper accounts that were replaced by new ones + # (to avoid conflicts) + SocialAccount.objects.filter(provider='clipper_inactive', + uid__in=c_uids).delete() + # Deprecate accounts clippers.update(provider='clipper_inactive') + def install_longterm_adapter(fake=False): """ - Manages the transition from an older django_cas or an allauth_ens installation - without LongTermClipperAccountAdapter + Manages the transition from an older django_cas or an allauth_ens + installation without LongTermClipperAccountAdapter """ - - accounts = {u.username: u for u in User.objects.all() if u.username.isalnum()} + + accounts = {u.username: u for u in User.objects.all() + if u.username.isalnum()} l = _init_ldap() ltc_adapter = get_adapter() - + info = l.search_s('dc=spi,dc=ens,dc=fr', ldap.SCOPE_SUBTREE, ("(|{})".format(''.join(("(uid=%s)" % (un,)) for un in accounts.keys()))), - [str("uid"), - str("cn"), - str("mailRoutingAddress"), - str("homeDirectory") ]) + ['uid', + 'cn', + 'mailRoutingAddress', + 'homeDirectory']) logs = {"created": [], "updated": []} cases = [] - + for userinfo in info: infos = userinfo[1] data = _extract_infos_from_ldap(infos) @@ -225,13 +241,16 @@ def install_longterm_adapter(fake=False): else: user.save() cases.append(user.username) - if SocialAccount.objects.filter(provider='clipper', uid=clipper).exists(): + if SocialAccount.objects.filter(provider='clipper', + uid=clipper).exists(): logs["updated"].append((clipper, user.username)) continue - sa = SocialAccount(user=user, provider='clipper', uid=clipper, extra_data=data) + sa = SocialAccount(user=user, provider='clipper', + uid=clipper, extra_data=data) if not fake: sa.save() logs["created"].append((clipper, user.username)) - - logs["unmodified"] = User.objects.exclude(username__in=cases).values_list("username", flat=True) + + logs["unmodified"] = User.objects.exclude(username__in=cases)\ + .values_list("username", flat=True) return logs diff --git a/allauth_ens/management/commands/deprecate_clippers.py b/allauth_ens/management/commands/deprecate_clippers.py index a824fd8..257d707 100644 --- a/allauth_ens/management/commands/deprecate_clippers.py +++ b/allauth_ens/management/commands/deprecate_clippers.py @@ -1,8 +1,9 @@ -#coding: utf-8 -from django.core.management.base import BaseCommand, CommandError +# coding: utf-8 +from django.core.management.base import BaseCommand from allauth_ens.adapter import deprecate_clippers + class Command(BaseCommand): help = 'Deprecates clipper SocialAccounts so as to avoid conflicts' @@ -11,4 +12,5 @@ class Command(BaseCommand): def handle(self, *args, **options): deprecate_clippers() - self.stdout.write(self.style.SUCCESS(u'Clippers deprecation successful')) + self.stdout.write(self.style.SUCCESS( + 'Clippers deprecation successful')) diff --git a/allauth_ens/management/commands/install_longterm.py b/allauth_ens/management/commands/install_longterm.py index f733010..40ccde3 100644 --- a/allauth_ens/management/commands/install_longterm.py +++ b/allauth_ens/management/commands/install_longterm.py @@ -1,27 +1,35 @@ -#coding: utf-8 -from django.core.management.base import BaseCommand, CommandError +# coding: utf-8 +from django.core.management.base import BaseCommand from allauth_ens.adapter import install_longterm_adapter + class Command(BaseCommand): - help = 'Manages the transition from an older django_cas or an allauth_ens installation without LongTermClipperAccountAdapter' + help = 'Manages the transition from an older django_cas' \ + 'or an allauth_ens installation without ' \ + 'LongTermClipperAccountAdapter' def add_arguments(self, parser): parser.add_argument( '--fake', action='store_true', default=False, - help='Does not save the models created/updated, only shows the list', + help=('Does not save the models created/updated,' + 'only shows the list'), ) pass def handle(self, *args, **options): logs = install_longterm_adapter(options.get("fake", False)) - self.stdout.write("Social accounts created : %d" % len(logs["created"])) - self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["created"])) - self.stdout.write("Social accounts displaced : %d" % len(logs["updated"])) - self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["updated"])) - self.stdout.write("User accounts unmodified : %d" % len(logs["unmodified"])) + self.stdout.write("Social accounts created : %d" + % len(logs["created"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["created"])) + self.stdout.write("Social accounts displaced : %d" + % len(logs["updated"])) + self.stdout.write(" ".join(("%s -> %s" % s) for s in logs["updated"])) + self.stdout.write("User accounts unmodified : %d" + % len(logs["unmodified"])) self.stdout.write(" ".join(logs["unmodified"])) - self.stdout.write(self.style.SUCCESS(u'LongTermClipper migration successful')) + self.stdout.write(self.style.SUCCESS( + "LongTermClipper migration successful")) diff --git a/allauth_ens/providers/clipper/provider.py b/allauth_ens/providers/clipper/provider.py index fa057b2..41bb56b 100644 --- a/allauth_ens/providers/clipper/provider.py +++ b/allauth_ens/providers/clipper/provider.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- -import ldap - from allauth.account.models import EmailAddress from allauth.socialaccount.providers.base import ProviderAccount -from allauth_cas.providers import CASProvider -from django.conf import settings +from allauth_cas.providers import CASProvider class ClipperAccount(ProviderAccount): diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index 5272614..eb276c6 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -6,15 +6,17 @@ from django.contrib.sites.models import Site from django.core import mail from django.test import TestCase, override_settings -from mock import patch -from fakeldap import MockLDAP - -from allauth_cas.test.testcases import CASTestCase, CASViewTestCase -from .adapter import deprecate_clippers, install_longterm_adapter from allauth.socialaccount.models import SocialAccount +from allauth_cas.test.testcases import CASTestCase +from fakeldap import MockLDAP +from mock import patch + +from .adapter import deprecate_clippers, install_longterm_adapter + _mock_ldap = MockLDAP() -ldap_patcher = patch('allauth_ens.adapter.ldap.initialize', lambda x: _mock_ldap) +ldap_patcher = patch('allauth_ens.adapter.ldap.initialize', + lambda x: _mock_ldap) if django.VERSION >= (1, 10): from django.urls import reverse @@ -37,6 +39,7 @@ def prevent_logout_pwd_change(client, user): session[HASH_SESSION_KEY] = user.get_session_auth_hash() session.save() + class ViewsTests(TestCase): """ Checks (barely) that templates do not contain errors. @@ -145,70 +148,77 @@ class ViewsTests(TestCase): r = self.client.get(reverse('account_reset_password_from_key_done')) self.assertEqual(r.status_code, 200) -@override_settings(SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter') -class LongTermClipperTests(CASTestCase): +@override_settings( + SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter' +) +class LongTermClipperTests(CASTestCase): def setUp(self): ldap_patcher.start() def tearDown(self): + ldap_patcher.stop() _mock_ldap.reset() def _setup_ldap(self, promo=12, username="test"): - import ldap - _mock_ldap.directory['dc=spi,dc=ens,dc=fr']={ + _mock_ldap.directory['dc=spi,dc=ens,dc=fr'] = { 'uid': [username], - 'cn': ['John Smith'], - 'mailRoutingAddress' : ['test@clipper.ens.fr'], - 'homeDirectory': ["/users/%d/phy/test/" % promo], + 'cn': [b'John Smith'], + 'mailRoutingAddress': [b'test@clipper.ens.fr'], + 'homeDirectory': [b'/users/%d/phy/test/' % promo], } def _count_ldap_queries(self): queries = _mock_ldap.ldap_methods_called() count = len([l for l in queries if l != 'set_option']) return count - + def test_new_connexion(self): self._setup_ldap() - - r = self.client_cas_login(self.client, provider_id="clipper", username="test") + + r = self.client_cas_login(self.client, provider_id="clipper", + username="test") u = r.context['user'] - + self.assertEqual(u.username, "test@12") self.assertEqual(u.first_name, "John") self.assertEqual(u.last_name, "Smith") self.assertEqual(u.email, "test@clipper.ens.fr") self.assertEqual(self._count_ldap_queries(), 1) - + sa = list(SocialAccount.objects.all())[-1] self.assertEqual(sa.user.id, u.id) def test_connect_disconnect(self): self._setup_ldap() - r0 = self.client_cas_login(self.client, provider_id="clipper", username="test") + r0 = self.client_cas_login(self.client, provider_id="clipper", + username="test") self.assertIn("_auth_user_id", self.client.session) self.assertIn('user', r0.context) - r1 = self.client.logout() + self.client.logout() self.assertNotIn("_auth_user_id", self.client.session) - + def test_second_connexion(self): self._setup_ldap() - self.client_cas_login(self.client, provider_id="clipper", username="test") + self.client_cas_login(self.client, provider_id="clipper", + username="test") self.client.logout() nu = User.objects.count() - - self.client_cas_login(self.client, provider_id="clipper", username="test") + + self.client_cas_login(self.client, provider_id="clipper", + username="test") self.assertEqual(User.objects.count(), nu) self.assertEqual(self._count_ldap_queries(), 1) def test_deprecation(self): self._setup_ldap() - self.client_cas_login(self.client, provider_id="clipper", username="test") + self.client_cas_login(self.client, provider_id="clipper", + username="test") deprecate_clippers() - + sa = SocialAccount.objects.all()[0] self.assertEqual(sa.provider, "clipper_inactive") @@ -220,9 +230,9 @@ class LongTermClipperTests(CASTestCase): n_sa0 = SocialAccount.objects.count() n_u0 = User.objects.count() self.client.logout() - + deprecate_clippers() - + r = self.client_cas_login(self.client, provider_id="clipper", username="test") user1 = r.context['user'] @@ -241,18 +251,18 @@ class LongTermClipperTests(CASTestCase): n_sa0 = SocialAccount.objects.count() n_u0 = User.objects.count() self.client.logout() - + deprecate_clippers() self._setup_ldap(13) r = self.client_cas_login(self.client, provider_id="clipper", username="test") - + user1 = r.context['user'] sa1 = list(SocialAccount.objects.all()) n_u1 = User.objects.count() - self.assertEqual(len(sa1), n_sa0+1) - self.assertEqual(n_u1, n_u0+1) + self.assertEqual(len(sa1), n_sa0 + 1) + self.assertEqual(n_u1, n_u0 + 1) self.assertNotEqual(user1.id, user0.id) def test_multiple_deprecation(self): @@ -260,18 +270,18 @@ class LongTermClipperTests(CASTestCase): r = self.client_cas_login(self.client, provider_id="clipper", username="test") self.client.logout() - + self._setup_ldap(15, "truc") r = self.client_cas_login(self.client, provider_id="clipper", username="truc") self.client.logout() sa0 = SocialAccount.objects.count() - + deprecate_clippers() self._setup_ldap(13) - r = self.client_cas_login(self.client, provider_id="clipper", - username="test") + self.client_cas_login(self.client, provider_id="clipper", + username="test") self.client.logout() sa1 = SocialAccount.objects.count() @@ -282,12 +292,12 @@ class LongTermClipperTests(CASTestCase): # Older "test" inactive SocialAccount gets erased by new one # while "truc" remains self.assertEqual(sa0, sa2) - self.assertEqual(sa1, sa0+1) + self.assertEqual(sa1, sa0 + 1) def test_longterm_installer_from_allauth(self): self._setup_ldap(12) - with self.settings(SOCIALACCOUNT_ADAPTER=\ - 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + with self.settings(SOCIALACCOUNT_ADAPTER= + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): r = self.client_cas_login(self.client, provider_id="clipper", username='test') user0 = r.context["user"] @@ -307,11 +317,12 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(user1.username, "test@12") def test_longterm_installer_from_djangocas(self): - with self.settings(SOCIALACCOUNT_ADAPTER=\ - 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): - user0 = User.objects.create_user('test', 'test@clipper.ens.fr', 'test') + with self.settings(SOCIALACCOUNT_ADAPTER= + 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + user0 = User.objects.create_user('test', 'test@clipper.ens.fr', + 'test') nsa0 = SocialAccount.objects.count() - + self._setup_ldap(12) l = install_longterm_adapter() @@ -322,5 +333,21 @@ class LongTermClipperTests(CASTestCase): user1 = r.context["user"] nsa1 = SocialAccount.objects.count() self.assertEqual(user1.id, user0.id) - self.assertEqual(nsa1, nsa0+1) + self.assertEqual(nsa1, nsa0 + 1) self.assertEqual(user1.username, "test@12") + + def test_disconnect_ldap(self): + nu0 = User.objects.count() + nsa0 = SocialAccount.objects.count() + + ldap_patcher.stop() + with self.settings(LDAP_SERVER=''): + self.assertRaises(ValueError, self.client_cas_login, + self.client, provider_id="clipper", + username="test") + + nu1 = User.objects.count() + nsa1 = SocialAccount.objects.count() + self.assertEqual(nu0, nu1) + self.assertEqual(nsa0, nsa1) + ldap_patcher.start() diff --git a/example/adapter.py b/example/adapter.py index f1079f5..b28cfdd 100644 --- a/example/adapter.py +++ b/example/adapter.py @@ -8,4 +8,10 @@ class AccountAdapter(DefaultAccountAdapter): class SocialAccountAdapter(LongTermClipperAccountAdapter): - pass + + def get_username(self, clipper, data): + """ + Exception-free version of get_username, so that it works even outside of + the ENS (if no access to LDAP server) + """ + return "{}@{}".format(clipper, data.get('annee', '00')) diff --git a/tox.ini b/tox.ini index ec77d8d..513c573 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,8 @@ deps = django111: django>=1.11,<2.0 django20: django>=2.0,<2.1 coverage - mock ; python_version < "3.0" + fakeldap + mock usedevelop= True commands = python -V From 17fef409a8aed1ffd8e479779b022281d665943d Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 3 Jun 2018 22:10:34 +0200 Subject: [PATCH 14/21] More readable and organized code Working from Aurelien's code reviews --- README.rst | 42 ++++++++--- allauth_ens/adapter.py | 168 +++++++++++------------------------------ allauth_ens/tests.py | 4 +- allauth_ens/utils.py | 121 +++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 135 deletions(-) create mode 100644 allauth_ens/utils.py diff --git a/README.rst b/README.rst index 5b30afa..d69d2b7 100644 --- a/README.rst +++ b/README.rst @@ -148,14 +148,11 @@ Configuration SOCIALACCOUNT_PROVIDERS = { # … - 'clipper': { - # These settings control whether a message containing a link to # disconnect from the CAS server is added when users log out. 'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT': True, 'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT_LEVEL': messages.INFO, - }, } @@ -164,33 +161,56 @@ Auto-signup - username: ```` - email (primary and verified): ``@clipper.ens.fr`` +******** +Adapters +******** Long Term Clipper Adapter ========================= -We provide an easy-to-use SocialAccountAdapter to handle the fact that Clipper Accounts are not eternal, and that there is no guarantee that the clipper usernames won't be reused later. +We provide an easy-to-use SocialAccountAdapter to handle the fact that Clipper +accounts are not eternal, and that there is no guarantee that the clipper +usernames won't be reused later. -This adapter also handles getting basic information about the user from SPI's LDAP. +This adapter also handles getting basic information about the user from SPI's +LDAP. Configuration - Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` in `settings.py` + Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` + in `settings.py` Auto-signup Populated data - username: ``@`` - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` - first_name, last_name from LDAP's *cn* field - - extra_data in SociallAccount instance, containing these field, plus *annee* and *promotion* parsed from LDAP's *homeDirectory* field (available only on first connection) + - extra_data in SocialAccount instance, containing these field, plus *annee* + and *promotion* parsed from LDAP's *homeDirectory* field (available only on + first connection) Account deprecation - At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. Every association clipper username <-> user will then be put on hold, and at the first subsequent connection, a verification of the account will be made (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. + At the beginning of each year (i.e. early November), to prevent clipper + username conflicts, you should run ``$ python manage.py deprecate_clippers``. + Every association clipper username <-> user will then be put on hold, and at + the first subsequent connection, a verification of the account will be made + (using LDAP), so that a known user keeps his account, but a newcomer won't + inherit an archicube's. Customize - You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. This function is used to disambiguate in the account deprecation process. - By default, ``get_username`` raises a ``ValueError`` when the connexion to the LDAP failed or did not allow to retrieve the user's entrance year. Overriding ``get_username`` (as done in the example website) allows to get rid of that behaviour, and for instance attribute a default entrance year. + You can customize the SocialAccountAdapter by inheriting + ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to + modify ``get_username(clipper, data)`` to change the default username format. + This function is used to disambiguate in the account deprecation process. + By default, ``get_username`` raises a ``ValueError`` when the connexion to the + LDAP failed or did not allow to retrieve the user's entrance year. Overriding + ``get_username`` (as done in the example website) allows to get rid of that + behaviour, and for instance attribute a default entrance year. Initial migration - If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, and (in the second case) to create the SocialAccount instances to link CAS and Users. This can be done easily with ``$ python manage.py install_longterm``. + If you used allauth without LongTermClipperAccountAdapter, or another CAS + interface to log in, you need to update the Users to the new username policy, + and (in the second case) to create the SocialAccount instances to link CAS and + Users. This can be done easily with ``$ python manage.py install_longterm``. ********* Demo Site diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index dadbd2a..50c5d33 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- -from django.conf import settings from django.contrib.auth import get_user_model -from allauth.account.models import EmailAddress from allauth.account.utils import user_email, user_field, user_username from allauth.socialaccount.adapter import ( DefaultSocialAccountAdapter, get_account_adapter, get_adapter, @@ -12,85 +10,10 @@ from allauth.socialaccount.models import SocialAccount import ldap +from .utils import extract_infos_from_ldap, get_ldap_infos, get_clipper_email, remove_email, init_ldap + User = get_user_model() -DEPARTMENTS_LIST = { - 'phy': u'Physique', - 'maths': u'Maths', - 'bio': u'Biologie', - 'chimie': u'Chimie', - 'geol': u'Géosciences', - 'dec': u'DEC', - 'info': u'Informatique', - 'litt': u'Littéraire', - 'guests': u'Pensionnaires étrangers', - 'pei': u'PEI', -} - - -def _init_ldap(): - server = getattr(settings, "LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, - ldap.OPT_X_TLS_NEVER) - l = ldap.initialize(server) - l.set_option(ldap.OPT_REFERRALS, 0) - l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) - l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) - l.set_option(ldap.OPT_X_TLS_DEMAND, True) - l.set_option(ldap.OPT_DEBUG_LEVEL, 255) - l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) - l.set_option(ldap.OPT_TIMEOUT, 10) - return l - - -def _extract_infos_from_ldap(infos, data={}): - # Name - data['name'] = infos.get('cn', [b''])[0].decode("utf-8") - - # Parsing homeDirectory to get entrance year and departments - annee = '00' - promotion = 'Inconnue' - - if 'homeDirectory' in infos: - dirs = infos['homeDirectory'][0].decode("utf-8").split('/') - if len(dirs) >= 4 and dirs[1] == 'users': - # Assume template "/users///clipper/" - annee = dirs[2] - dep = dirs[3] - dep = DEPARTMENTS_LIST.get(dep.lower(), '') - promotion = u'%s %s' % (dep, annee) - data['annee'] = annee - data['promotion'] = promotion - - # Mail - pmail = infos.get('mailRoutingAddress', []) - if pmail: - data['email'] = pmail[0].decode("utf-8") - return data - - -def get_ldap_infos(clipper): - assert clipper.isalnum() - data = {} - try: - l = _init_ldap() - - info = l.search_s('dc=spi,dc=ens,dc=fr', - ldap.SCOPE_SUBTREE, - ('(uid=%s)' % (clipper,)), - ['cn', - 'mailRoutingAddress', - 'homeDirectory']) - - if len(info) > 0: - data = _extract_infos_from_ldap(info[0][1], data) - - except ldap.LDAPError: - pass - - return data - - class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ A class to manage the fact that people loose their account at the end of @@ -98,61 +21,63 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ def pre_social_login(self, request, sociallogin): + """ + If a clipper connection has already existed with the uid, it checks + that this connection still belongs to the user it was associated with. + + This check is performed by comparing the generated username corresponding + to this connection with the old one. + + If the check succeeds, it simply reactivates the clipper connection as + belonging to the associated user. + + If the check fails, it frees the elements (as the clipper email + address) which will be assigned to the new connection later. + """ + if sociallogin.account.provider != "clipper": return super(LongTermClipperAccountAdapter, self).pre_social_login(request, sociallogin) - clipper = sociallogin.account.uid + clipper_uid = sociallogin.account.uid try: - a = SocialAccount.objects.get(provider='clipper_inactive', - uid=clipper) + old_conn = SocialAccount.objects.get(provider='clipper_inactive', + uid=clipper_uid) except SocialAccount.DoesNotExist: return # An account with that uid was registered, but potentially # deprecated at the beginning of the year # We need to check that the user is still the same as before - ldap_data = get_ldap_infos(clipper) + ldap_data = get_ldap_infos(clipper_uid) sociallogin._ldap_data = ldap_data - if a.user.username != self.get_username(clipper, ldap_data): + if old_conn.user.username != self.get_username(clipper_uid, ldap_data): # The admission year is different - # We need a new SocialAccount - # But before that, we need to invalidate the email address of - # the previous user - email = ldap_data.get('email', '{}@clipper.ens.fr'.format( - clipper.strip().lower())) - u_mails = EmailAddress.objects.filter(user=a.user) - try: - clipper_mail = u_mails.get(email=email) - if clipper_mail.primary: - n_mails = u_mails.filter(primary=False) - if n_mails.exists(): - n_mails[0].set_as_primary() - else: - user_email(a.user, '') - a.user.save() - clipper_mail.delete() - except EmailAddress.DoesNotExist: - pass + # We cannot reuse this SocialAccount, so we need to invalidate + # the email address of the previous user to prevent conflicts + # if a new SocialAccount is created + email = ldap_data.get('email', get_clipper_email(clipper_uid)) + remove_email(old_conn.user, email) + return # The admission year is the same, we can update the model and keep # the previous SocialAccount instance - a.provider = 'clipper' - a.save() + old_conn.provider = 'clipper' + old_conn.save() # Redo the thing that had failed just before sociallogin.lookup() - def get_username(self, clipper, data): + def get_username(self, clipper_uid, data): """ Util function to generate a unique username, by default 'clipper@promo' This is used to disambiguate and recognize if the person is the same """ - if data is None or 'annee' not in data: + if data is None or 'entrance_year' not in data: raise ValueError("No entrance year in LDAP data") - return "{}@{}".format(clipper, data.get('annee', '00')) + return "{}@{}".format(clipper_uid, data['entrance_year']) def save_user(self, request, sociallogin, form=None): if sociallogin.account.provider != "clipper": @@ -161,14 +86,13 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): user = sociallogin.user user.set_unusable_password() - clipper = sociallogin.account.uid + clipper_uid = sociallogin.account.uid ldap_data = sociallogin._ldap_data if hasattr(sociallogin, '_ldap_data') \ - else get_ldap_infos(clipper) + else get_ldap_infos(clipper_uid) - username = self.get_username(clipper, ldap_data) - email = ldap_data.get('email', '{}@clipper.ens.fr'.format( - clipper.strip().lower())) + username = self.get_username(clipper_uid, ldap_data) + email = ldap_data.get('email', get_clipper_email(clipper_uid)) name = ldap_data.get('name') user_username(user, username or '') user_email(user, email or '') @@ -213,7 +137,7 @@ def install_longterm_adapter(fake=False): accounts = {u.username: u for u in User.objects.all() if u.username.isalnum()} - l = _init_ldap() + l = init_ldap() ltc_adapter = get_adapter() info = l.search_s('dc=spi,dc=ens,dc=fr', @@ -230,26 +154,26 @@ def install_longterm_adapter(fake=False): for userinfo in info: infos = userinfo[1] - data = _extract_infos_from_ldap(infos) - clipper = infos["uid"][0] - user = accounts.get(clipper, None) + data = extract_infos_from_ldap(infos) + clipper_uid = data['clipper_uid'] + user = accounts.get(clipper_uid, None) if user is None: continue - user.username = ltc_adapter.get_username(clipper, data) + user.username = ltc_adapter.get_username(clipper_uid, data) if fake: - cases.append(clipper) + cases.append(clipper_uid) else: user.save() cases.append(user.username) if SocialAccount.objects.filter(provider='clipper', - uid=clipper).exists(): - logs["updated"].append((clipper, user.username)) + uid=clipper_uid).exists(): + logs["updated"].append((clipper_uid, user.username)) continue sa = SocialAccount(user=user, provider='clipper', - uid=clipper, extra_data=data) + uid=clipper_uid, extra_data=data) if not fake: sa.save() - logs["created"].append((clipper, user.username)) + logs["created"].append((clipper_uid, user.username)) logs["unmodified"] = User.objects.exclude(username__in=cases)\ .values_list("username", flat=True) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index eb276c6..d9c01e5 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -15,7 +15,7 @@ from mock import patch from .adapter import deprecate_clippers, install_longterm_adapter _mock_ldap = MockLDAP() -ldap_patcher = patch('allauth_ens.adapter.ldap.initialize', +ldap_patcher = patch('allauth_ens.utils.ldap.initialize', lambda x: _mock_ldap) if django.VERSION >= (1, 10): @@ -341,7 +341,7 @@ class LongTermClipperTests(CASTestCase): nsa0 = SocialAccount.objects.count() ldap_patcher.stop() - with self.settings(LDAP_SERVER=''): + with self.settings(CLIPPER_LDAP_SERVER=''): self.assertRaises(ValueError, self.client_cas_login, self.client, provider_id="clipper", username="test") diff --git a/allauth_ens/utils.py b/allauth_ens/utils.py new file mode 100644 index 0000000..57df7b1 --- /dev/null +++ b/allauth_ens/utils.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings + +from allauth.account.models import EmailAddress +from allauth.account.utils import user_email + +import ldap + +DEPARTMENTS_LIST = { + 'phy': u'Physique', + 'maths': u'Maths', + 'bio': u'Biologie', + 'chimie': u'Chimie', + 'geol': u'Géosciences', + 'dec': u'DEC', + 'info': u'Informatique', + 'litt': u'Littéraire', + 'guests': u'Pensionnaires étrangers', + 'pei': u'PEI', +} + +def init_ldap(): + server = getattr(settings, "CLIPPER_LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, + ldap.OPT_X_TLS_NEVER) + l = ldap.initialize(server) + l.set_option(ldap.OPT_REFERRALS, 0) + l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + l.set_option(ldap.OPT_X_TLS_DEMAND, True) + l.set_option(ldap.OPT_DEBUG_LEVEL, 255) + l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) + l.set_option(ldap.OPT_TIMEOUT, 10) + return l + +def extract_infos_from_ldap(infos): + data = {} + + # Name + if 'cn' in infos: + data['name'] = infos['cn'][0].decode("utf-8") + + # Parsing homeDirectory to get entrance year and departments + if 'homeDirectory' in infos: + dirs = infos['homeDirectory'][0].decode("utf-8").split('/') + if len(dirs) >= 4 and dirs[1] == 'users': + # Assume template "/users///clipper/" + annee = dirs[2] + dep = dirs[3] + dep_fancy = DEPARTMENTS_LIST.get(dep.lower(), '') + promotion = u'%s %s' % (dep, annee) + data['entrance_year'] = annee + data['department_code'] = dep + data['department'] = dep_fancy + + # Mail + pmail = infos.get('mailRoutingAddress', []) + if pmail: + data['email'] = pmail[0].decode("utf-8") + + # User id + if 'uid' in infos: + data['clipper_uid'] = infos['uid'][0].decode("utf-8").strip().lower() + + return data + +def get_ldap_infos(clipper_uid): + assert clipper_uid.isalnum() + data = {} + try: + l = init_ldap() + + info = l.search_s('dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ('(uid=%s)' % (clipper_uid,)), + ['cn', + 'mailRoutingAddress', + 'homeDirectory']) + + if len(info) > 0: + data = extract_infos_from_ldap(info[0][1]) + + except ldap.LDAPError: + pass + + return data + +def get_clipper_email(clipper): + return '{}@clipper.ens.fr'.format(clipper.strip().lower()) + +def remove_email(user, email): + """ + Removes an email address of a user. + + If it is his primary email address, it sets another email address as + primary, preferably verified. + """ + u_mailaddrs = user.emailaddress_set.filter(user=user) + try: + mailaddr = user.emailaddress_set.get(email=email) + except EmailAddress.DoesNotExist: + return + + if mailaddr.primary: + others = u_mailaddrs.filter(primary=False) + + # Prefer a verified mail. + new_primary = ( + others.filter(verified=True).last() or + others.last() + ) + + if new_primary: + # It also updates 'user.EMAIL_FIELD'. + new_primary.set_as_primary() + else: + user_email(user, '') + user.save() + + mailaddr.delete() From 5ee1c774ac5975780f446d26f019da31aab84abb Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 3 Jun 2018 22:15:16 +0200 Subject: [PATCH 15/21] Better README display? --- README.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index d69d2b7..11c4195 100644 --- a/README.rst +++ b/README.rst @@ -179,15 +179,17 @@ Configuration Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` in `settings.py` + Auto-signup Populated data - username: ``@`` - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` - first_name, last_name from LDAP's *cn* field - extra_data in SocialAccount instance, containing these field, plus *annee* - and *promotion* parsed from LDAP's *homeDirectory* field (available only on - first connection) - + and *promotion* parsed from LDAP's *homeDirectory* field (available only on + first connection) + + Account deprecation At the beginning of each year (i.e. early November), to prevent clipper username conflicts, you should run ``$ python manage.py deprecate_clippers``. @@ -196,6 +198,7 @@ Account deprecation (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. + Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to @@ -206,6 +209,7 @@ Customize ``get_username`` (as done in the example website) allows to get rid of that behaviour, and for instance attribute a default entrance year. + Initial migration If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, From 4cf633ed8167dd46d43d661693a2e31bfebca4bb Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 3 Jun 2018 22:17:14 +0200 Subject: [PATCH 16/21] Actually no, too bad --- README.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 11c4195..4a06279 100644 --- a/README.rst +++ b/README.rst @@ -179,16 +179,14 @@ Configuration Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` in `settings.py` - Auto-signup Populated data - username: ``@`` - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` - first_name, last_name from LDAP's *cn* field - extra_data in SocialAccount instance, containing these field, plus *annee* - and *promotion* parsed from LDAP's *homeDirectory* field (available only on - first connection) - +and *promotion* parsed from LDAP's *homeDirectory* field (available only on +first connection) Account deprecation At the beginning of each year (i.e. early November), to prevent clipper @@ -198,7 +196,6 @@ Account deprecation (using LDAP), so that a known user keeps his account, but a newcomer won't inherit an archicube's. - Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to @@ -209,7 +206,6 @@ Customize ``get_username`` (as done in the example website) allows to get rid of that behaviour, and for instance attribute a default entrance year. - Initial migration If you used allauth without LongTermClipperAccountAdapter, or another CAS interface to log in, you need to update the Users to the new username policy, From 08a47150db9fd84c30f0002619213b55db354a26 Mon Sep 17 00:00:00 2001 From: Evarin Date: Sun, 3 Jun 2018 22:26:53 +0200 Subject: [PATCH 17/21] Populate user model with promotion infos As requested by Erkan et Martin --- README.rst | 11 +++++------ allauth_ens/adapter.py | 10 ++++++++++ allauth_ens/utils.py | 1 - 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 4a06279..28914db 100644 --- a/README.rst +++ b/README.rst @@ -181,12 +181,11 @@ Configuration Auto-signup Populated data - - username: ``@`` - - email: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` - - first_name, last_name from LDAP's *cn* field - - extra_data in SocialAccount instance, containing these field, plus *annee* -and *promotion* parsed from LDAP's *homeDirectory* field (available only on -first connection) + - *username*: ``@`` + - *email*: from LDAP's *mailRoutingAddress* field, or ``@clipper.ens.fr`` + - *first_name*, *last_name* from LDAP's *cn* field + - *entrance_year* (as 2-digit string), *department_code*, *department* and *promotion* (department+year) parsed from LDAP's *homeDirectory* field + - *extra_data* in SocialAccount instance, containing all these field except *promotion* (and available only on first connection) Account deprecation At the beginning of each year (i.e. early November), to prevent clipper diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index 50c5d33..e85d978 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -100,6 +100,16 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): user_field(user, 'first_name', name_parts[0]) user_field(user, 'last_name', ' '.join(name_parts[1:])) + # Entrance year and department, if the user has these fields + entrance_year = ldap_data.get('entrance_year', '') + dep_code = ldap_data.get('department_code', '') + dep_fancy = ldap_data.get('department', '') + promotion = u'%s %s' % (dep_fancy, entrance_year) + user_field(user, 'entrance_year', entrance_year) + user_field(user, 'department_code', dep_code) + user_field(user, 'department', dep_fancy) + user_field(user, 'promotion', promotion) + # Ignore form get_account_adapter().populate_username(request, user) diff --git a/allauth_ens/utils.py b/allauth_ens/utils.py index 57df7b1..230d55d 100644 --- a/allauth_ens/utils.py +++ b/allauth_ens/utils.py @@ -49,7 +49,6 @@ def extract_infos_from_ldap(infos): annee = dirs[2] dep = dirs[3] dep_fancy = DEPARTMENTS_LIST.get(dep.lower(), '') - promotion = u'%s %s' % (dep, annee) data['entrance_year'] = annee data['department_code'] = dep data['department'] = dep_fancy From 35a3bc3e2dd9112952459d8d238d2c08c0014712 Mon Sep 17 00:00:00 2001 From: Evarin Date: Tue, 19 Jun 2018 19:15:25 +0200 Subject: [PATCH 18/21] Small fix to example site --- example/adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/adapter.py b/example/adapter.py index b28cfdd..5ad5e10 100644 --- a/example/adapter.py +++ b/example/adapter.py @@ -14,4 +14,4 @@ class SocialAccountAdapter(LongTermClipperAccountAdapter): Exception-free version of get_username, so that it works even outside of the ENS (if no access to LDAP server) """ - return "{}@{}".format(clipper, data.get('annee', '00')) + return "{}@{}".format(clipper, data.get('entrance_year', '00')) From 534e0b188f8025d491142a5f069859f89e803c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 16 Jun 2018 17:02:32 +0200 Subject: [PATCH 19/21] LongTermClipper preserving LDAP data --- README.rst | 5 ++- allauth_ens/adapter.py | 45 ++++++++++++++--------- allauth_ens/providers/clipper/provider.py | 15 ++++++++ allauth_ens/providers/clipper/tests.py | 12 ++++++ allauth_ens/tests.py | 14 ++++++- setup.cfg | 1 - 6 files changed, 72 insertions(+), 20 deletions(-) diff --git a/README.rst b/README.rst index 28914db..4e953c3 100644 --- a/README.rst +++ b/README.rst @@ -175,6 +175,10 @@ usernames won't be reused later. This adapter also handles getting basic information about the user from SPI's LDAP. +**Important:** If you are building on top of *allauth*, take care to preserve +the ``extra_data['ldap']`` of ``SocialAccount`` instances related to *Clipper* +(where ``provider_id`` is ``clipper`` or ``clipper_inactive``). + Configuration Set ``SOCIALACCOUNT_ADAPTER='allauth_ens.adapter.LongTermClipperAccountAdapter'`` in `settings.py` @@ -199,7 +203,6 @@ Customize You can customize the SocialAccountAdapter by inheriting ``allauth_ens.adapter.LongTermClipperAccountAdapter``. You might want to modify ``get_username(clipper, data)`` to change the default username format. - This function is used to disambiguate in the account deprecation process. By default, ``get_username`` raises a ``ValueError`` when the connexion to the LDAP failed or did not allow to retrieve the user's entrance year. Overriding ``get_username`` (as done in the example website) allows to get rid of that diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index e85d978..b9c5d48 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -25,8 +25,8 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): If a clipper connection has already existed with the uid, it checks that this connection still belongs to the user it was associated with. - This check is performed by comparing the generated username corresponding - to this connection with the old one. + This check is performed by comparing the entrance years provided by the + LDAP. If the check succeeds, it simply reactivates the clipper connection as belonging to the associated user. @@ -52,14 +52,19 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): ldap_data = get_ldap_infos(clipper_uid) sociallogin._ldap_data = ldap_data - if old_conn.user.username != self.get_username(clipper_uid, ldap_data): - # The admission year is different + if ldap_data is None or 'entrance_year' not in ldap_data: + raise ValueError("No entrance year in LDAP data") + + old_conn_entrance_year = ( + old_conn.extra_data.get('ldap', {}).get('entrance_year')) + + if old_conn_entrance_year != ldap_data['entrance_year']: # We cannot reuse this SocialAccount, so we need to invalidate # the email address of the previous user to prevent conflicts # if a new SocialAccount is created email = ldap_data.get('email', get_clipper_email(clipper_uid)) remove_email(old_conn.user, email) - + return # The admission year is the same, we can update the model and keep @@ -72,8 +77,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): def get_username(self, clipper_uid, data): """ - Util function to generate a unique username, by default 'clipper@promo' - This is used to disambiguate and recognize if the person is the same + Util function to generate a unique username, by default 'clipper@promo'. """ if data is None or 'entrance_year' not in data: raise ValueError("No entrance year in LDAP data") @@ -114,7 +118,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): get_account_adapter().populate_username(request, user) # Save extra data (only once) - sociallogin.account.extra_data = sociallogin.extra_data = ldap_data + sociallogin.account.extra_data['ldap'] = ldap_data sociallogin.save(request) sociallogin.account.save() @@ -175,15 +179,22 @@ def install_longterm_adapter(fake=False): else: user.save() cases.append(user.username) - if SocialAccount.objects.filter(provider='clipper', - uid=clipper_uid).exists(): - logs["updated"].append((clipper_uid, user.username)) - continue - sa = SocialAccount(user=user, provider='clipper', - uid=clipper_uid, extra_data=data) - if not fake: - sa.save() - logs["created"].append((clipper_uid, user.username)) + + try: + sa = SocialAccount.objects.get(provider='clipper', uid=clipper_uid) + if not sa.extra_data.get('ldap'): + sa.extra_data['ldap'] = data + if not fake: + sa.save(update_fields=['extra_data']) + logs["updated"].append((clipper_uid, user.username)) + except SocialAccount.DoesNotExist: + sa = SocialAccount( + provider='clipper', uid=clipper_uid, + user=user, extra_data={'ldap': data}, + ) + if not fake: + sa.save() + logs["created"].append((clipper_uid, user.username)) logs["unmodified"] = User.objects.exclude(username__in=cases)\ .values_list("username", flat=True) diff --git a/allauth_ens/providers/clipper/provider.py b/allauth_ens/providers/clipper/provider.py index 41bb56b..4ee6169 100644 --- a/allauth_ens/providers/clipper/provider.py +++ b/allauth_ens/providers/clipper/provider.py @@ -37,8 +37,23 @@ class ClipperProvider(CASProvider): ] def extract_extra_data(self, data): + """ + If LongTermClipperAccountAdapter is in use, keep the data retrieved + from the LDAP server. + """ + from allauth.socialaccount.models import SocialAccount # noqa extra_data = super(ClipperProvider, self).extract_extra_data(data) extra_data['email'] = self.extract_email(data) + + # Preserve LDAP data at all cost. + try: + clipper_account = SocialAccount.objects.get( + provider=self.id, uid=self.extract_uid(data)) + if 'ldap' in clipper_account.extra_data: + extra_data['ldap'] = clipper_account.extra_data['ldap'] + except SocialAccount.DoesNotExist: + pass + return extra_data def message_suggest_caslogout_on_logout(self, request): diff --git a/allauth_ens/providers/clipper/tests.py b/allauth_ens/providers/clipper/tests.py index 657982c..00bb3b1 100644 --- a/allauth_ens/providers/clipper/tests.py +++ b/allauth_ens/providers/clipper/tests.py @@ -17,6 +17,18 @@ class ClipperProviderTests(CASTestCase): u = User.objects.get(username='clipper_uid') self.assertEqual(u.email, 'clipper_uid@clipper.ens.fr') + def test_extra_data_keeps_ldap_data(self): + clipper_conn = self.u.socialaccount_set.create( + uid='user', provider='clipper', + extra_data={'ldap': {'aa': 'bb'}}, + ) + + self.client_cas_login( + self.client, provider_id='clipper', username='user') + + clipper_conn.refresh_from_db() + self.assertEqual(clipper_conn.extra_data['ldap'], {'aa': 'bb'}) + class ClipperViewsTests(CASViewTestCase): diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index d9c01e5..471f479 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import re import django @@ -8,6 +10,7 @@ from django.test import TestCase, override_settings from allauth.socialaccount.models import SocialAccount +import six from allauth_cas.test.testcases import CASTestCase from fakeldap import MockLDAP from mock import patch @@ -161,8 +164,12 @@ class LongTermClipperTests(CASTestCase): _mock_ldap.reset() def _setup_ldap(self, promo=12, username="test"): + try: + buid = six.binary_type(username, 'utf-8') + except TypeError: + buid = six.binary_type(username) _mock_ldap.directory['dc=spi,dc=ens,dc=fr'] = { - 'uid': [username], + 'uid': [buid], 'cn': [b'John Smith'], 'mailRoutingAddress': [b'test@clipper.ens.fr'], 'homeDirectory': [b'/users/%d/phy/test/' % promo], @@ -188,6 +195,7 @@ class LongTermClipperTests(CASTestCase): sa = list(SocialAccount.objects.all())[-1] self.assertEqual(sa.user.id, u.id) + self.assertEqual(sa.extra_data['ldap']['entrance_year'], '12') def test_connect_disconnect(self): self._setup_ldap() @@ -312,9 +320,11 @@ class LongTermClipperTests(CASTestCase): username='test') user1 = r.context["user"] nsa1 = SocialAccount.objects.count() + conn = user1.socialaccount_set.get(provider='clipper') self.assertEqual(user1.id, user0.id) self.assertEqual(nsa1, nsa0) self.assertEqual(user1.username, "test@12") + self.assertEqual(conn.extra_data['ldap']['entrance_year'], '12') def test_longterm_installer_from_djangocas(self): with self.settings(SOCIALACCOUNT_ADAPTER= @@ -332,9 +342,11 @@ class LongTermClipperTests(CASTestCase): username='test') user1 = r.context["user"] nsa1 = SocialAccount.objects.count() + conn = user1.socialaccount_set.get(provider='clipper') self.assertEqual(user1.id, user0.id) self.assertEqual(nsa1, nsa0 + 1) self.assertEqual(user1.username, "test@12") + self.assertEqual(conn.extra_data['ldap']['entrance_year'], '12') def test_disconnect_ldap(self): nu0 = User.objects.count() diff --git a/setup.cfg b/setup.cfg index 179a83e..bfb5cf5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,6 @@ combine_as_imports = True default_section = THIRDPARTY include_trailing_comma = True known_allauth = allauth -known_future_library = future,six known_django = django known_first_party = allauth_ens multi_line_output = 5 From b189c1145402cb37e9d03ea964b83a4562225d7a Mon Sep 17 00:00:00 2001 From: Evarin Date: Sat, 29 Sep 2018 23:32:50 +0200 Subject: [PATCH 20/21] Detoxify + debug py34 --- allauth_ens/adapter.py | 37 ++++++++++++++++++--------------- allauth_ens/tests.py | 30 ++++++++++++++------------- allauth_ens/utils.py | 46 ++++++++++++++++++++++++------------------ 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/allauth_ens/adapter.py b/allauth_ens/adapter.py index e85d978..a283cc6 100644 --- a/allauth_ens/adapter.py +++ b/allauth_ens/adapter.py @@ -10,10 +10,14 @@ from allauth.socialaccount.models import SocialAccount import ldap -from .utils import extract_infos_from_ldap, get_ldap_infos, get_clipper_email, remove_email, init_ldap +from .utils import ( + extract_infos_from_ldap, get_clipper_email, get_ldap_infos, init_ldap, + remove_email, +) User = get_user_model() + class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): """ A class to manage the fact that people loose their account at the end of @@ -25,8 +29,8 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): If a clipper connection has already existed with the uid, it checks that this connection still belongs to the user it was associated with. - This check is performed by comparing the generated username corresponding - to this connection with the old one. + This check is performed by comparing the generated username + corresponding to this connection with the old one. If the check succeeds, it simply reactivates the clipper connection as belonging to the associated user. @@ -59,7 +63,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): # if a new SocialAccount is created email = ldap_data.get('email', get_clipper_email(clipper_uid)) remove_email(old_conn.user, email) - + return # The admission year is the same, we can update the model and keep @@ -87,9 +91,9 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter): user.set_unusable_password() clipper_uid = sociallogin.account.uid - ldap_data = sociallogin._ldap_data if hasattr(sociallogin, - '_ldap_data') \ - else get_ldap_infos(clipper_uid) + ldap_data = sociallogin._ldap_data if \ + hasattr(sociallogin, '_ldap_data') \ + else get_ldap_infos(clipper_uid) username = self.get_username(clipper_uid, ldap_data) email = ldap_data.get('email', get_clipper_email(clipper_uid)) @@ -147,17 +151,18 @@ def install_longterm_adapter(fake=False): accounts = {u.username: u for u in User.objects.all() if u.username.isalnum()} - l = init_ldap() + ldap_connection = init_ldap() ltc_adapter = get_adapter() - info = l.search_s('dc=spi,dc=ens,dc=fr', - ldap.SCOPE_SUBTREE, - ("(|{})".format(''.join(("(uid=%s)" % (un,)) - for un in accounts.keys()))), - ['uid', - 'cn', - 'mailRoutingAddress', - 'homeDirectory']) + info = ldap_connection.search_s( + 'dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ("(|{})".format(''.join(("(uid=%s)" % (un,)) + for un in accounts.keys()))), + ['uid', + 'cn', + 'mailRoutingAddress', + 'homeDirectory']) logs = {"created": [], "updated": []} cases = [] diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index d9c01e5..805e627 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -165,12 +165,12 @@ class LongTermClipperTests(CASTestCase): 'uid': [username], 'cn': [b'John Smith'], 'mailRoutingAddress': [b'test@clipper.ens.fr'], - 'homeDirectory': [b'/users/%d/phy/test/' % promo], + 'homeDirectory': [bytes('/users/%d/phy/test/' % promo)], } def _count_ldap_queries(self): queries = _mock_ldap.ldap_methods_called() - count = len([l for l in queries if l != 'set_option']) + count = len([op for op in queries if op != 'set_option']) return count def test_new_connexion(self): @@ -267,13 +267,13 @@ class LongTermClipperTests(CASTestCase): def test_multiple_deprecation(self): self._setup_ldap(12) - r = self.client_cas_login(self.client, provider_id="clipper", - username="test") + self.client_cas_login(self.client, provider_id="clipper", + username="test") self.client.logout() self._setup_ldap(15, "truc") - r = self.client_cas_login(self.client, provider_id="clipper", - username="truc") + self.client_cas_login(self.client, provider_id="clipper", + username="truc") self.client.logout() sa0 = SocialAccount.objects.count() @@ -296,8 +296,9 @@ class LongTermClipperTests(CASTestCase): def test_longterm_installer_from_allauth(self): self._setup_ldap(12) - with self.settings(SOCIALACCOUNT_ADAPTER= - 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + with self.settings( + SOCIALACCOUNT_ADAPTER='allauth.socialaccount.' + 'adapter.DefaultSocialAccountAdapter'): r = self.client_cas_login(self.client, provider_id="clipper", username='test') user0 = r.context["user"] @@ -305,9 +306,9 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(user0.username, "test") self.client.logout() - l = install_longterm_adapter() + outputs = install_longterm_adapter() - self.assertEqual(l["updated"], [("test", "test@12")]) + self.assertEqual(outputs["updated"], [("test", "test@12")]) r = self.client_cas_login(self.client, provider_id="clipper", username='test') user1 = r.context["user"] @@ -317,17 +318,18 @@ class LongTermClipperTests(CASTestCase): self.assertEqual(user1.username, "test@12") def test_longterm_installer_from_djangocas(self): - with self.settings(SOCIALACCOUNT_ADAPTER= - 'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'): + with self.settings( + SOCIALACCOUNT_ADAPTER='allauth.socialaccount.' + 'adapter.DefaultSocialAccountAdapter'): user0 = User.objects.create_user('test', 'test@clipper.ens.fr', 'test') nsa0 = SocialAccount.objects.count() self._setup_ldap(12) - l = install_longterm_adapter() + outputs = install_longterm_adapter() - self.assertEqual(l["created"], [("test", "test@12")]) + self.assertEqual(outputs["created"], [("test", "test@12")]) r = self.client_cas_login(self.client, provider_id="clipper", username='test') user1 = r.context["user"] diff --git a/allauth_ens/utils.py b/allauth_ens/utils.py index 230d55d..afd29a9 100644 --- a/allauth_ens/utils.py +++ b/allauth_ens/utils.py @@ -20,23 +20,26 @@ DEPARTMENTS_LIST = { 'pei': u'PEI', } + def init_ldap(): - server = getattr(settings, "CLIPPER_LDAP_SERVER", "ldaps://ldap.spi.ens.fr:636") + server = getattr(settings, "CLIPPER_LDAP_SERVER", + "ldaps://ldap.spi.ens.fr:636") ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) - l = ldap.initialize(server) - l.set_option(ldap.OPT_REFERRALS, 0) - l.set_option(ldap.OPT_PROTOCOL_VERSION, 3) - l.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) - l.set_option(ldap.OPT_X_TLS_DEMAND, True) - l.set_option(ldap.OPT_DEBUG_LEVEL, 255) - l.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) - l.set_option(ldap.OPT_TIMEOUT, 10) - return l + ldap_connection = ldap.initialize(server) + ldap_connection.set_option(ldap.OPT_REFERRALS, 0) + ldap_connection.set_option(ldap.OPT_PROTOCOL_VERSION, 3) + ldap_connection.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) + ldap_connection.set_option(ldap.OPT_X_TLS_DEMAND, True) + ldap_connection.set_option(ldap.OPT_DEBUG_LEVEL, 255) + ldap_connection.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) + ldap_connection.set_option(ldap.OPT_TIMEOUT, 10) + return ldap_connection + def extract_infos_from_ldap(infos): data = {} - + # Name if 'cn' in infos: data['name'] = infos['cn'][0].decode("utf-8") @@ -57,25 +60,26 @@ def extract_infos_from_ldap(infos): pmail = infos.get('mailRoutingAddress', []) if pmail: data['email'] = pmail[0].decode("utf-8") - + # User id if 'uid' in infos: data['clipper_uid'] = infos['uid'][0].decode("utf-8").strip().lower() - + return data + def get_ldap_infos(clipper_uid): assert clipper_uid.isalnum() data = {} try: - l = init_ldap() + ldap_connection = init_ldap() - info = l.search_s('dc=spi,dc=ens,dc=fr', - ldap.SCOPE_SUBTREE, - ('(uid=%s)' % (clipper_uid,)), - ['cn', - 'mailRoutingAddress', - 'homeDirectory']) + info = ldap_connection.search_s('dc=spi,dc=ens,dc=fr', + ldap.SCOPE_SUBTREE, + ('(uid=%s)' % (clipper_uid,)), + ['cn', + 'mailRoutingAddress', + 'homeDirectory']) if len(info) > 0: data = extract_infos_from_ldap(info[0][1]) @@ -85,9 +89,11 @@ def get_ldap_infos(clipper_uid): return data + def get_clipper_email(clipper): return '{}@clipper.ens.fr'.format(clipper.strip().lower()) + def remove_email(user, email): """ Removes an email address of a user. From a812a43a2cde3e243f4b9c578cc0b6873142fb71 Mon Sep 17 00:00:00 2001 From: Evarin Date: Sat, 29 Sep 2018 23:58:32 +0200 Subject: [PATCH 21/21] Compatibilite des tests en py2 --- allauth_ens/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/allauth_ens/tests.py b/allauth_ens/tests.py index fe3edc3..b902d81 100644 --- a/allauth_ens/tests.py +++ b/allauth_ens/tests.py @@ -166,13 +166,15 @@ class LongTermClipperTests(CASTestCase): def _setup_ldap(self, promo=12, username="test"): try: buid = six.binary_type(username, 'utf-8') + home = six.binary_type('/users/%d/phy/test/' % promo, 'utf-8') except TypeError: buid = six.binary_type(username) + home = six.binary_type('/users/%d/phy/test/' % promo) _mock_ldap.directory['dc=spi,dc=ens,dc=fr'] = { 'uid': [buid], 'cn': [b'John Smith'], 'mailRoutingAddress': [b'test@clipper.ens.fr'], - 'homeDirectory': [bytes('/users/%d/phy/test/' % promo)], + 'homeDirectory': [home], } def _count_ldap_queries(self):