From 5dd646f33fa154ea98df1366b53e8904e5adf762 Mon Sep 17 00:00:00 2001 From: Tim Abbott Date: Wed, 12 Dec 2018 10:46:37 -0800 Subject: [PATCH] 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. --- docs/production/authentication-methods.md | 14 ++++++++++++-- zerver/management/commands/query_ldap.py | 6 +++++- zerver/tests/test_auth_backends.py | 13 ++++++++++++- zproject/backends.py | 18 ++++++++++++++++++ zproject/prod_settings_template.py | 2 ++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/production/authentication-methods.md b/docs/production/authentication-methods.md index a723c60fe8..7496ef0eac 100644 --- a/docs/production/authentication-methods.md +++ b/docs/production/authentication-methods.md @@ -73,8 +73,8 @@ In either configuration, you will need to do the following: in your LDAP database. 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 - username and/or email mapping: + the form it needs for authentication. There are three supported + ways to set up the username and/or email mapping: (A) Using email addresses as usernames, if LDAP has each user's 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 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 To do the union of multiple LDAP searches, use `LDAPSearchUnion`. For example: diff --git a/zerver/management/commands/query_ldap.py b/zerver/management/commands/query_ldap.py index f6ed1e072c..c3b0bf61d9 100644 --- a/zerver/management/commands/query_ldap.py +++ b/zerver/management/commands/query_ldap.py @@ -17,7 +17,11 @@ def query_ldap(**options: str) -> None: print("No such user found") else: 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: print("%s: %s" % ('email', ldap_attrs[settings.LDAP_EMAIL_ATTR])) diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 455d53ce24..7e2f7f6194 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -32,6 +32,7 @@ from zerver.lib.actions import ( ensure_stream, validate_email, ) +from zerver.lib.avatar import avatar_url from zerver.lib.mobile_auth_otp import otp_decrypt_api_key from zerver.lib.validator import validate_login_email, \ check_bool, check_dict_only, check_string, Validator @@ -2409,12 +2410,17 @@ class TestLDAP(ZulipTestCase): self.assertIs(user_profile, None) @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( self) -> None: self.mock_ldap.directory = { 'uid=nonexisting,ou=users,dc=acme,dc=com': { 'cn': ['NonExisting', ], - 'userPassword': 'testing' + 'userPassword': 'testing', + 'thumbnailPhoto': [open(os.path.join(settings.STATIC_ROOT, "images/team/tim.png"), "rb").read()], } } with self.settings( @@ -2428,6 +2434,11 @@ class TestLDAP(ZulipTestCase): self.assertEqual(user_profile.full_name, 'NonExisting') 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): def test_authenticate(self) -> None: backend = ZulipLDAPUserPopulator() diff --git a/zproject/backends.py b/zproject/backends.py index af6ddff925..ebb8dd5eed 100644 --- a/zproject/backends.py +++ b/zproject/backends.py @@ -336,6 +336,23 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend): return "@".join((username, settings.LDAP_APPEND_DOMAIN)) 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): REALM_IS_NONE_ERROR = 1 @@ -404,6 +421,7 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase): short_name = ldap_user.attrs[short_name_attr][0] 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 diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index 76b1c66880..4184b20471 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -445,6 +445,8 @@ LDAP_EMAIL_ATTR = None # type: Optional[str] AUTH_LDAP_USER_ATTR_MAP = { # full_name is required; common values include "cn" or "displayName". "full_name": "cn", + # User avatars can be pulled from the LDAP "thumbnailPhoto"/"jpegPhoto" field. + # "avatar": "thumbnailPhoto", }