ldap: Use email search in django_to_ldap_username.

With this, django_to_ldap_username can take an email and find the ldap
username of the ldap user who has this email - if email search is
configured.

This allows successful authenticate() with ldap email and ldap password,
instead of ldap username. This is especially useful because when
a user wants to fetch their api key, the server attempts authenticate
with user_profile.email - and this used to fail if the user was an ldap
user (because the ldap username was required to authenticate
succesfully). See issue #9277.
This commit is contained in:
Mateusz Mandera 2019-10-05 03:54:48 +02:00 committed by Tim Abbott
parent fea4d0b2be
commit 3699fe28f8
5 changed files with 146 additions and 28 deletions

View File

@ -3,7 +3,7 @@ from django.conf import settings
from django.core import mail
from django.http import HttpResponse
from django.test import override_settings
from django_auth_ldap.backend import LDAPBackend, LDAPSearch, _LDAPUser
from django_auth_ldap.backend import LDAPBackend, LDAPSearch
from django.test.client import RequestFactory
from django.utils.timezone import now as timezone_now
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
@ -265,42 +265,35 @@ class AuthBackendTest(ZulipTestCase):
result = self.client_get('/register/')
self.assert_not_in_success_response(["No authentication backends are enabled"], result)
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',),
LDAP_EMAIL_ATTR="mail")
def test_ldap_backend(self) -> None:
self.init_default_ldap_database()
user_profile = self.example_user('hamlet')
email = user_profile.email
password = "test_password"
password = "testing"
self.setup_subdomain(user_profile)
username = self.get_username()
backend = ZulipLDAPAuthBackend()
# Test LDAP auth fails when LDAP server rejects password
with mock.patch('django_auth_ldap.backend._LDAPUser._authenticate_user_dn',
side_effect=_LDAPUser.AuthenticationFailed("Failed")), (
mock.patch('django_auth_ldap.backend._LDAPUser._check_requirements')), (
mock.patch('django_auth_ldap.backend._LDAPUser.attrs',
return_value=dict(full_name=['Hamlet']))):
self.assertIsNone(backend.authenticate(username=email, password=password, realm=get_realm("zulip")))
self.assertIsNone(backend.authenticate(username=email, password="wrongpass", realm=get_realm("zulip")))
with mock.patch('django_auth_ldap.backend._LDAPUser._authenticate_user_dn'), (
mock.patch('django_auth_ldap.backend._LDAPUser._check_requirements')), (
mock.patch('django_auth_ldap.backend._LDAPUser.attrs',
return_value=dict(full_name=['Hamlet']))):
self.verify_backend(backend,
bad_kwargs=dict(username=username,
password=password,
realm=get_realm('zephyr')),
good_kwargs=dict(username=username,
password=password,
realm=get_realm('zulip')))
self.verify_backend(backend,
bad_kwargs=dict(username=username,
password=password,
realm=get_realm('zephyr')),
good_kwargs=dict(username=username,
password=password,
realm=get_realm('zulip')))
self.verify_backend(backend,
bad_kwargs=dict(username=username,
password=password,
realm=get_realm('zephyr')),
good_kwargs=dict(username=username,
password=password,
realm=get_realm('zulip')))
self.verify_backend(backend,
bad_kwargs=dict(username=username,
password=password,
realm=get_realm('zephyr')),
good_kwargs=dict(username=username,
password=password,
realm=get_realm('zulip')))
def test_devauth_backend(self) -> None:
self.verify_backend(DevAuthBackend(),

View File

@ -1,3 +1,4 @@
import ldap
import random
import re
import ujson
@ -5,6 +6,7 @@ import ujson
from django.conf import settings
from django.core import mail
from django.test import override_settings
from django_auth_ldap.config import LDAPSearch
from email.utils import formataddr
from mock import patch, MagicMock
from typing import List, Optional
@ -47,7 +49,11 @@ class TestFollowupEmails(ZulipTestCase):
# See https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#ldap-including-active-directory
# for case details.
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',
'zproject.backends.ZulipDummyBackend'))
'zproject.backends.ZulipDummyBackend'),
# configure email search for email address in the uid attribute:
AUTH_LDAP_REVERSE_EMAIL_SEARCH=LDAPSearch("ou=users,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(uid=%(email)s)"))
def test_day1_email_ldap_case_a_login_credentials(self) -> None:
self.init_default_ldap_database()
ldap_user_attr_map = {'full_name': 'cn', 'short_name': 'sn'}

