Compare commits

...

59 commits

Author SHA1 Message Date
122d99e515
fix: ugettext does not exist anymore 2024-10-22 10:53:31 +02:00
Théophile Bastian
ad4ebfe7ef Bump version: 1.1.3 2019-12-25 11:55:07 +01:00
Martin Pepin
ab40368c65 Merge branch 'tobast/translation' into 'master'
Deploy translation files

See merge request klub-dev-ens/django-allauth-ens!19
2019-10-04 22:52:04 +02:00
Théophile Bastian
4c8c66b005 Add missing translations 2019-06-25 10:37:15 +02:00
Théophile Bastian
239247efc9 Deploy translations with pip package 2019-06-25 10:36:08 +02:00
Aurélien Delobelle
572d58722d Bump version: 1.1.2 2019-01-06 14:08:44 +01:00
Aurélien Delobelle
a216a0cad1 Merge branch 'Evarin/install_params' into 'master'
New longterm_install options

See merge request klub-dev-ens/django-allauth-ens!18
2019-01-06 13:58:08 +01:00
Evarin
884020cc20 Make flake8 happy 2019-01-06 12:08:07 +01:00
Evarin
a30d3866c5 Install options explained in readme 2019-01-06 11:47:16 +01:00
Evarin
1a91ca8090 Flag to keep usernames in install_longterm 2018-10-21 23:21:47 +02:00
Evarin
126f367e76 flake8 2018-10-21 22:35:44 +02:00
Evarin
2c57fb7a4d New parameters for install_longterm 2018-10-21 22:27:15 +02:00
Aurélien Delobelle
0272edd77d Bump version: 1.1.1 2018-10-21 16:10:04 +02:00
Aurélien Delobelle
14d54f528b Merge branch 'aureplop/pin-python-cas' into 'master'
Pin python-cas & bump allauth-cas

See merge request cof-geek/django-allauth-ens!17
2018-10-21 16:08:04 +02:00
Aurélien Delobelle
85f09ca9f7 Pin python-cas & bump allauth-cas
Pin python-cas to avoid breaking login, and keep working redirection
after CAS logout.
2018-10-21 15:45:55 +02:00
Aurélien Delobelle
bc772851d1 Merge branch 'Kerl/fix-test' into 'master'
Fix non py3-compatible test in CI

See merge request cof-geek/django-allauth-ens!16
2018-10-06 23:15:51 +02:00
Martin Pépin
a27921016b fix import ordering 2018-10-06 22:57:49 +02:00
Martin Pépin
fc16b3fedf Fix non py3-compatible test in CI 2018-10-06 17:18:40 +02:00
Aurélien Delobelle
5d978e89ea Merge branch 'Kerl/isalnum' into 'master'
Show ill-formed clipper uids in error messages

See merge request cof-geek/django-allauth-ens!13
2018-10-06 16:58:31 +02:00
Martin Pépin
d22d1ea567 remove leftover debug print 2018-10-06 16:15:31 +02:00
Aurélien Delobelle
9d3e7a805a Merge branch 'aureplop/setup-ci-remove-tox-from-cache' into 'master'
Don't cache .tox in CI, it's too big ; hope pip cache is fine enough

See merge request cof-geek/django-allauth-ens!15
2018-10-06 13:45:53 +02:00
Aurélien Delobelle
b0451097fc Don't cache .tox in CI, it's too big ; hope pip cache is fine enough 2018-10-06 13:27:10 +02:00
Aurélien Delobelle
e35169d544 Merge branch 'aureplop/setup-ci-multi-django' into 'master'
Fix CI to run all Django versions

See merge request cof-geek/django-allauth-ens!14
2018-10-06 13:16:19 +02:00
Aurélien Delobelle
e936fb70de Try caching pip packages 2018-10-06 12:11:08 +02:00
Aurélien Delobelle
509a1bbd96 Run CI for all Django versions for each Python one 2018-10-06 12:09:04 +02:00
Martin Pépin
4a119c7d13 Show ill-formed clipper uids in error messages 2018-10-06 11:57:36 +02:00
Aurélien Delobelle
52a5e21ade Merge branch 'aureplop/setup-ci' into 'master'
Setup GitlLab CI

See merge request cof-geek/django-allauth-ens!12
2018-10-06 11:49:57 +02:00
Aurélien Delobelle
845b09cc2b Disable coverage in CI
GitLab may not like this regex...
2018-10-05 23:33:52 +02:00
Aurélien Delobelle
c0059bea80 Setup GitLab CI 2018-10-05 23:26:31 +02:00
Aurélien Delobelle
28a8127d35 Bump version: 1.1.0 2018-09-30 00:48:49 +02:00
Aurélien Delobelle
e193239231 Lint with flake8 2018-09-30 00:15:18 +02:00
Aurélien Delobelle
b09bada052 Merge branch 'Evarin/highlight_clipper'
Highlight Clipper provider.
Toggle by settings "ALLAUTH_ENS_HIGHLIGHT_CLIPPER".

See merge request cof-geek/django-allauth-ens!9
2018-09-30 00:06:00 +02:00
Aurélien Delobelle
bc2b606288 Merge branch 'Evarin/archicubes' into 'master'
Un adapter django-allauth pour gérer les comptes en fin de scolarité et la possible réattribution des logins clippers.

