LDAP error propagation + README + tox update and corrections
This commit is contained in:
parent
bfc0bb42ad
commit
787efe96d0
8 changed files with 171 additions and 110 deletions
|
@ -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``.
|
||||
|
|
|
@ -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/<year>/<department>/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,7 +80,7 @@ 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)
|
||||
|
@ -94,7 +99,8 @@ 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:
|
||||
|
@ -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)
|
||||
|
@ -138,26 +145,30 @@ 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') \
|
||||
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 '')
|
||||
|
@ -175,6 +186,7 @@ class LongTermClipperAccountAdapter(DefaultSocialAccountAdapter):
|
|||
|
||||
return user
|
||||
|
||||
|
||||
def deprecate_clippers():
|
||||
"""
|
||||
Marks all the SocialAccount with clipper as deprecated, by setting their
|
||||
|
@ -184,19 +196,23 @@ def deprecate_clippers():
|
|||
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()
|
||||
|
||||
|
@ -204,10 +220,10 @@ def install_longterm_adapter(fake=False):
|
|||
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 = []
|
||||
|
@ -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
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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("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("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("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"))
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,22 +148,24 @@ 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):
|
||||
|
@ -171,7 +176,8 @@ class LongTermClipperTests(CASTestCase):
|
|||
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")
|
||||
|
@ -185,28 +191,32 @@ class LongTermClipperTests(CASTestCase):
|
|||
|
||||
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]
|
||||
|
@ -251,8 +261,8 @@ class LongTermClipperTests(CASTestCase):
|
|||
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):
|
||||
|
@ -270,7 +280,7 @@ class LongTermClipperTests(CASTestCase):
|
|||
deprecate_clippers()
|
||||
|
||||
self._setup_ldap(13)
|
||||
r = self.client_cas_login(self.client, provider_id="clipper",
|
||||
self.client_cas_login(self.client, provider_id="clipper",
|
||||
username="test")
|
||||
self.client.logout()
|
||||
|
||||
|
@ -282,11 +292,11 @@ 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=\
|
||||
with self.settings(SOCIALACCOUNT_ADAPTER=
|
||||
'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'):
|
||||
r = self.client_cas_login(self.client, provider_id="clipper",
|
||||
username='test')
|
||||
|
@ -307,9 +317,10 @@ class LongTermClipperTests(CASTestCase):
|
|||
self.assertEqual(user1.username, "test@12")
|
||||
|
||||
def test_longterm_installer_from_djangocas(self):
|
||||
with self.settings(SOCIALACCOUNT_ADAPTER=\
|
||||
with self.settings(SOCIALACCOUNT_ADAPTER=
|
||||
'allauth.socialaccount.adapter.DefaultSocialAccountAdapter'):
|
||||
user0 = User.objects.create_user('test', 'test@clipper.ens.fr', 'test')
|
||||
user0 = User.objects.create_user('test', 'test@clipper.ens.fr',
|
||||
'test')
|
||||
nsa0 = SocialAccount.objects.count()
|
||||
|
||||
self._setup_ldap(12)
|
||||
|
@ -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()
|
||||
|
|
|
@ -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'))
|
||||
|
|
3
tox.ini
3
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
|
||||
|
|
Loading…
Reference in a new issue