ldap: Add support for syncing avatar images from LDAP.

This should make life a lot more convenient for organizations that use
the LDAP integration and have their avatars in LDAP already.

This hasn't been end-to-end tested against LDAP yet, so there may be
some minor revisions, but fundamentally, it works, has automated
tests, and should be easy to maintain.

Fixes #286.
This commit is contained in:
Tim Abbott 2018-12-12 10:46:37 -08:00
parent c9b75a8a65
commit 5dd646f33f
5 changed files with 49 additions and 4 deletions

View File

@ -73,8 +73,8 @@ In either configuration, you will need to do the following:
in your LDAP database. in your LDAP database.
4. Tell Zulip how to map the user information in your LDAP database to 4. Tell Zulip how to map the user information in your LDAP database to
the form it needs. There are three supported ways to set up the the form it needs for authentication. There are three supported
username and/or email mapping: ways to set up the username and/or email mapping:
(A) Using email addresses as usernames, if LDAP has each user's (A) Using email addresses as usernames, if LDAP has each user's
email address. To do this, just set `AUTH_LDAP_USER_SEARCH` to email address. To do this, just set `AUTH_LDAP_USER_SEARCH` to
@ -116,6 +116,16 @@ configuring this integration, you will need to run:
to sync names for existing users. You may want to run this in a cron to sync names for existing users. You may want to run this in a cron
job to pick up name changes made on your LDAP server. job to pick up name changes made on your LDAP server.
### Synchronizing avatars
Starting with Zulip 2.0, Zulip supports syncing LDAP / Active
Directory profile pictures (usually available in the `thumbnailPhoto`
or `jpegPhoto` attribute in LDAP) by configuring the `avatar` key in
`AUTH_LDAP_USER_ATTR_MAP`. This uses the same mechanism as populating
names: Users will automatically receive the appropriate avatar on
account creation, and `manage.py sync_ldap_user_data` will
automatically update their avatar from the data in LDAP.
### Multiple LDAP searches ### Multiple LDAP searches
To do the union of multiple LDAP searches, use `LDAPSearchUnion`. For example: To do the union of multiple LDAP searches, use `LDAPSearchUnion`. For example:

View File

@ -17,7 +17,11 @@ def query_ldap(**options: str) -> None:
print("No such user found") print("No such user found")
else: else:
for django_field, ldap_field in settings.AUTH_LDAP_USER_ATTR_MAP.items(): for django_field, ldap_field in settings.AUTH_LDAP_USER_ATTR_MAP.items():
print("%s: %s" % (django_field, ldap_attrs[ldap_field])) value = ldap_attrs[ldap_field]
if django_field == "avatar":
if isinstance(value[0], bytes):
value = "(An avatar image file)"
print("%s: %s" % (django_field, value))
if settings.LDAP_EMAIL_ATTR is not None: if settings.LDAP_EMAIL_ATTR is not None:
print("%s: %s" % ('email', ldap_attrs[settings.LDAP_EMAIL_ATTR])) print("%s: %s" % ('email', ldap_attrs[settings.LDAP_EMAIL_ATTR]))

View File

@ -32,6 +32,7 @@ from zerver.lib.actions import (
ensure_stream, ensure_stream,
validate_email, validate_email,
) )
from zerver.lib.avatar import avatar_url
from zerver.lib.mobile_auth_otp import otp_decrypt_api_key from zerver.lib.mobile_auth_otp import otp_decrypt_api_key
from zerver.lib.validator import validate_login_email, \ from zerver.lib.validator import validate_login_email, \
check_bool, check_dict_only, check_string, Validator check_bool, check_dict_only, check_string, Validator
@ -2409,12 +2410,17 @@ class TestLDAP(ZulipTestCase):
self.assertIs(user_profile, None) self.assertIs(user_profile, None)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',)) @override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
@override_settings(AUTH_LDAP_USER_ATTR_MAP={
"full_name": "cn",
"avatar": "thumbnailPhoto",
})
def test_login_success_when_user_does_not_exist_with_valid_subdomain( def test_login_success_when_user_does_not_exist_with_valid_subdomain(
self) -> None: self) -> None:
self.mock_ldap.directory = { self.mock_ldap.directory = {
'uid=nonexisting,ou=users,dc=acme,dc=com': { 'uid=nonexisting,ou=users,dc=acme,dc=com': {
'cn': ['NonExisting', ], 'cn': ['NonExisting', ],
'userPassword': 'testing' 'userPassword': 'testing',
'thumbnailPhoto': [open(os.path.join(settings.STATIC_ROOT, "images/team/tim.png"), "rb").read()],
} }
} }
with self.settings( with self.settings(
@ -2428,6 +2434,11 @@ class TestLDAP(ZulipTestCase):
self.assertEqual(user_profile.full_name, 'NonExisting') self.assertEqual(user_profile.full_name, 'NonExisting')
self.assertEqual(user_profile.realm.string_id, 'zulip') self.assertEqual(user_profile.realm.string_id, 'zulip')
# Verify avatar gets created
self.assertEqual(user_profile.avatar_source, UserProfile.AVATAR_FROM_USER)
result = self.client_get(avatar_url(user_profile))
self.assertEqual(result.status_code, 200)
class TestZulipLDAPUserPopulator(ZulipTestCase): class TestZulipLDAPUserPopulator(ZulipTestCase):
def test_authenticate(self) -> None: def test_authenticate(self) -> None:
backend = ZulipLDAPUserPopulator() backend = ZulipLDAPUserPopulator()

View File

@ -336,6 +336,23 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
return "@".join((username, settings.LDAP_APPEND_DOMAIN)) return "@".join((username, settings.LDAP_APPEND_DOMAIN))
return username return username
def sync_avatar_from_ldap(self, user: UserProfile, ldap_user: _LDAPUser) -> None:
if 'avatar' in settings.AUTH_LDAP_USER_ATTR_MAP:
# We do local imports here to avoid import loops
from zerver.lib.upload import upload_avatar_image
from zerver.lib.actions import do_change_avatar_fields
from io import BytesIO
avatar_attr_name = settings.AUTH_LDAP_USER_ATTR_MAP['avatar']
upload_avatar_image(BytesIO(ldap_user.attrs[avatar_attr_name][0]), user, user)
do_change_avatar_fields(user, UserProfile.AVATAR_FROM_USER)
def get_or_build_user(self, username: str,
ldap_user: _LDAPUser) -> Tuple[UserProfile, bool]: # nocoverage
(user, built) = super().get_or_build_user(username, ldap_user)
self.sync_avatar_from_ldap(user, ldap_user)
return (user, built)
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase): class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
REALM_IS_NONE_ERROR = 1 REALM_IS_NONE_ERROR = 1
@ -404,6 +421,7 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
short_name = ldap_user.attrs[short_name_attr][0] short_name = ldap_user.attrs[short_name_attr][0]
user_profile = do_create_user(username, None, self._realm, full_name, short_name) user_profile = do_create_user(username, None, self._realm, full_name, short_name)
self.sync_avatar_from_ldap(user_profile, ldap_user)
return user_profile, True return user_profile, True

View File

@ -445,6 +445,8 @@ LDAP_EMAIL_ATTR = None # type: Optional[str]
AUTH_LDAP_USER_ATTR_MAP = { AUTH_LDAP_USER_ATTR_MAP = {
# full_name is required; common values include "cn" or "displayName". # full_name is required; common values include "cn" or "displayName".
"full_name": "cn", "full_name": "cn",
# User avatars can be pulled from the LDAP "thumbnailPhoto"/"jpegPhoto" field.
# "avatar": "thumbnailPhoto",
} }