See merge request cof-geek/django-allauth-ens!3
2018-09-30 00:03:25 +02:00
Evarin
a812a43a2c Compatibilite des tests en py2 2018-09-29 23:58:32 +02:00
Evarin
6a79b02fa8 Merge branch 'Evarin/archicubes' of git.eleves.ens.fr:cof-geek/django-allauth-ens into Evarin/archicubes 2018-09-29 23:36:17 +02:00
Evarin
b189c11454 Detoxify + debug py34 2018-09-29 23:32:50 +02:00
Evarin
232236b50b Fix translation and indentation 2018-06-24 20:11:33 +02:00
Robin Champenois
6e77b31e0d Merge branch 'aureplop/archicubes_keep-ldap' into 'Evarin/archicubes'
LongTermClipper preserving LDAP data

See merge request cof-geek/django-allauth-ens!8
2018-06-24 19:57:06 +02:00
Aurélien Delobelle
534e0b188f LongTermClipper preserving LDAP data 2018-06-24 12:42:13 +02:00
Evarin
7a0ec189e2 Traductions en Français 2018-06-22 22:37:16 +02:00
Evarin
cca8da5772 Show prominently the Clipper third-party 2018-06-22 22:05:42 +02:00
Evarin
35a3bc3e2d Small fix to example site 2018-06-19 19:15:25 +02:00
Evarin
08a47150db Populate user model with promotion infos
As requested by Erkan et Martin
2018-06-03 22:26:53 +02:00
Evarin
4cf633ed81 Actually no, too bad 2018-06-03 22:17:14 +02:00
Evarin
5ee1c774ac Better README display? 2018-06-03 22:15:16 +02:00
Evarin
17fef409a8 More readable and organized code
Working from Aurelien's code reviews
2018-06-03 22:10:34 +02:00
Evarin
787efe96d0 LDAP error propagation + README + tox update and corrections 2018-04-29 01:28:30 +02:00
Evarin
bfc0bb42ad Add ldap query counting to tests 2018-04-28 16:33:08 +02:00
Evarin
a1671a3dd7 Fixes from Elarnon's review 2018-04-28 16:26:52 +02:00
Evarin
f0a73f6ef6 Util management command to install longtermadapter + fixes 2018-04-24 00:19:42 +02:00
Evarin
b6f5acaa46 Readme 2018-04-23 01:13:12 +02:00
Evarin
9c3fc72ec0 One more test case 2018-04-23 01:13:03 +02:00
Evarin
6fc012cb39 Fix my tests 2018-04-23 00:38:37 +02:00
Evarin
1142db73f7 Revert "Fix other tests"
This reverts commit 54963e9f91.
2018-04-23 00:35:57 +02:00
Evarin
54963e9f91 Fix other tests 2018-04-23 00:22:50 +02:00
Evarin
dc8873cafc Tests for adapter ready 2018-04-23 00:01:27 +02:00
Evarin
5a78561d17 WIP tests 2018-04-22 20:13:42 +02:00
Evarin
cdb9c3c722 Mail address disambiguation, debug, only one ldap query 2018-04-22 17:22:16 +02:00
Evarin
021d50fade An adapter to handle the 'end of scolarity' problem 2018-04-22 15:31:41 +02:00
28 changed files with 1522 additions and 68 deletions

67
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,67 @@
image: python
stages:
- test
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/vendor/pip"
cache:
paths:
- vendor
before_script:
- mkdir -p vendor/{apt,pip}
# http://www.python-ldap.org/en/latest/installing.html
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq build-essential python2.7-dev python3-dev libldap2-dev libsasl2-dev
- pip install tox
python27:
image: python:2.7
stage: test
script:
- tox -e django18-py27
- tox -e django19-py27
- tox -e django110-py27
- tox -e django111-py27
python34:
image: python:3.4
stage: test
script:
- tox -e django18-py34
- tox -e django19-py34
- tox -e django110-py34
- tox -e django111-py34
- tox -e django20-py34
python35:
image: python:3.5
stage: test
script:
- tox -e django18-py35
- tox -e django19-py35
- tox -e django110-py35
- tox -e django111-py35
- tox -e django20-py35
- tox -e cov_combine
# Catch coverage here. Python3.5 supports more Django versions.
# For GitLab, keep this commented.
# coverage: '/TOTAL.*\s(\d+\.\d+)\%$/'
python36:
image: python:3.6
stage: test
script:
- tox -e django111-py36
- tox -e django20-py36
flake8:
image: python:3.6
stage: test
script: tox -e flake8
isort:
image: python:3.6
stage: test
script: tox -e isort

View file

@ -1,5 +1,33 @@
****************** *****
1.0.0 (unreleased) 1.1.3
****************** *****
- Fix: translation was not included in the package
*****
1.1.2
*****
- Options added to ``install_longterm`` command to preserve usernames and source
clipper identifiers from various sources. !18
*****
1.1.1
*****
- Pin python-cas version to make Clipper provider work. !17
*****
1.1.0
*****
- Add long-term support for Clipper (see README for instructions on setup/update)
- Highlight Clipper provider in login screens (see related settings in README)
- Vendorize some static files (JS)
*******
1.0.0b2
*******
- First official release. - First official release.

View file

@ -2,3 +2,4 @@ include LICENSE README.rst
recursive-include allauth_ens/static * recursive-include allauth_ens/static *
recursive-include allauth_ens/templates * recursive-include allauth_ens/templates *
recursive-include allauth_ens/locale *

View file