91
zerver/tests/test_ldap.py Normal file
View File

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
from django.contrib.auth import authenticate
from django.test.utils import override_settings
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import get_realm
from zproject.backends import ZulipLDAPAuthBackend, ZulipLDAPExceptionOutsideDomain
from django_auth_ldap.config import LDAPSearch
import ldap
import mock
"""
This is a file for additional LDAP tests, that don't belong
anywhere else.
"""
class DjangoToLDAPUsernameTests(ZulipTestCase):
def setUp(self) -> None:
self.init_default_ldap_database()
self.backend = ZulipLDAPAuthBackend()
def test_django_to_ldap_username_with_append_domain(self) -> None:
with self.settings(LDAP_APPEND_DOMAIN="zulip.com"):
self.assertEqual(self.backend.django_to_ldap_username("hamlet"), "hamlet")
self.assertEqual(self.backend.django_to_ldap_username("hamlet@zulip.com"), "hamlet")
with self.assertRaises(ZulipLDAPExceptionOutsideDomain):
self.backend.django_to_ldap_username("hamlet@example.com")
def test_django_to_ldap_username_without_email_search(self) -> None:
with self.settings(AUTH_LDAP_REVERSE_EMAIL_SEARCH=None):
self.assertEqual(self.backend.django_to_ldap_username("hamlet"), "hamlet")
self.assertEqual(self.backend.django_to_ldap_username("hamlet@zulip.com"), "hamlet@zulip.com")
self.assertEqual(self.backend.django_to_ldap_username("hamlet@example.com"), "hamlet@example.com")
def test_django_to_ldap_username_with_email_search(self) -> None:
with self.settings():
self.assertEqual(self.backend.django_to_ldap_username("hamlet"), "hamlet")
self.assertEqual(self.backend.django_to_ldap_username("hamlet@zulip.com"), "hamlet")
# If there are no matches through the email search, return the email unchanged:
self.assertEqual(self.backend.django_to_ldap_username("no_such_email@example.com"),
"no_such_email@example.com")
# aaron has uid=letham in our test directory:
self.assertEqual(self.backend.django_to_ldap_username("aaron@zulip.com"), "letham")
with mock.patch("zproject.backends.logging.warning") as mock_warn:
self.assertEqual(
self.backend.django_to_ldap_username("shared_email@zulip.com"),
"shared_email@zulip.com"
)
mock_warn.assert_called_with("Multiple users with email shared_email@zulip.com found in LDAP.")
# Configure email search for emails in the uid attribute:
with self.settings(AUTH_LDAP_REVERSE_EMAIL_SEARCH=LDAPSearch("ou=users,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(uid=%(email)s)")):
self.assertEqual(self.backend.django_to_ldap_username("newuser_email_as_uid@zulip.com"),
"newuser_email_as_uid@zulip.com")
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.EmailAuthBackend',
'zproject.backends.ZulipLDAPAuthBackend',))
def test_authenticate_to_ldap_via_email(self) -> None:
"""
With AUTH_LDAP_REVERSE_EMAIL_SEARCH configured, django_to_ldap_username
should be able to translate an email to ldap username,
and thus it should be possible to authenticate through user_profile.email.
"""
realm = get_realm("zulip")
user_profile = self.example_user("hamlet")
password = "testpassword"
user_profile.set_password(password)
user_profile.save()
# Without email search, can't login via ldap:
with self.settings(AUTH_LDAP_REVERSE_EMAIL_SEARCH=None, LDAP_EMAIL_ATTR='mail'):
# Using hamlet's ldap password fails without email search:
self.assertEqual(authenticate(username=user_profile.email, password="testing", realm=realm),
None)
# Need hamlet's zulip password to login (via email backend)
self.assertEqual(authenticate(username=user_profile.email, password="testpassword", realm=realm),
user_profile)
# To login via ldap, username needs to be the ldap username, not email:
self.assertEqual(authenticate(username="hamlet", password="testing", realm=realm),
user_profile)
# With email search:
with self.settings(LDAP_EMAIL_ATTR='mail'):
# Ldap password works now:
self.assertEqual(authenticate(username=user_profile.email, password="testing", realm=realm),
user_profile)

View File

@ -328,6 +328,24 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
raise ZulipLDAPExceptionOutsideDomain("Email %s does not match LDAP domain %s." % (
username, settings.LDAP_APPEND_DOMAIN))
return email_to_username(username)
else:
return username
if settings.AUTH_LDAP_USERNAME_ATTR and settings.AUTH_LDAP_REVERSE_EMAIL_SEARCH:
# We can use find_ldap_users_by_email
if is_valid_email(username):
result = find_ldap_users_by_email(username)
if result is None:
return username
if len(result) == 1:
return result[0]._username
if len(result) > 1:
# This is possible, but strange, so worth logging a warning about.
# We can't translate the email to a unique username,
# so we don't do anything else here.
logging.warning("Multiple users with email {} found in LDAP.".format(username))
return username
return username
def ldap_to_django_username(self, username: str) -> str:
@ -492,6 +510,12 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
return None
try:
# We want to apss the user's LDAP username into
# authenticate() below. If an email address was entered
# in the login form, we need to use
# django_to_ldap_username to translate the email address
# to the user's LDAP username before calling the
# django-auth-ldap authenticate().
username = self.django_to_ldap_username(username)
except ZulipLDAPExceptionOutsideDomain:
if return_data is not None:

View File

@ -71,6 +71,10 @@ GOOGLE_OAUTH2_CLIENT_ID = "test_client_id"
AUTH_LDAP_ALWAYS_UPDATE_USER = False
AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=users,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL, "(uid=%(user)s)")
AUTH_LDAP_USERNAME_ATTR = "uid"
AUTH_LDAP_REVERSE_EMAIL_SEARCH = LDAPSearch("ou=users,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(mail=%(email)s)")
TEST_SUITE = True
RATE_LIMITING = False