mirror of https://github.com/zulip/zulip.git
auth: Rate limit username+password authenticate() calls.
This applies rate limiting (through a decorator) of authenticate() functions in the Email and LDAP backends - because those are the ones where we check user's password. The limiting is based on the username that the authentication is attempted for - more than X attempts in Y minutes to a username is not permitted. If the limit is exceeded, RateLimited exception will be raised - this can be either handled in a custom way by the code that calls authenticate(), or it will be handled by RateLimitMiddleware and return a json_error as the response.
This commit is contained in:
parent
335b804510
commit
5f94ea3d54
|
@ -2,7 +2,7 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, HttpRequest
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django_auth_ldap.backend import LDAPSearch, _LDAPUser
|
from django_auth_ldap.backend import LDAPSearch, _LDAPUser
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
|
@ -33,12 +33,15 @@ from zerver.lib.actions import (
|
||||||
from zerver.lib.avatar import avatar_url
|
from zerver.lib.avatar import avatar_url
|
||||||
from zerver.lib.avatar_hash import user_avatar_path
|
from zerver.lib.avatar_hash import user_avatar_path
|
||||||
from zerver.lib.dev_ldap_directory import generate_dev_ldap_dir
|
from zerver.lib.dev_ldap_directory import generate_dev_ldap_dir
|
||||||
|
from zerver.lib.exceptions import RateLimited
|
||||||
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_list, check_string, Validator
|
check_bool, check_dict_only, check_list, check_string, Validator
|
||||||
|
from zerver.lib.rate_limiter import add_ratelimit_rule, remove_ratelimit_rule, clear_history
|
||||||
from zerver.lib.request import JsonableError
|
from zerver.lib.request import JsonableError
|
||||||
from zerver.lib.storage import static_path
|
from zerver.lib.storage import static_path
|
||||||
from zerver.lib.users import get_all_api_keys
|
from zerver.lib.users import get_all_api_keys
|
||||||
|
from zerver.lib.utils import generate_random_token
|
||||||
from zerver.lib.upload import resize_avatar, MEDIUM_AVATAR_SIZE
|
from zerver.lib.upload import resize_avatar, MEDIUM_AVATAR_SIZE
|
||||||
from zerver.lib.utils import generate_random_token
|
from zerver.lib.utils import generate_random_token
|
||||||
from zerver.lib.initial_password import initial_password
|
from zerver.lib.initial_password import initial_password
|
||||||
|
@ -62,7 +65,7 @@ from zproject.backends import ZulipDummyBackend, EmailAuthBackend, \
|
||||||
ZulipLDAPException, query_ldap, sync_user_from_ldap, SocialAuthMixin, \
|
ZulipLDAPException, query_ldap, sync_user_from_ldap, SocialAuthMixin, \
|
||||||
PopulateUserLDAPError, SAMLAuthBackend, saml_auth_enabled, email_belongs_to_ldap, \
|
PopulateUserLDAPError, SAMLAuthBackend, saml_auth_enabled, email_belongs_to_ldap, \
|
||||||
get_external_method_dicts, AzureADAuthBackend, check_password_strength, \
|
get_external_method_dicts, AzureADAuthBackend, check_password_strength, \
|
||||||
ZulipLDAPUser
|
ZulipLDAPUser, RateLimitedAuthenticationByUsername
|
||||||
|
|
||||||
from zerver.views.auth import (maybe_send_to_registration,
|
from zerver.views.auth import (maybe_send_to_registration,
|
||||||
store_login_data, LOGIN_TOKEN_LENGTH)
|
store_login_data, LOGIN_TOKEN_LENGTH)
|
||||||
|
@ -190,7 +193,8 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
mock.patch('zproject.backends.password_auth_enabled',
|
mock.patch('zproject.backends.password_auth_enabled',
|
||||||
return_value=True):
|
return_value=True):
|
||||||
return_data = {} # type: Dict[str, bool]
|
return_data = {} # type: Dict[str, bool]
|
||||||
user = EmailAuthBackend().authenticate(username=self.example_email('hamlet'),
|
user = EmailAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email('hamlet'),
|
||||||
realm=get_realm("zulip"),
|
realm=get_realm("zulip"),
|
||||||
password=password,
|
password=password,
|
||||||
return_data=return_data)
|
return_data=return_data)
|
||||||
|
@ -198,20 +202,24 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
self.assertTrue(return_data['email_auth_disabled'])
|
self.assertTrue(return_data['email_auth_disabled'])
|
||||||
|
|
||||||
self.verify_backend(EmailAuthBackend(),
|
self.verify_backend(EmailAuthBackend(),
|
||||||
good_kwargs=dict(password=password,
|
good_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
password=password,
|
||||||
username=username,
|
username=username,
|
||||||
realm=get_realm('zulip'),
|
realm=get_realm('zulip'),
|
||||||
return_data=dict()),
|
return_data=dict()),
|
||||||
bad_kwargs=dict(password=password,
|
bad_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
password=password,
|
||||||
username=username,
|
username=username,
|
||||||
realm=get_realm('zephyr'),
|
realm=get_realm('zephyr'),
|
||||||
return_data=dict()))
|
return_data=dict()))
|
||||||
self.verify_backend(EmailAuthBackend(),
|
self.verify_backend(EmailAuthBackend(),
|
||||||
good_kwargs=dict(password=password,
|
good_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
password=password,
|
||||||
username=username,
|
username=username,
|
||||||
realm=get_realm('zulip'),
|
realm=get_realm('zulip'),
|
||||||
return_data=dict()),
|
return_data=dict()),
|
||||||
bad_kwargs=dict(password=password,
|
bad_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
password=password,
|
||||||
username=username,
|
username=username,
|
||||||
realm=get_realm('zephyr'),
|
realm=get_realm('zephyr'),
|
||||||
return_data=dict()))
|
return_data=dict()))
|
||||||
|
@ -224,7 +232,8 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
|
|
||||||
# First, verify authentication works with the a nonempty
|
# First, verify authentication works with the a nonempty
|
||||||
# password so we know we've set up the test correctly.
|
# password so we know we've set up the test correctly.
|
||||||
self.assertIsNotNone(EmailAuthBackend().authenticate(username=self.example_email('hamlet'),
|
self.assertIsNotNone(EmailAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email('hamlet'),
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm("zulip")))
|
realm=get_realm("zulip")))
|
||||||
|
|
||||||
|
@ -237,7 +246,8 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
# by using Django's version of this method.
|
# by using Django's version of this method.
|
||||||
super(UserProfile, user_profile).set_password(password)
|
super(UserProfile, user_profile).set_password(password)
|
||||||
user_profile.save()
|
user_profile.save()
|
||||||
self.assertIsNone(EmailAuthBackend().authenticate(username=self.example_email('hamlet'),
|
self.assertIsNone(EmailAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email('hamlet'),
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm("zulip")))
|
realm=get_realm("zulip")))
|
||||||
|
|
||||||
|
@ -248,7 +258,8 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
user_profile.save()
|
user_profile.save()
|
||||||
# Verify if a realm has password auth disabled, correct password is rejected
|
# Verify if a realm has password auth disabled, correct password is rejected
|
||||||
with mock.patch('zproject.backends.password_auth_enabled', return_value=False):
|
with mock.patch('zproject.backends.password_auth_enabled', return_value=False):
|
||||||
self.assertIsNone(EmailAuthBackend().authenticate(username=self.example_email('hamlet'),
|
self.assertIsNone(EmailAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email('hamlet'),
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm("zulip")))
|
realm=get_realm("zulip")))
|
||||||
|
|
||||||
|
@ -308,20 +319,25 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
backend = ZulipLDAPAuthBackend()
|
backend = ZulipLDAPAuthBackend()
|
||||||
|
|
||||||
# Test LDAP auth fails when LDAP server rejects password
|
# Test LDAP auth fails when LDAP server rejects password
|
||||||
self.assertIsNone(backend.authenticate(username=email, password="wrongpass", realm=get_realm("zulip")))
|
self.assertIsNone(backend.authenticate(request=mock.MagicMock(), username=email,
|
||||||
|
password="wrongpass", realm=get_realm("zulip")))
|
||||||
|
|
||||||
self.verify_backend(backend,
|
self.verify_backend(backend,
|
||||||
bad_kwargs=dict(username=username,
|
bad_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm('zephyr')),
|
realm=get_realm('zephyr')),
|
||||||
good_kwargs=dict(username=username,
|
good_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm('zulip')))
|
realm=get_realm('zulip')))
|
||||||
self.verify_backend(backend,
|
self.verify_backend(backend,
|
||||||
bad_kwargs=dict(username=username,
|
bad_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm('zephyr')),
|
realm=get_realm('zephyr')),
|
||||||
good_kwargs=dict(username=username,
|
good_kwargs=dict(request=mock.MagicMock(),
|
||||||
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
realm=get_realm('zulip')))
|
realm=get_realm('zulip')))
|
||||||
|
|
||||||
|
@ -459,6 +475,115 @@ class AuthBackendTest(ZulipTestCase):
|
||||||
backend.authenticate = orig_authenticate
|
backend.authenticate = orig_authenticate
|
||||||
backend.get_verified_emails = orig_get_verified_emails
|
backend.get_verified_emails = orig_get_verified_emails
|
||||||
|
|
||||||
|
class RateLimitAuthenticationTests(ZulipTestCase):
|
||||||
|
@override_settings(RATE_LIMITING_AUTHENTICATE=True)
|
||||||
|
def do_test_auth_rate_limiting(self,
|
||||||
|
attempt_authentication_func: Callable[[HttpRequest, str, str],
|
||||||
|
Optional[UserProfile]],
|
||||||
|
username: str, correct_password: str, wrong_password: str,
|
||||||
|
expected_user_profile: UserProfile) -> None:
|
||||||
|
# We have to mock RateLimitedAuthenticationByUsername.key_fragment to avoid key collisions
|
||||||
|
# if tests run in parallel.
|
||||||
|
original_key_fragment_method = RateLimitedAuthenticationByUsername.key_fragment
|
||||||
|
salt = generate_random_token(32)
|
||||||
|
|
||||||
|
def _mock_key_fragment(self: RateLimitedAuthenticationByUsername) -> str:
|
||||||
|
return "{}:{}".format(salt, original_key_fragment_method(self))
|
||||||
|
|
||||||
|
def attempt_authentication(username: str, password: str) -> Optional[UserProfile]:
|
||||||
|
request = HttpRequest()
|
||||||
|
return attempt_authentication_func(request, username, password)
|
||||||
|
|
||||||
|
add_ratelimit_rule(10, 2, domain='authenticate')
|
||||||
|
with mock.patch.object(RateLimitedAuthenticationByUsername, 'key_fragment', new=_mock_key_fragment):
|
||||||
|
try:
|
||||||
|
start_time = time.time()
|
||||||
|
with mock.patch('time.time', return_value=start_time):
|
||||||
|
self.assertIsNone(attempt_authentication(username, wrong_password))
|
||||||
|
self.assertIsNone(attempt_authentication(username, wrong_password))
|
||||||
|
# 2 failed attempts is the limit, so the next ones should get blocked,
|
||||||
|
# even with the correct password.
|
||||||
|
with self.assertRaises(RateLimited):
|
||||||
|
attempt_authentication(username, correct_password)
|
||||||
|
with self.assertRaises(RateLimited):
|
||||||
|
attempt_authentication(username, wrong_password)
|
||||||
|
|
||||||
|
# After enough time passes, more authentication attempts can be made:
|
||||||
|
with mock.patch('time.time', return_value=start_time + 11.0):
|
||||||
|
self.assertIsNone(attempt_authentication(username, wrong_password))
|
||||||
|
|
||||||
|
self.assertEqual(attempt_authentication(username, correct_password), expected_user_profile) # Correct password
|
||||||
|
# A correct login attempt should reset the rate limits for this user profile,
|
||||||
|
# so the next two attempts shouldn't get limited:
|
||||||
|
self.assertIsNone(attempt_authentication(username, wrong_password))
|
||||||
|
self.assertIsNone(attempt_authentication(username, wrong_password))
|
||||||
|
# But the third attempt goes over the limit:
|
||||||
|
with self.assertRaises(RateLimited):
|
||||||
|
attempt_authentication(username, wrong_password)
|
||||||
|
finally:
|
||||||
|
# Clean up to avoid affecting other tests.
|
||||||
|
clear_history(RateLimitedAuthenticationByUsername(username))
|
||||||
|
remove_ratelimit_rule(10, 2, domain='authenticate')
|
||||||
|
|
||||||
|
def test_email_auth_backend_user_based_rate_limiting(self) -> None:
|
||||||
|
user_profile = self.example_user('hamlet')
|
||||||
|
password = "testpassword"
|
||||||
|
user_profile.set_password(password)
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
def attempt_authentication(request: HttpRequest, username: str, password: str) -> Optional[UserProfile]:
|
||||||
|
return EmailAuthBackend().authenticate(request=request,
|
||||||
|
username=username,
|
||||||
|
realm=get_realm("zulip"),
|
||||||
|
password=password,
|
||||||
|
return_data=dict())
|
||||||
|
|
||||||
|
self.do_test_auth_rate_limiting(attempt_authentication, user_profile.email, password, 'wrong_password',
|
||||||
|
user_profile)
|
||||||
|
|
||||||
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',),
|
||||||
|
LDAP_EMAIL_ATTR="mail")
|
||||||
|
def test_ldap_backend_user_based_rate_limiting(self) -> None:
|
||||||
|
self.init_default_ldap_database()
|
||||||
|
user_profile = self.example_user('hamlet')
|
||||||
|
password = self.ldap_password()
|
||||||
|
|
||||||
|
def attempt_authentication(request: HttpRequest, username: str, password: str) -> Optional[UserProfile]:
|
||||||
|
return ZulipLDAPAuthBackend().authenticate(request=request,
|
||||||
|
username=username,
|
||||||
|
realm=get_realm("zulip"),
|
||||||
|
password=password,
|
||||||
|
return_data=dict())
|
||||||
|
|
||||||
|
self.do_test_auth_rate_limiting(attempt_authentication, user_profile.email, password, 'wrong_password',
|
||||||
|
user_profile)
|
||||||
|
|
||||||
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.EmailAuthBackend',
|
||||||
|
'zproject.backends.ZulipLDAPAuthBackend'),
|
||||||
|
LDAP_EMAIL_ATTR="mail")
|
||||||
|
def test_email_and_ldap_backends_user_based_rate_limiting(self) -> None:
|
||||||
|
self.init_default_ldap_database()
|
||||||
|
user_profile = self.example_user('hamlet')
|
||||||
|
ldap_password = self.ldap_password()
|
||||||
|
|
||||||
|
email_password = "email_password"
|
||||||
|
user_profile.set_password(email_password)
|
||||||
|
user_profile.save()
|
||||||
|
|
||||||
|
def attempt_authentication(request: HttpRequest, username: str, password: str) -> Optional[UserProfile]:
|
||||||
|
return authenticate(request=request,
|
||||||
|
username=username,
|
||||||
|
realm=get_realm("zulip"),
|
||||||
|
password=password,
|
||||||
|
return_data=dict())
|
||||||
|
|
||||||
|
self.do_test_auth_rate_limiting(attempt_authentication, user_profile.email,
|
||||||
|
email_password, 'wrong_password',
|
||||||
|
user_profile)
|
||||||
|
self.do_test_auth_rate_limiting(attempt_authentication, user_profile.email,
|
||||||
|
ldap_password, 'wrong_password',
|
||||||
|
user_profile)
|
||||||
|
|
||||||
class CheckPasswordStrengthTest(ZulipTestCase):
|
class CheckPasswordStrengthTest(ZulipTestCase):
|
||||||
def test_check_password_strength(self) -> None:
|
def test_check_password_strength(self) -> None:
|
||||||
with self.settings(PASSWORD_MIN_LENGTH=0, PASSWORD_MIN_GUESSES=0):
|
with self.settings(PASSWORD_MIN_LENGTH=0, PASSWORD_MIN_GUESSES=0):
|
||||||
|
@ -2698,7 +2823,8 @@ class DjangoToLDAPUsernameTests(ZulipTestCase):
|
||||||
|
|
||||||
with self.settings(LDAP_EMAIL_ATTR='mail'):
|
with self.settings(LDAP_EMAIL_ATTR='mail'):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
authenticate(username=user_profile.email, password=self.ldap_password(), realm=realm),
|
authenticate(request=mock.MagicMock(), username=user_profile.email,
|
||||||
|
password=self.ldap_password(), realm=realm),
|
||||||
user_profile)
|
user_profile)
|
||||||
|
|
||||||
@override_settings(LDAP_EMAIL_ATTR='mail', LDAP_DEACTIVATE_NON_MATCHING_USERS=True)
|
@override_settings(LDAP_EMAIL_ATTR='mail', LDAP_DEACTIVATE_NON_MATCHING_USERS=True)
|
||||||
|
@ -2777,7 +2903,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
ldap.SCOPE_ONELEVEL, "(email=%(email)s)"),
|
ldap.SCOPE_ONELEVEL, "(email=%(email)s)"),
|
||||||
LDAP_APPEND_DOMAIN='zulip.com'
|
LDAP_APPEND_DOMAIN='zulip.com'
|
||||||
):
|
):
|
||||||
user_profile = self.backend.authenticate(username='ldapuser1', password='dapu',
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username='ldapuser1', password='dapu',
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
|
|
||||||
assert(user_profile is None)
|
assert(user_profile is None)
|
||||||
|
@ -2785,7 +2912,9 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_success(self) -> None:
|
def test_login_success(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user_profile = self.backend.authenticate(username=self.example_email("hamlet"), password=self.ldap_password(),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"),
|
||||||
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
|
|
||||||
assert(user_profile is not None)
|
assert(user_profile is not None)
|
||||||
|
@ -2794,7 +2923,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_success_with_username(self) -> None:
|
def test_login_success_with_username(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user_profile = self.backend.authenticate(username="hamlet", password=self.ldap_password(),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username="hamlet", password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
|
|
||||||
assert(user_profile is not None)
|
assert(user_profile is not None)
|
||||||
|
@ -2803,7 +2933,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_success_with_email_attr(self) -> None:
|
def test_login_success_with_email_attr(self) -> None:
|
||||||
with self.settings(LDAP_EMAIL_ATTR='mail'):
|
with self.settings(LDAP_EMAIL_ATTR='mail'):
|
||||||
user_profile = self.backend.authenticate(username=self.ldap_username("aaron"),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.ldap_username("aaron"),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
|
|
||||||
|
@ -2822,7 +2953,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
):
|
):
|
||||||
realm = get_realm('zulip')
|
realm = get_realm('zulip')
|
||||||
self.assertEqual(email_belongs_to_ldap(realm, self.example_email("aaron")), True)
|
self.assertEqual(email_belongs_to_ldap(realm, self.example_email("aaron")), True)
|
||||||
user_profile = ZulipLDAPAuthBackend().authenticate(username=self.ldap_username("aaron"),
|
user_profile = ZulipLDAPAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.ldap_username("aaron"),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=realm)
|
realm=realm)
|
||||||
self.assertEqual(user_profile, self.example_user("aaron"))
|
self.assertEqual(user_profile, self.example_user("aaron"))
|
||||||
|
@ -2833,21 +2965,24 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
othello.save()
|
othello.save()
|
||||||
|
|
||||||
self.assertEqual(email_belongs_to_ldap(realm, othello.email), False)
|
self.assertEqual(email_belongs_to_ldap(realm, othello.email), False)
|
||||||
user_profile = EmailAuthBackend().authenticate(username=othello.email, password=password,
|
user_profile = EmailAuthBackend().authenticate(request=mock.MagicMock(),
|
||||||
|
username=othello.email, password=password,
|
||||||
realm=realm)
|
realm=realm)
|
||||||
self.assertEqual(user_profile, othello)
|
self.assertEqual(user_profile, othello)
|
||||||
|
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_failure_due_to_wrong_password(self) -> None:
|
def test_login_failure_due_to_wrong_password(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user = self.backend.authenticate(username=self.example_email("hamlet"), password='wrong',
|
user = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"), password='wrong',
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
self.assertIs(user, None)
|
self.assertIs(user, None)
|
||||||
|
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_failure_due_to_nonexistent_user(self) -> None:
|
def test_login_failure_due_to_nonexistent_user(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user = self.backend.authenticate(username='nonexistent@zulip.com', password=self.ldap_password(),
|
user = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username='nonexistent@zulip.com', password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
self.assertIs(user, None)
|
self.assertIs(user, None)
|
||||||
|
|
||||||
|
@ -2974,7 +3109,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_failure_when_domain_does_not_match(self) -> None:
|
def test_login_failure_when_domain_does_not_match(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='acme.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='acme.com'):
|
||||||
user_profile = self.backend.authenticate(username=self.example_email("hamlet"),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
self.assertIs(user_profile, None)
|
self.assertIs(user_profile, None)
|
||||||
|
@ -2987,7 +3123,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
with self.settings(
|
with self.settings(
|
||||||
LDAP_APPEND_DOMAIN='zulip.com',
|
LDAP_APPEND_DOMAIN='zulip.com',
|
||||||
AUTH_LDAP_USER_ATTR_MAP=ldap_user_attr_map):
|
AUTH_LDAP_USER_ATTR_MAP=ldap_user_attr_map):
|
||||||
user_profile = self.backend.authenticate(username=self.example_email('hamlet'),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email('hamlet'),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('acme'))
|
realm=get_realm('acme'))
|
||||||
self.assertEqual(user_profile.email, self.example_email('hamlet'))
|
self.assertEqual(user_profile.email, self.example_email('hamlet'))
|
||||||
|
@ -2995,7 +3132,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||||
def test_login_success_with_valid_subdomain(self) -> None:
|
def test_login_success_with_valid_subdomain(self) -> None:
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user_profile = self.backend.authenticate(username=self.example_email("hamlet"),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
assert(user_profile is not None)
|
assert(user_profile is not None)
|
||||||
|
@ -3006,7 +3144,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
user_profile = self.example_user("hamlet")
|
user_profile = self.example_user("hamlet")
|
||||||
do_deactivate_user(user_profile)
|
do_deactivate_user(user_profile)
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='zulip.com'):
|
||||||
user_profile = self.backend.authenticate(username=self.example_email("hamlet"),
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"),
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
self.assertIs(user_profile, None)
|
self.assertIs(user_profile, None)
|
||||||
|
@ -3019,7 +3158,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
def test_login_success_when_user_does_not_exist_with_valid_subdomain(self) -> None:
|
def test_login_success_when_user_does_not_exist_with_valid_subdomain(self) -> None:
|
||||||
RealmDomain.objects.create(realm=self.backend._realm, domain='acme.com')
|
RealmDomain.objects.create(realm=self.backend._realm, domain='acme.com')
|
||||||
with self.settings(LDAP_APPEND_DOMAIN='acme.com'):
|
with self.settings(LDAP_APPEND_DOMAIN='acme.com'):
|
||||||
user_profile = self.backend.authenticate(username='newuser@acme.com',
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username='newuser@acme.com',
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
assert(user_profile is not None)
|
assert(user_profile is not None)
|
||||||
|
@ -3037,7 +3177,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
||||||
with self.settings(
|
with self.settings(
|
||||||
LDAP_APPEND_DOMAIN='zulip.com',
|
LDAP_APPEND_DOMAIN='zulip.com',
|
||||||
AUTH_LDAP_USER_ATTR_MAP={'first_name': 'sn', 'last_name': 'cn'}):
|
AUTH_LDAP_USER_ATTR_MAP={'first_name': 'sn', 'last_name': 'cn'}):
|
||||||
user_profile = self.backend.authenticate(username='newuser_splitname@zulip.com',
|
user_profile = self.backend.authenticate(request=mock.MagicMock(),
|
||||||
|
username='newuser_splitname@zulip.com',
|
||||||
password=self.ldap_password(),
|
password=self.ldap_password(),
|
||||||
realm=get_realm('zulip'))
|
realm=get_realm('zulip'))
|
||||||
assert(user_profile is not None)
|
assert(user_profile is not None)
|
||||||
|
|
|
@ -16,19 +16,21 @@ import copy
|
||||||
import logging
|
import logging
|
||||||
import magic
|
import magic
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
from zxcvbn import zxcvbn
|
from zxcvbn import zxcvbn
|
||||||
|
|
||||||
from django_auth_ldap.backend import LDAPBackend, LDAPReverseEmailSearch, \
|
from django_auth_ldap.backend import LDAPBackend, LDAPReverseEmailSearch, \
|
||||||
_LDAPUser, ldap_error
|
_LDAPUser, ldap_error
|
||||||
|
from decorator import decorator
|
||||||
|
|
||||||
from django.contrib.auth import get_backends
|
from django.contrib.auth import get_backends
|
||||||
from django.contrib.auth.backends import RemoteUserBackend
|
from django.contrib.auth.backends import RemoteUserBackend
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.dispatch import receiver, Signal
|
from django.dispatch import receiver, Signal
|
||||||
from django.http import HttpResponse, HttpResponseRedirect
|
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -43,12 +45,14 @@ from social_core.backends.saml import SAMLAuth
|
||||||
from social_core.pipeline.partial import partial
|
from social_core.pipeline.partial import partial
|
||||||
from social_core.exceptions import AuthFailed, SocialAuthBaseException
|
from social_core.exceptions import AuthFailed, SocialAuthBaseException
|
||||||
|
|
||||||
|
from zerver.decorator import client_is_exempt_from_rate_limiting
|
||||||
from zerver.lib.actions import do_create_user, do_reactivate_user, do_deactivate_user, \
|
from zerver.lib.actions import do_create_user, do_reactivate_user, do_deactivate_user, \
|
||||||
do_update_user_custom_profile_data_if_changed, validate_email_for_realm
|
do_update_user_custom_profile_data_if_changed, validate_email_for_realm
|
||||||
from zerver.lib.avatar import is_avatar_new, avatar_url
|
from zerver.lib.avatar import is_avatar_new, avatar_url
|
||||||
from zerver.lib.avatar_hash import user_avatar_content_hash
|
from zerver.lib.avatar_hash import user_avatar_content_hash
|
||||||
from zerver.lib.dev_ldap_directory import init_fakeldap
|
from zerver.lib.dev_ldap_directory import init_fakeldap
|
||||||
from zerver.lib.mobile_auth_otp import is_valid_otp
|
from zerver.lib.mobile_auth_otp import is_valid_otp
|
||||||
|
from zerver.lib.rate_limiter import clear_history, rate_limit_request_by_entity, RateLimitedObject
|
||||||
from zerver.lib.request import JsonableError
|
from zerver.lib.request import JsonableError
|
||||||
from zerver.lib.users import check_full_name, validate_user_custom_profile_field
|
from zerver.lib.users import check_full_name, validate_user_custom_profile_field
|
||||||
from zerver.lib.redis_utils import get_redis_client, get_dict_from_redis, put_dict_in_redis
|
from zerver.lib.redis_utils import get_redis_client, get_dict_from_redis, put_dict_in_redis
|
||||||
|
@ -164,6 +168,60 @@ def common_get_active_user(email: str, realm: Realm,
|
||||||
|
|
||||||
return user_profile
|
return user_profile
|
||||||
|
|
||||||
|
AuthFuncT = TypeVar('AuthFuncT', bound=Callable[..., Optional[UserProfile]])
|
||||||
|
rate_limiting_rules = settings.RATE_LIMITING_RULES['authenticate']
|
||||||
|
|
||||||
|
class RateLimitedAuthenticationByUsername(RateLimitedObject):
|
||||||
|
def __init__(self, username: str) -> None:
|
||||||
|
self.username = username
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Username: {}".format(self.username)
|
||||||
|
|
||||||
|
def key_fragment(self) -> str:
|
||||||
|
return "{}:{}".format(type(self), self.username)
|
||||||
|
|
||||||
|
def rules(self) -> List[Tuple[int, int]]:
|
||||||
|
return rate_limiting_rules
|
||||||
|
|
||||||
|
def rate_limit_authentication_by_username(request: HttpRequest, username: str) -> None:
|
||||||
|
entity = RateLimitedAuthenticationByUsername(username)
|
||||||
|
rate_limit_request_by_entity(request, entity)
|
||||||
|
|
||||||
|
def auth_rate_limiting_already_applied(request: HttpRequest) -> bool:
|
||||||
|
return hasattr(request, '_ratelimit') and 'RateLimitedAuthenticationByUsername' in request._ratelimit
|
||||||
|
|
||||||
|
# Django's authentication mechanism uses introspection on the various authenticate() functions
|
||||||
|
# defined by backends, so we need a decorator that doesn't break function signatures.
|
||||||
|
# @decorator does this for us.
|
||||||
|
# The usual @wraps from functools breaks signatures, so it can't be used here.
|
||||||
|
@decorator
|
||||||
|
def rate_limit_auth(auth_func: AuthFuncT, *args: Any, **kwargs: Any) -> Optional[UserProfile]:
|
||||||
|
if not settings.RATE_LIMITING_AUTHENTICATE:
|
||||||
|
return auth_func(*args, **kwargs)
|
||||||
|
|
||||||
|
request = kwargs['request']
|
||||||
|
username = kwargs['username']
|
||||||
|
if not hasattr(request, 'client') or not client_is_exempt_from_rate_limiting(request):
|
||||||
|
# Django cycles through enabled authentication backends until one succeeds,
|
||||||
|
# or all of them fail. If multiple backends are tried like this, we only want
|
||||||
|
# to execute rate_limit_authentication_* once, on the first attempt:
|
||||||
|
if auth_rate_limiting_already_applied(request):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Apply rate limiting. If this request is above the limit,
|
||||||
|
# RateLimited will be raised, interrupting the authentication process.
|
||||||
|
# From there, the code calling authenticate() can either catch the exception
|
||||||
|
# and handle it on its own, or it will be processed by RateLimitMiddleware.
|
||||||
|
rate_limit_authentication_by_username(request, username)
|
||||||
|
|
||||||
|
result = auth_func(*args, **kwargs)
|
||||||
|
if result is not None:
|
||||||
|
# Authentication succeeded, clear the rate-limiting record.
|
||||||
|
clear_history(RateLimitedAuthenticationByUsername(username))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
class ZulipAuthMixin:
|
class ZulipAuthMixin:
|
||||||
"""This common mixin is used to override Django's default behavior for
|
"""This common mixin is used to override Django's default behavior for
|
||||||
looking up a logged-in user by ID to use a version that fetches
|
looking up a logged-in user by ID to use a version that fetches
|
||||||
|
@ -219,7 +277,8 @@ class EmailAuthBackend(ZulipAuthMixin):
|
||||||
Allows a user to sign in using an email/password pair.
|
Allows a user to sign in using an email/password pair.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def authenticate(self, *, username: str, password: str,
|
@rate_limit_auth
|
||||||
|
def authenticate(self, *, request: HttpRequest, username: str, password: str,
|
||||||
realm: Realm,
|
realm: Realm,
|
||||||
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
||||||
""" Authenticate a user based on email address as the user name. """
|
""" Authenticate a user based on email address as the user name. """
|
||||||
|
@ -528,7 +587,8 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
|
||||||
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
||||||
REALM_IS_NONE_ERROR = 1
|
REALM_IS_NONE_ERROR = 1
|
||||||
|
|
||||||
def authenticate(self, *, username: str, password: str, realm: Realm,
|
@rate_limit_auth
|
||||||
|
def authenticate(self, *, request: HttpRequest, username: str, password: str, realm: Realm,
|
||||||
prereg_user: Optional[PreregistrationUser]=None,
|
prereg_user: Optional[PreregistrationUser]=None,
|
||||||
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
||||||
self._realm = realm
|
self._realm = realm
|
||||||
|
|
|
@ -134,6 +134,7 @@ PUSH_NOTIFICATION_BOUNCER_URL = None # type: Optional[str]
|
||||||
PUSH_NOTIFICATION_REDACT_CONTENT = False
|
PUSH_NOTIFICATION_REDACT_CONTENT = False
|
||||||
SUBMIT_USAGE_STATISTICS = True
|
SUBMIT_USAGE_STATISTICS = True
|
||||||
RATE_LIMITING = True
|
RATE_LIMITING = True
|
||||||
|
RATE_LIMITING_AUTHENTICATE = True
|
||||||
SEND_LOGIN_EMAILS = True
|
SEND_LOGIN_EMAILS = True
|
||||||
EMBEDDED_BOTS_ENABLED = False
|
EMBEDDED_BOTS_ENABLED = False
|
||||||
|
|
||||||
|
|
|
@ -357,6 +357,9 @@ RATE_LIMITING_RULES = {
|
||||||
'all': [
|
'all': [
|
||||||
(60, 200), # 200 requests max every minute
|
(60, 200), # 200 requests max every minute
|
||||||
],
|
],
|
||||||
|
'authenticate': [
|
||||||
|
(1800, 5), # 5 login attempts within 30 minutes
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
RATE_LIMITING_MIRROR_REALM_RULES = [
|
RATE_LIMITING_MIRROR_REALM_RULES = [
|
||||||
|
|
|
@ -81,6 +81,7 @@ AUTH_LDAP_REVERSE_EMAIL_SEARCH = LDAPSearch("ou=users,dc=zulip,dc=com",
|
||||||
|
|
||||||
TEST_SUITE = True
|
TEST_SUITE = True
|
||||||
RATE_LIMITING = False
|
RATE_LIMITING = False
|
||||||
|
RATE_LIMITING_AUTHENTICATE = False
|
||||||
# Don't use rabbitmq from the test suite -- the user_profile_ids for
|
# Don't use rabbitmq from the test suite -- the user_profile_ids for
|
||||||
# any generated queue elements won't match those being used by the
|
# any generated queue elements won't match those being used by the
|
||||||
# real app.
|
# real app.
|
||||||
|
|
Loading…
Reference in New Issue