@ -77,6 +77,13 @@ See also the `allauth configuration`_ and `advanced usage`_ docs pages.
**Examples:** ``'my-account'``, ``'/my-account/'`` **Examples:** ``'my-account'``, ``'/my-account/'``
``ALLAUTH_ENS_HIGHLIGHT_CLIPPER``
*Optional* — Boolean (default: `True`).
When set to `True`, displays prominently the Clipper option in the login view
(if you use the `allauth_ens` templates).
***** *****
Views Views
***** *****
@ -148,22 +155,83 @@ Configuration
SOCIALACCOUNT_PROVIDERS = { SOCIALACCOUNT_PROVIDERS = {
# … # …
'clipper': { 'clipper': {
# These settings control whether a message containing a link to # These settings control whether a message containing a link to
# disconnect from the CAS server is added when users log out. # disconnect from the CAS server is added when users log out.
'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT': True, 'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT': True,
'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT_LEVEL': messages.INFO, 'MESSAGE_SUGGEST_LOGOUT_ON_LOGOUT_LEVEL': messages.INFO,
}, },
} }
Auto-signup Auto-signup
Poulated data Populated data
- username: ``<clipper>`` - username: ``<clipper>``
- email (primary and verified): ``<clipper>@clipper.ens.fr`` - email (primary and verified): ``<clipper>@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.
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`
Auto-signup
Populated data
- *username*: ``<clipper>@<entrance year>``
- *email*: from LDAP's *mailRoutingAddress* field, or ``<clipper>@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
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.
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
Description
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``.
Install_longterm options
- ``--use-socialaccounts``: Use the existing SocialAccounts rather than all the Users. Useful if you are already using Allauth and don't want ``install_longterm`` to mess with the non-clipper authentications.
- ``--keep-usernames``: Do not apply the username template (e.g. ``clipper@promo``) to the existing accounts, only populate the SocialAccounts with LDAP informations. Useful if you don't want to change the usernames of previous users, but do want such a template for future accounts.
- ``--clipper-field <field_name>``: Use a special field rather than the username to get the clipper username (for LDAP lookup and SocialAccount creation/update). This parameter is compatible with ForeignKeys (e.g. ``profile.clipper``). Note: ``--use-socialaccounts`` will ignore the ``--clipper-field`` parameter.
- ``--fake``: Do not modify the database. Use it to test there is no conflict, and be sure the changes are the ones expected. This command does not check for uniqueness errors, so there it may succeed and the actual command fail eventually.
Typical use cases
- *Django-cas-ng -> Longterm*: Use ``install_longterm`` without parameters, or maybe ``--keep-usernames``. If you had a custom username handling, ``--clipper_field`` may be useful.
- *Allauth -> Longterm*: Use ``install_longterm`` with ``--use-socialaccounts``, and maybe ``--keep-usernames``.
********* *********
Demo Site Demo Site
@ -201,7 +269,11 @@ Tests
Local environment Local environment
----------------- -----------------
``$ ./runtests.py`` Requirements
* fakeldap and mock, install with ``$ pip install mock fakeldap``
Run
* ``$ ./runtests.py``
All All
--- ---

View file

@ -1,3 +1,3 @@
__version__ = '1.0.0b2' __version__ = "1.1.3"
default_app_config = 'allauth_ens.apps.ENSAllauthConfig' default_app_config = "allauth_ens.apps.ENSAllauthConfig"

215
allauth_ens/adapter.py Normal file
View file

@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
from django.contrib.auth import get_user_model
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 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
their scolarity and that their clipper login might be reused later
"""
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 entrance years provided by the
LDAP.
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_uid = sociallogin.account.uid
try:
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_uid)
sociallogin._ldap_data = ldap_data
if ldap_data is None or 'entrance_year' not in ldap_data:
raise ValueError("No entrance year in LDAP data")
old_conn_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
# the previous SocialAccount instance
old_conn.provider = 'clipper'
old_conn.save()
# Redo the thing that had failed just before
sociallogin.lookup()
def get_username(self, clipper_uid, data):
"""
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")
return "{}@{}".format(clipper_uid, data['entrance_year'])
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()
clipper_uid = sociallogin.account.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))
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:]))
# 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)
# Save extra data (only once)
sociallogin.account.extra_data['ldap'] = ldap_data
sociallogin.save(request)
sociallogin.account.save()
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)
# 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, accounts=None, keep_usernames=False):
"""
Manages the transition from an older django_cas or an allauth_ens
installation without LongTermClipperAccountAdapter
accounts is an optional dictionary containing the association between
clipper usernames and django's User accounts. If not provided, the
function will assumer Users' usernames are their clipper uid.
"""
if accounts is None:
accounts = {u.username: u for u in User.objects.all()
if u.username.isalnum()}
ldap_connection = init_ldap()
ltc_adapter = get_adapter()
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 = []
for userinfo in info:
infos = userinfo[1]
data = extract_infos_from_ldap(infos)
clipper_uid = data['clipper_uid']
user = accounts.get(clipper_uid, None)
if user is None:
continue
if not keep_usernames:
user.username = ltc_adapter.get_username(clipper_uid, data)
if fake:
cases.append(clipper_uid)
else:
user.save()
cases.append(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)
return logs

View file

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
class ENSAllauthConfig(AppConfig): class ENSAllauthConfig(AppConfig):

Binary file not shown.

View file

@ -0,0 +1,415 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-24 20:10+0200\n"
"PO-Revision-Date: 2019-06-25 10:35+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Poedit 2.2.1\n"
#: apps.py:7
msgid "ENS Authentication"
msgstr "Connexion pour l'ENS"
#: templates/account/account_inactive.html:5
#: templates/account/account_inactive.html:6
msgid "Account Inactive"
msgstr "Compte inactif"
#: templates/account/account_inactive.html:12
msgid ""
"\n"
" This account is inactive.\n"
" "
msgstr ""
"\n"
" Ce compte est inactif.\n"
" "
#: templates/account/email.html:4 templates/account/email.html:5
msgid "E-mail Addresses"
msgstr "Adresses e-mail"
#: templates/account/email.html:16
msgid ""
"\n"
" The following e-mail addresses are associated with your account:\n"
" "
msgstr ""
"\n"
" Les adresses mails suivantes sont associées à votre compte :\n"
" "
#: templates/account/email.html:30
msgid "This email address is verified."
msgstr "Cette adresse e-mail est vérifiée."
#: templates/account/email.html:34
msgid "This email address is not verified."
msgstr "Cette adresse e-mail n'est pas vérifiée."
#: templates/account/email.html:40
msgid "This is your primary email address."
msgstr "Ceci est votre adresse e-mail primaire."
#: templates/account/email.html:54
msgid "Remove"
msgstr "Supprimer"
#: templates/account/email.html:65
msgid "Re-send verification"
msgstr "Ré-envoyer le message de vérification"
#: templates/account/email.html:77
msgid "Make primary"
msgstr "Rendre principale"
#: templates/account/email.html:90
msgid ""
"\n"
" You currently do not have any e-mail address set up. You should really\n"
" add an e-mail address so you can receive notifications, reset your\n"
" password, etc.\n"
" "
msgstr ""
"\n"
" Vous n'avez actuellement aucune adresse e-mail associée à votre compte.\n"
" Vous devriez vraiment ajouter une adresse e-mail afin que vous puissiez "
"recevoir\n"
" des notifications, réinitialiser votre mot de passe, etc.\n"
" "
#: templates/account/email.html:101
msgid "Add E-mail"
msgstr "Ajouter un e-mail"
#: templates/account/email_confirm.html:5
#: templates/account/email_confirm.html:6
msgid "Confirm E-mail Address"
msgstr "Confirmer l'adresse e-mail"
#: templates/account/email_confirm.html:18
#, python-format
msgid ""
"\n"
" Please confirm that <b>%(email)s</b> is an e-mail address for user\n"
" %(user_display)s.\n"
" "
msgstr ""
"\n"
" Merci de confirmer que <b>%(email)s</b> est une adresse e-mail pour "
"l'utilisateur\n"
" %(user_display)s.\n"
" "
#: templates/account/email_confirm.html:26
msgid "Confirm"
msgstr "Confirmer"
#: templates/account/email_confirm.html:34
#, python-format
msgid ""
"\n"
" This e-mail confirmation link expired or is invalid.<br>\n"
" Please <a href=\"%(email_url)s\">issue a new e-mail confirmation "
"request</a>.\n"
" "
msgstr ""
"\n"
" Ce lien de confirmation d'e-mail a expiré ou est invalide.<br>\n"
" Merci de <a href=\"%(email_url)s\">soumettre une nouvelle requête de "
"confirmation d'e-mail</a>.\n"
" "
#: templates/account/login.html:5 templates/account/login.html:6
#: templates/account/login.html:78
msgid "Sign In"
msgstr "Connexion"
#: templates/account/login.html:14
msgid ""
"\n"
" Authentication failed. Please check your credentials and try "
"again.\n"
" "
msgstr ""
"\n"
" L'authentification a échoué. Merci de vérifier vos identifiants et "
"essayer à nouveau.\n"
" "
#: templates/account/login.html:22
#, python-format
msgid ""
"\n"
" Your are authenticated as %(user_str)s, but are not authorized to "
"access\n"
" this page. Would you like to login to a different account ?\n"
" "
msgstr ""
"\n"
" Vous êtes identifié comme %(user_str)s, mais vous n'être pas "
"autorisé à accéder\n"
" à cette page. Voulez-vous essayer avec un compte différent ?\n"
" "
#: templates/account/login.html:44
msgid "Connect with Clipper"
msgstr "Connexion via Clipper"
#: templates/account/login.html:45
msgid "Other choices"
msgstr "Autres choix"
#: templates/account/login.html:45
msgid "Other ways to sign in/sign up"
msgstr "Autres méthodes de connexion"
#: templates/account/login.html:65
msgid ""
"\n"
" Please sign in with one of your existing third party accounts, or with "
"the form below.\n"
" "
msgstr ""
"\n"
" Connectez-vous avec l'un de vos comptes tiers existants, ou avec le "
"formulaire ci-dessous.\n"
" "
#: templates/account/login.html:70
msgid "Forgot Password?"
msgstr "Mot de passe oublié ?"
#: templates/account/login.html:73 templates/account/signup.html:17
#: templates/socialaccount/signup.html:19
msgid "Sign Up"
msgstr "Nouveau compte"
#: templates/account/logout.html:4 templates/account/logout.html:5
#: templates/account/logout.html:20
msgid "Sign Out"
msgstr "Déconnexion"
#: templates/account/logout.html:11
msgid ""
"\n"
" Are you sure you want to sign out?\n"
" "
msgstr ""
"\n"
" Êtes-vous sûr de vouloir vous déconnecter ?\n"
" "
#: templates/account/password_change.html:4
#: templates/account/password_change.html:5
#: templates/account/password_change.html:18
#: templates/account/password_reset_from_key.html:4
#: templates/account/password_reset_from_key.html:5
#: templates/account/password_reset_from_key_done.html:4
#: templates/account/password_reset_from_key_done.html:5
msgid "Change Password"
msgstr "Changer le mot de passe"
#: templates/account/password_reset.html:4
#: templates/account/password_reset.html:5
#: templates/account/password_reset_done.html:4
#: templates/account/password_reset_done.html:5
msgid "Password Reset"
msgstr "Réinitialisation du mot de passe"
#: templates/account/password_reset.html:11
msgid ""
"\n"
" Forgotten your password? Enter your e-mail address below, and we'll "
"send\n"
" you an e-mail allowing you to reset it.\n"
" "
msgstr ""
"\n"
" Vous avez oublié votre mot de passe ? Entrez votre adresse e-mail ci-"
"dessous, et\n"
" nous vous enverrons un e-mail qui vous permettra de le réinitialiser.\n"
" "
#: templates/account/password_reset.html:18
#: templates/account/password_reset_from_key.html:21
msgid "Reset Password"
msgstr "Réinitialiser le mot de passe"
#: templates/account/password_reset_done.html:11
msgid ""
"\n"
" We have sent you an e-mail. Please contact us if you do not receive it "
"within a few minutes.\n"
" "
msgstr ""
"\n"
" Nous vous avons envoyé un e-mail. Merci de nous contacter si vous ne "
"l'avez pas reçu d'ici quelques minutes.\n"
" "
#: templates/account/password_reset_from_key.html:13
#, python-format
msgid ""
"\n"
" The password reset link was invalid, possibly because it has already "
"been used.\n"
" Please request a <a href=\"%(passwd_reset_url)s\">new password reset</"
"a>.\n"
" "
msgstr ""
"\n"
" Le lien de réinitialisation de mot de passe est invalide, possiblement "
"parce qu'il a déjà été utilisé.\n"
" Merci de faire une <a href=\"%(passwd_reset_url)s\">nouvelle demande</"
"a>.\n"
" "
#: templates/account/password_reset_from_key.html:24
msgid "Your password is now changed."
msgstr "Votre mot de passe a été modifié."
#: templates/account/password_reset_from_key_done.html:11
msgid ""
"\n"
" Your password is now changed.\n"
" "
msgstr ""
"\n"
" Votre mot de passe a été modifié.\n"
" "
#: templates/account/password_set.html:4 templates/account/password_set.html:5
#: templates/account/password_set.html:24
msgid "Set Password"
msgstr "Définir le mot de passe"
#: templates/account/password_set.html:17
msgid ""
"\n"
" Your account does not have a password yet. Add one to authenticate "
"without\n"
" third parties.\n"
" "
msgstr ""
"\n"
" Votre compte n'a pas encore de mot de passe. Ajoutez-en un pour vous "
"connecter\n"
" sans compte tiers.\n"
" "
#: templates/account/signup.html:4 templates/account/signup.html:5
#: templates/socialaccount/signup.html:4 templates/socialaccount/signup.html:5
msgid "Signup"
msgstr "Nouveau compte"
#: templates/account/signup.html:12
msgid "Already have an account?"
msgstr "Vous avez déjà un compte ?"
#: templates/account/signup_closed.html:4
#: templates/account/signup_closed.html:5
msgid "Sign Up Closed"
msgstr "Création de compte fermée"
#: templates/account/signup_closed.html:11
msgid ""
"\n"
" We are sorry, but the sign up is currently closed.\n"
" "
msgstr ""
"\n"
" Nous sommes désolés, mais la création de compte est actuellement "
"désactivée.\n"
" "
#: templates/allauth_ens/base.html:71
msgid "Not Connected"
msgstr "Non connecté"
#: templates/socialaccount/authentication_error.html:4
#: templates/socialaccount/authentication_error.html:5
msgid "Login Failure"
msgstr "Échec de la connexion"
#: templates/socialaccount/authentication_error.html:11
msgid ""
"\n"
" An error occured while attempting to login via your social network "
"account.\n"
" "
msgstr ""
"\n"
" Une erreur s'est produite lors de la connexion via le site externe.\n"
" "
#: templates/socialaccount/connections.html:5
#: templates/socialaccount/connections.html:6
msgid "Account Connections"
msgstr "Méthodes de connexion"
#: templates/socialaccount/connections.html:13
msgid ""
"\n"
" You can sign in to your account using any of the following third party "
"accounts:\n"
" "
msgstr ""
"\n"
" Vous pouvez vous connecter à votre compte en utilisant n'importe lequel "
"de ces comptes tiers :\n"
" "
#: templates/socialaccount/connections.html:17
msgid ""
"\n"
" You currently have no third party accounts connected to this account.\n"
" "
msgstr ""
"\n"
" Vous n'avez actuellement aucun compte tiers associé à ce compte.\n"
" "
#: templates/socialaccount/login_cancelled.html:4
#: templates/socialaccount/login_cancelled.html:5
msgid "Login Cancelled"
msgstr "Connexion annulée"
#: templates/socialaccount/login_cancelled.html:13
#, python-format
msgid ""
"\n"
" You decided to cancel logging into our site using one of your existing "
"accounts. If this was a mistake, please proceed to <a href=\"%(login_url)s"
"\">sign in</a>."
msgstr ""
"\n"
" Vous avez décidé d'annuler la connexion à notre site via un de vos "
"comptes existants. Si cela était une erreur, vous pouvez revenir sur <a href="
"\"%(login_url)s\">la page de connexion</a>."
#: templates/socialaccount/signup.html:11
#, python-format
msgid ""
"\n"
" You are about to use your %(provider_name)s account to login.\n"
" As a final step, please complete the following form:\n"
" "
msgstr ""
"\n"
" Vous êtes sur le point d'utiliser votre compte %(provider_name)s pour "
"vous connecter\n"
" Pour finaliser la procédure, merci de remplir le formulaire suivant :\n"
" "

View file

View file

@ -0,0 +1,16 @@
# 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'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
deprecate_clippers()
self.stdout.write(self.style.SUCCESS(
'Clippers deprecation successful'))

View file

@ -0,0 +1,80 @@
# coding: utf-8
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialAccount
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'),
)
parser.add_argument(
'--use-socialaccounts',
action='store_true',
default=False,
help=('Use the existing SocialAccounts rather than all the Users'),
)
parser.add_argument(
'--keep-usernames',
action='store_true',
default=False,
help=('Do not apply the username template (e.g. clipper@promo) to'
'the existing account, only populate the SocialAccounts with'
'ldap informations'),
)
parser.add_argument(
'--clipper-field',
default=None,
type=str
)
pass
def handle(self, *args, **options):
fake = options.get("fake", False)
keep_usernames = options.get("keep_usernames", False)
if options.get('use_socialaccounts', False):
accounts = {account.uid: account.user for account in
(SocialAccount.objects.filter(provider="clipper")
.prefetch_related("user"))}
elif options.get('clipper_field', None):
fields = options['clipper_field'].split('.')
User = get_user_model()
def get_subattr(obj, fields):
# Allows to follow OneToOne relationships
if len(fields) == 1:
return getattr(obj, fields[0])
return get_subattr(getattr(obj, fields[0]), fields[1:])
accounts = {get_subattr(account, fields): account for account in
User.objects.all()}
else:
accounts = None
logs = install_longterm_adapter(fake, accounts, keep_usernames)
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(
"LongTermClipper migration successful"))

View file

@ -1,11 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import ldap
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from allauth.socialaccount.providers.base import ProviderAccount 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): class ClipperAccount(ProviderAccount):
@ -21,41 +18,14 @@ class ClipperProvider(CASProvider):
uid, extra = data uid, extra = data
return '{}@clipper.ens.fr'.format(uid.strip().lower()) 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 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) common = super(ClipperProvider, self).extract_common_fields(data)
fn, ln = get_names(common['username'])
common['email'] = self.extract_email(data) common['email'] = self.extract_email(data)
common['name'] = fn
common['last_name'] = ln
return common return common
def extract_email_addresses(self, data): def extract_email_addresses(self, data):
@ -67,8 +37,23 @@ class ClipperProvider(CASProvider):
] ]
def extract_extra_data(self, data): 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 = super(ClipperProvider, self).extract_extra_data(data)
extra_data['email'] = self.extract_email(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 return extra_data
def message_suggest_caslogout_on_logout(self, request): def message_suggest_caslogout_on_logout(self, request):

View file

@ -17,6 +17,18 @@ class ClipperProviderTests(CASTestCase):
u = User.objects.get(username='clipper_uid') u = User.objects.get(username='clipper_uid')
self.assertEqual(u.email, 'clipper_uid@clipper.ens.fr') 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): class ClipperViewsTests(CASViewTestCase):

View file

@ -0,0 +1,35 @@
.content-wrapper.highlight-clipper {
.main-login-choices {
li:not(:first-child) {
margin-top: 5px;
}
a {
display: block;
text-align: center;
background: $gray-lighter;
padding: 35px 20px;
color: $black;
font-size: 1.1em;
@include hover-focus {
background: lighten($brand-primary, 50%);
text-decoration: none;
}
}
}
&:not(.not-clipper) {
width: 100vw;
max-width: 500px;
& > :not(.main-login-choices) {
display: none;
}
}
&.not-clipper {
.main-login-choices {
display: none;
}
}
}

View file

@ -5,3 +5,5 @@
@import "mixins"; @import "mixins";
@import "base"; @import "base";
@import "highlight_clipper";

View file

@ -1,4 +1,4 @@
/* line 5, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 5, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
html, body, div, span, applet, object, iframe, html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre, h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code, a, abbr, acronym, address, big, cite, code,
@ -20,45 +20,45 @@ time, mark, audio, video {
vertical-align: baseline; vertical-align: baseline;
} }
/* line 22, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 22, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
html { html {
line-height: 1; line-height: 1;
} }
/* line 24, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 24, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
ol, ul { ol, ul {
list-style: none; list-style: none;
} }
/* line 26, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 26, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
table { table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
} }
/* line 28, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 28, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
caption, th, td { caption, th, td {
text-align: left; text-align: left;
font-weight: normal; font-weight: normal;
vertical-align: middle; vertical-align: middle;
} }
/* line 30, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 30, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
q, blockquote { q, blockquote {
quotes: none; quotes: none;
} }
/* line 103, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 103, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
q:before, q:after, blockquote:before, blockquote:after { q:before, q:after, blockquote:before, blockquote:after {
content: ""; content: "";
content: none; content: none;
} }
/* line 32, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 32, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
a img { a img {
border: none; border: none;
} }
/* line 116, ../../../vendor/bundle/ruby/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ /* line 116, ../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */
article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary {
display: block; display: block;
} }
@ -775,3 +775,35 @@ section > * + * {
background: #2d672d; background: #2d672d;
color: #fff; color: #fff;
} }
/* line 3, ../../scss/_highlight_clipper.scss */
.content-wrapper.highlight-clipper .main-login-choices li:not(:first-child) {
margin-top: 5px;
}
/* line 7, ../../scss/_highlight_clipper.scss */
.content-wrapper.highlight-clipper .main-login-choices a {
display: block;
text-align: center;
background: #eceeef;
padding: 35px 20px;
color: #000;
font-size: 1.1em;
}
/* line 10, ../../scss/_mixins.scss */
.content-wrapper.highlight-clipper .main-login-choices a:focus, .content-wrapper.highlight-clipper .main-login-choices a:hover {
background: #a8d6fe;
text-decoration: none;
}
/* line 21, ../../scss/_highlight_clipper.scss */
.content-wrapper.highlight-clipper:not(.not-clipper) {
width: 100vw;
max-width: 500px;
}
/* line 25, ../../scss/_highlight_clipper.scss */
.content-wrapper.highlight-clipper:not(.not-clipper) > :not(.main-login-choices) {
display: none;
}
/* line 31, ../../scss/_highlight_clipper.scss */
.content-wrapper.highlight-clipper.not-clipper .main-login-choices {
display: none;
}

View file

@ -30,10 +30,23 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content-extra-classes %}{% is_clipper_highlighted as highlight_clipper %}{% if highlight_clipper %}highlight-clipper{% endif %}{% endblock %}
{% block content %} {% block content %}
{% get_providers as socialaccount_providers %} {% get_providers as socialaccount_providers %}
{% is_clipper_highlighted as highlight_clipper %}
{% if highlight_clipper %}
<section class="main-login-choices">
<ul>
<li><a title="Clipper" href="{% provider_login_url "clipper" process="login" scope=scope auth_params=auth_params %}">{% trans "Connect with Clipper" %}</a></li>
<li><a title="{% trans "Other choices" %}" href="javascript:void(0);" onclick="$('.content-wrapper').toggleClass('not-clipper')">{% trans "Other ways to sign in/sign up" %}</a></li>
</ul>
</section>
{% endif %}
{% if socialaccount_providers %} {% if socialaccount_providers %}
<section id="providers"> <section id="providers">
<ul class="method-list"> <ul class="method-list">
@ -68,7 +81,6 @@
{% endif %} {% endif %}
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View file

@ -79,7 +79,7 @@
{% block messages-extra %}{% endblock %} {% block messages-extra %}{% endblock %}
<div class="content-wrapper"> <div class="content-wrapper {% block content-extra-classes %}{% endblock %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>

View file

@ -7,7 +7,7 @@
{% for provider in socialaccount_providers %} {% for provider in socialaccount_providers %}
{% if provider.id == "openid" %} {% if provider.id == "openid" %}
{% for brand in provider.get_brands %} {% for brand in provider.get_brands %}
<li class="method-wrapper"> <li class="method-wrapper provider-openid-{{ brand.id }}">
<a title="{{ brand.name }}" <a title="{{ brand.name }}"
href="{% provider_login_url provider.id openid=brand.openid_url process=process %}" href="{% provider_login_url provider.id openid=brand.openid_url process=process %}"
> >
@ -16,9 +16,9 @@
</li> </li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<li class="method-wrapper"> <li class="method-wrapper provider-{{ provider.id }}">
<a title="{{ provider.name }}" <a title="{{ provider.name }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_parms %}" href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}"
> >
{{ provider.name }} {{ provider.name }}
</a> </a>

View file

@ -33,3 +33,10 @@ def get_profile_url():
def is_open_for_signup(context): def is_open_for_signup(context):
request = context['request'] request = context['request']
return get_adapter(request).is_open_for_signup(request) return get_adapter(request).is_open_for_signup(request)
@simple_tag
def is_clipper_highlighted():
installed_apps = getattr(settings, 'INSTALLED_APPS', [])
return ('allauth_ens.providers.clipper' in installed_apps) \
and getattr(settings, 'ALLAUTH_ENS_HIGHLIGHT_CLIPPER', True)

View file

@ -1,3 +1,5 @@
from __future__ import unicode_literals
import re import re
import django import django
@ -5,6 +7,23 @@ from django.contrib.auth import HASH_SESSION_KEY, get_user_model
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail from django.core import mail
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.utils import captured_stdout
from allauth.socialaccount.models import SocialAccount
import six
from allauth_cas.test.testcases import CASTestCase
from fakeldap import MockLDAP
from mock import patch
from allauth_ens.utils import get_ldap_infos
from .adapter import deprecate_clippers, install_longterm_adapter
from .management.commands.install_longterm import Command as InstallLongterm
_mock_ldap = MockLDAP()
ldap_patcher = patch('allauth_ens.utils.ldap.initialize',
lambda x: _mock_ldap)
if django.VERSION >= (1, 10): if django.VERSION >= (1, 10):
from django.urls import reverse from django.urls import reverse
@ -135,3 +154,320 @@ class ViewsTests(TestCase):
def test_account_reset_password_from_key_done(self): def test_account_reset_password_from_key_done(self):
r = self.client.get(reverse('account_reset_password_from_key_done')) r = self.client.get(reverse('account_reset_password_from_key_done'))
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
@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"):
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': [home],
}
def _count_ldap_queries(self):
queries = _mock_ldap.ldap_methods_called()
count = len([op for op in queries if op != 'set_option'])
return count
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")
self.assertEqual(self._count_ldap_queries(), 1)
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()
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)
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.logout()
nu = User.objects.count()
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")
deprecate_clippers()
sa = SocialAccount.objects.all()[0]
self.assertEqual(sa.provider, "clipper_inactive")
def test_reconnect_after_deprecation(self):
self._setup_ldap()
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()
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)
self.assertEqual(self._count_ldap_queries(), 2)
def test_override_inactive_account(self):
self._setup_ldap(12)
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)
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.assertNotEqual(user1.id, user0.id)
def test_multiple_deprecation(self):
self._setup_ldap(12)
self.client_cas_login(self.client, provider_id="clipper",
username="test")
self.client.logout()
self._setup_ldap(15, "truc")
self.client_cas_login(self.client, provider_id="clipper",
username="truc")
self.client.logout()
sa0 = SocialAccount.objects.count()
deprecate_clippers()
self._setup_ldap(13)
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)
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()
outputs = install_longterm_adapter()
self.assertEqual(outputs["updated"], [("test", "test@12")])
r = self.client_cas_login(self.client, provider_id="clipper",
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_allauth_command_using_username(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()
with captured_stdout() as stdout:
command = InstallLongterm()
command.handle(clipper_field='username')
output = stdout.getvalue()
self.assertIn('test -> test@12', output)
r = self.client_cas_login(self.client, provider_id="clipper",
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_allauth_command_keeping_username(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()
with captured_stdout() as stdout:
command = InstallLongterm()
command.handle(keep_usernames=True)
output = stdout.getvalue()
self.assertIn('test -> test', output)
r = self.client_cas_login(self.client, provider_id="clipper",
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")
self.assertEqual(conn.extra_data['ldap']['entrance_year'], '12')
def test_longterm_installer_from_allauth_command_socialaccounts(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"]
self.assertEqual(user0.username, "test")
self.client.logout()
user1 = User.objects.create_user('bidule', 'bidule@clipper.ens.fr',
'bidule')
nsa0 = SocialAccount.objects.count()
with captured_stdout() as stdout:
command = InstallLongterm()
command.handle(use_socialaccounts=True)
output = stdout.getvalue()
self.assertIn('test -> test@12', output)
self.assertNotIn('bidule ->', output)
r = self.client_cas_login(self.client, provider_id="clipper",
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(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='allauth.socialaccount.'
'adapter.DefaultSocialAccountAdapter'):
user0 = User.objects.create_user('test', 'test@clipper.ens.fr',
'test')
nsa0 = SocialAccount.objects.count()
self._setup_ldap(12)
outputs = install_longterm_adapter()
self.assertEqual(outputs["created"], [("test", "test@12")])
r = self.client_cas_login(self.client, provider_id="clipper",
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()
nsa0 = SocialAccount.objects.count()
ldap_patcher.stop()
with self.settings(CLIPPER_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()
def test_invalid_uid(self):
self._setup_ldap(12, "test")
uids = [" test", "test ", "\\test", "test)"]
for uid in uids:
with self.assertRaises(ValueError) as cm:
get_ldap_infos(uid)
self.assertIn(uid, str(cm.exception))

129
allauth_ens/utils.py Normal file
View file

@ -0,0 +1,129 @@
# -*- 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)
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")
# 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/<year>/<department>/clipper/"
annee = dirs[2]
dep = dirs[3]
dep_fancy = DEPARTMENTS_LIST.get(dep.lower(), '')
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):
if not clipper_uid.isalnum():
raise ValueError(
'Invalid uid "{}": contains non-alphanumeric characters'
.format(clipper_uid)
)
data = {}
try:
ldap_connection = init_ldap()
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])
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()

View file

@ -1,11 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth_ens.adapter import LongTermClipperAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
class AccountAdapter(DefaultAccountAdapter): class AccountAdapter(DefaultAccountAdapter):
pass pass
class SocialAccountAdapter(DefaultSocialAccountAdapter): 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('entrance_year', '00'))

View file

@ -7,7 +7,6 @@ combine_as_imports = True
default_section = THIRDPARTY default_section = THIRDPARTY
include_trailing_comma = True include_trailing_comma = True
known_allauth = allauth known_allauth = allauth
known_future_library = future,six
known_django = django known_django = django
known_first_party = allauth_ens known_first_party = allauth_ens
multi_line_output = 5 multi_line_output = 5

View file

@ -45,7 +45,10 @@ setup(
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
'django-allauth', 'django-allauth',
'django-allauth-cas>=1.0.0b2,<1.1', 'django-allauth-cas>=1.0,<1.1',
# The version of CAS used by cas.eleves.ens.fr is unclear…
# Stick to python-cas 1.2.0 until we solve this mystery.
'python-cas==1.2.0',
'django-widget-tweaks', 'django-widget-tweaks',
'python-ldap', 'python-ldap',
], ],

View file

@ -1,4 +1,5 @@
[tox] [tox]
# Update .gitlab-ci.yml if you change the Django/Python matrix
envlist = envlist =
django{18,19,110}-py{27,34,35}, django{18,19,110}-py{27,34,35},
django111-py{27,34,35,36}, django111-py{27,34,35,36},
@ -15,7 +16,8 @@ deps =
django111: django>=1.11,<2.0 django111: django>=1.11,<2.0
django20: django>=2.0,<2.1 django20: django>=2.0,<2.1
coverage coverage
mock ; python_version < "3.0" fakeldap
mock
usedevelop= True usedevelop= True
commands = commands =
python -V python -V