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.contrib.auth import authenticate
|
||||
from django.core import mail
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.test import override_settings
|
||||
from django_auth_ldap.backend import LDAPSearch, _LDAPUser
|
||||
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_hash import user_avatar_path
|
||||
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.validator import validate_login_email, \
|
||||
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.storage import static_path
|
||||
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.utils import generate_random_token
|
||||
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, \
|
||||
PopulateUserLDAPError, SAMLAuthBackend, saml_auth_enabled, email_belongs_to_ldap, \
|
||||
get_external_method_dicts, AzureADAuthBackend, check_password_strength, \
|
||||
ZulipLDAPUser
|
||||
ZulipLDAPUser, RateLimitedAuthenticationByUsername
|
||||
|
||||
from zerver.views.auth import (maybe_send_to_registration,
|
||||
store_login_data, LOGIN_TOKEN_LENGTH)
|
||||
|
@ -190,7 +193,8 @@ class AuthBackendTest(ZulipTestCase):
|
|||
mock.patch('zproject.backends.password_auth_enabled',
|
||||
return_value=True):
|
||||
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"),
|
||||
password=password,
|
||||
return_data=return_data)
|
||||
|
@ -198,20 +202,24 @@ class AuthBackendTest(ZulipTestCase):
|
|||
self.assertTrue(return_data['email_auth_disabled'])
|
||||
|
||||
self.verify_backend(EmailAuthBackend(),
|
||||
good_kwargs=dict(password=password,
|
||||
good_kwargs=dict(request=mock.MagicMock(),
|
||||
password=password,
|
||||
username=username,
|
||||
realm=get_realm('zulip'),
|
||||
return_data=dict()),
|
||||
bad_kwargs=dict(password=password,
|
||||
bad_kwargs=dict(request=mock.MagicMock(),
|
||||
password=password,
|
||||
username=username,
|
||||
realm=get_realm('zephyr'),
|
||||
return_data=dict()))
|
||||
self.verify_backend(EmailAuthBackend(),
|
||||
good_kwargs=dict(password=password,
|
||||
good_kwargs=dict(request=mock.MagicMock(),
|
||||
password=password,
|
||||
username=username,
|
||||
realm=get_realm('zulip'),
|
||||
return_data=dict()),
|
||||
bad_kwargs=dict(password=password,
|
||||
bad_kwargs=dict(request=mock.MagicMock(),
|
||||
password=password,
|
||||
username=username,
|
||||
realm=get_realm('zephyr'),
|
||||
return_data=dict()))
|
||||
|
@ -224,7 +232,8 @@ class AuthBackendTest(ZulipTestCase):
|
|||
|
||||
# First, verify authentication works with the a nonempty
|
||||
# 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,
|
||||
realm=get_realm("zulip")))
|
||||
|
||||
|
@ -237,7 +246,8 @@ class AuthBackendTest(ZulipTestCase):
|
|||
# by using Django's version of this method.
|
||||
super(UserProfile, user_profile).set_password(password)
|
||||
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,
|
||||
realm=get_realm("zulip")))
|
||||
|
||||
|
@ -248,7 +258,8 @@ class AuthBackendTest(ZulipTestCase):
|
|||
user_profile.save()
|
||||
# Verify if a realm has password auth disabled, correct password is rejected
|
||||
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,
|
||||
realm=get_realm("zulip")))
|
||||
|
||||
|
@ -308,20 +319,25 @@ class AuthBackendTest(ZulipTestCase):
|
|||
backend = ZulipLDAPAuthBackend()
|
||||
|
||||
# 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,
|
||||
bad_kwargs=dict(username=username,
|
||||
bad_kwargs=dict(request=mock.MagicMock(),
|
||||
username=username,
|
||||
password=password,
|
||||
realm=get_realm('zephyr')),
|
||||
good_kwargs=dict(username=username,
|
||||
good_kwargs=dict(request=mock.MagicMock(),
|
||||
username=username,
|
||||
password=password,
|
||||
realm=get_realm('zulip')))
|
||||
self.verify_backend(backend,
|
||||
bad_kwargs=dict(username=username,
|
||||
bad_kwargs=dict(request=mock.MagicMock(),
|
||||
username=username,
|
||||
password=password,
|
||||
realm=get_realm('zephyr')),
|
||||
good_kwargs=dict(username=username,
|
||||
good_kwargs=dict(request=mock.MagicMock(),
|
||||
username=username,
|
||||
password=password,
|
||||
realm=get_realm('zulip')))
|
||||
|
||||
|
@ -459,6 +475,115 @@ class AuthBackendTest(ZulipTestCase):
|
|||
backend.authenticate = orig_authenticate
|
||||
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):
|
||||
def test_check_password_strength(self) -> None:
|
||||
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'):
|
||||
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)
|
||||
|
||||
@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_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'))
|
||||
|
||||
assert(user_profile is None)
|
||||
|
@ -2785,7 +2912,9 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_success(self) -> None:
|
||||
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'))
|
||||
|
||||
assert(user_profile is not None)
|
||||
|
@ -2794,7 +2923,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_success_with_username(self) -> None:
|
||||
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'))
|
||||
|
||||
assert(user_profile is not None)
|
||||
|
@ -2803,7 +2933,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_success_with_email_attr(self) -> None:
|
||||
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(),
|
||||
realm=get_realm('zulip'))
|
||||
|
||||
|
@ -2822,7 +2953,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
):
|
||||
realm = get_realm('zulip')
|
||||
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(),
|
||||
realm=realm)
|
||||
self.assertEqual(user_profile, self.example_user("aaron"))
|
||||
|
@ -2833,21 +2965,24 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
othello.save()
|
||||
|
||||
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)
|
||||
self.assertEqual(user_profile, othello)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_failure_due_to_wrong_password(self) -> None:
|
||||
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'))
|
||||
self.assertIs(user, None)
|
||||
|
||||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_failure_due_to_nonexistent_user(self) -> None:
|
||||
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'))
|
||||
self.assertIs(user, None)
|
||||
|
||||
|
@ -2974,7 +3109,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_failure_when_domain_does_not_match(self) -> None:
|
||||
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(),
|
||||
realm=get_realm('zulip'))
|
||||
self.assertIs(user_profile, None)
|
||||
|
@ -2987,7 +3123,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
with self.settings(
|
||||
LDAP_APPEND_DOMAIN='zulip.com',
|
||||
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(),
|
||||
realm=get_realm('acme'))
|
||||
self.assertEqual(user_profile.email, self.example_email('hamlet'))
|
||||
|
@ -2995,7 +3132,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
@override_settings(AUTHENTICATION_BACKENDS=('zproject.backends.ZulipLDAPAuthBackend',))
|
||||
def test_login_success_with_valid_subdomain(self) -> None:
|
||||
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(),
|
||||
realm=get_realm('zulip'))
|
||||
assert(user_profile is not None)
|
||||
|
@ -3006,7 +3144,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
user_profile = self.example_user("hamlet")
|
||||
do_deactivate_user(user_profile)
|
||||
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(),
|
||||
realm=get_realm('zulip'))
|
||||
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:
|
||||
RealmDomain.objects.create(realm=self.backend._realm, 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(),
|
||||
realm=get_realm('zulip'))
|
||||
assert(user_profile is not None)
|
||||
|
@ -3037,7 +3177,8 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||
with self.settings(
|
||||
LDAP_APPEND_DOMAIN='zulip.com',
|
||||
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(),
|
||||
realm=get_realm('zulip'))
|
||||
assert(user_profile is not None)
|
||||
|
|
|
@ -16,19 +16,21 @@ import copy
|
|||
import logging
|
||||
import magic
|
||||
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 zxcvbn import zxcvbn
|
||||
|
||||
from django_auth_ldap.backend import LDAPBackend, LDAPReverseEmailSearch, \
|
||||
_LDAPUser, ldap_error
|
||||
from decorator import decorator
|
||||
|
||||
from django.contrib.auth import get_backends
|
||||
from django.contrib.auth.backends import RemoteUserBackend
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
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.urls import reverse
|
||||
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.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, \
|
||||
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_hash import user_avatar_content_hash
|
||||
from zerver.lib.dev_ldap_directory import init_fakeldap
|
||||
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.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
|
||||
|
@ -164,6 +168,60 @@ def common_get_active_user(email: str, realm: Realm,
|
|||
|
||||
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:
|
||||
"""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
|
||||
|
@ -219,7 +277,8 @@ class EmailAuthBackend(ZulipAuthMixin):
|
|||
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,
|
||||
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
||||
""" Authenticate a user based on email address as the user name. """
|
||||
|
@ -528,7 +587,8 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
|
|||
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
||||
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,
|
||||
return_data: Optional[Dict[str, Any]]=None) -> Optional[UserProfile]:
|
||||
self._realm = realm
|
||||
|
|
|
@ -134,6 +134,7 @@ PUSH_NOTIFICATION_BOUNCER_URL = None # type: Optional[str]
|
|||
PUSH_NOTIFICATION_REDACT_CONTENT = False
|
||||
SUBMIT_USAGE_STATISTICS = True
|
||||
RATE_LIMITING = True
|
||||
RATE_LIMITING_AUTHENTICATE = True
|
||||
SEND_LOGIN_EMAILS = True
|
||||
EMBEDDED_BOTS_ENABLED = False
|
||||
|
||||
|
|
|
@ -357,6 +357,9 @@ RATE_LIMITING_RULES = {
|
|||
'all': [
|
||||
(60, 200), # 200 requests max every minute
|
||||
],
|
||||
'authenticate': [
|
||||
(1800, 5), # 5 login attempts within 30 minutes
|
||||
],
|
||||
}
|
||||
|
||||
RATE_LIMITING_MIRROR_REALM_RULES = [
|
||||
|
|
|
@ -81,6 +81,7 @@ AUTH_LDAP_REVERSE_EMAIL_SEARCH = LDAPSearch("ou=users,dc=zulip,dc=com",
|
|||
|
||||
TEST_SUITE = True
|
||||
RATE_LIMITING = False
|
||||
RATE_LIMITING_AUTHENTICATE = False
|
||||
# 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
|
||||
# real app.
|
||||
|
|
Loading…
Reference in New Issue