Repository: pennersr/django-allauth
Pull request #3592 (2024-01-14 21:30:48)

Refactor rate limits
- Don't implicitly use user based rate limit
- Unified custom LOGIN_ATTEMPTS_LIMIT and email cooldown rate limits.
- Ability to specify multiple rate limits per action


coveralls: 
[![Coverage Status](https://coveralls.io/builds/65062495/badge)](https://coveralls.io/builds/65062495)

coverage: 95.847% (+0.003%) from 95.844%
when pulling **658963818d00a5c977a26bf447dc0b0c5d3bf47b on refactor-rate-limits**
into **fde1c28519472915da0ce92866f6212da636ed61 on main**.


Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 669a4811..4084a23e 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -24,6 +24,20 @@ Fixes
 - SAML: the login view did not obey the ``SOCIALACCOUNT_LOGIN_ON_GET = False`` setting.
 
 
+Backwards incompatible changes
+------------------------------
+
+- The rate limit mechanism has received an update. Previously, when specifying
+  e.g. ``"5/m"`` it was handled implicitly whether or not that limit was per IP,
+  per user, or per action specific key. This has now been made explicit:
+  ``"5/m/user"`` vs ``"5/m/ip"`` vs ``"5/m/key"``. Combinations are also supported
+  now: ``"20/m/ip,5/m/key"`` . Additionally, the rate limit mechanism is now used
+  throughout, including email confirmation cooldown as well as limitting failed login
+  attempts.  Therefore, the ``ACCOUNT_LOGIN_ATTEMPTS_LIMIT`` and
+  ``ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN`` settings are deprecated.
+  See :doc:`Rate Limits <../account/rate_limits>` for details.
+
+
 0.60.0 (2024-01-05)
 *******************
 
diff --git a/allauth/account/adapter.py b/allauth/account/adapter.py
index 9bc6011a..7ff297bf 100644
--- a/allauth/account/adapter.py
+++ b/allauth/account/adapter.py
@@ -1,7 +1,6 @@
 import html
 import json
 import warnings
-from datetime import timedelta
 from urllib.parse import urlparse
 
 from django import forms
@@ -595,22 +594,11 @@ class DefaultAccountAdapter(object):
         return ret
 
     def should_send_confirmation_mail(self, request, email_address, signup):
-        from allauth.account.models import EmailConfirmation
-
-        cooldown_period = timedelta(seconds=app_settings.EMAIL_CONFIRMATION_COOLDOWN)
-        if app_settings.EMAIL_CONFIRMATION_HMAC:
-            send_email = ratelimit.consume(
-                request,
-                action="confirm_email",
-                key=email_address.email.lower(),
-                amount=1,
-                duration=cooldown_period.total_seconds(),
-            )
-        else:
-            send_email = not EmailConfirmation.objects.filter(
-                sent__gt=timezone.now() - cooldown_period,
-                email_address=email_address,
-            ).exists()
+        send_email = ratelimit.consume(
+            request,
+            action="confirm_email",
+            key=email_address.email.lower(),
+        )
         return send_email
 
     def send_account_already_exists_mail(self, email):
@@ -650,23 +638,17 @@ class DefaultAccountAdapter(object):
         return "{site}:{login}".format(site=site.domain, login=login)
 
     def _delete_login_attempts_cached_email(self, request, **credentials):
-        if app_settings.LOGIN_ATTEMPTS_LIMIT:
-            cache_key = self._get_login_attempts_cache_key(request, **credentials)
-            ratelimit.clear(request, action="login_failed", key=cache_key)
+        cache_key = self._get_login_attempts_cache_key(request, **credentials)
+        ratelimit.clear(request, action="login_failed", key=cache_key)
 
     def pre_authenticate(self, request, **credentials):
-        if app_settings.LOGIN_ATTEMPTS_LIMIT:
-            cache_key = self._get_login_attempts_cache_key(request, **credentials)
-            if not ratelimit.consume(
-                request,
-                action="login_failed",
-                key=cache_key,
-                amount=app_settings.LOGIN_ATTEMPTS_LIMIT,
-                duration=app_settings.LOGIN_ATTEMPTS_TIMEOUT,
-            ):
-                raise forms.ValidationError(
-                    self.error_messages["too_many_login_attempts"]
-                )
+        cache_key = self._get_login_attempts_cache_key(request, **credentials)
+        if not ratelimit.consume(
+            request,
+            action="login_failed",
+            key=cache_key,
+        ):
+            raise forms.ValidationError(self.error_messages["too_many_login_attempts"])
 
     def authenticate(self, request, **credentials):
         """Only authenticates, does not actually login. See `login`"""
@@ -677,7 +659,7 @@ class DefaultAccountAdapter(object):
         user = authenticate(request, **credentials)
         alt_user = AuthenticationBackend.unstash_authenticated_user()
         user = user or alt_user
-        if user and app_settings.LOGIN_ATTEMPTS_LIMIT:
+        if user:
             self._delete_login_attempts_cached_email(request, **credentials)
         else:
             self.authentication_failed(request, **credentials)
diff --git a/allauth/account/app_settings.py b/allauth/account/app_settings.py
index 045d90cc..18ac6786 100644
--- a/allauth/account/app_settings.py
+++ b/allauth/account/app_settings.py
@@ -1,3 +1,5 @@
+import warnings
+
 from django.core.exceptions import ImproperlyConfigured
 
 
@@ -18,6 +20,8 @@ class AppSettings(object):
         NONE = "none"
 
     def __init__(self, prefix):
+        from django.conf import settings
+
         self.prefix = prefix
         # If login is by email, email must be required
         assert (
@@ -43,6 +47,17 @@ class AppSettings(object):
                 raise ImproperlyConfigured(
                     "Invalid combination of ACCOUNT_CHANGE_EMAIL and ACCOUNT_MAX_EMAIL_ADDRESSES"
                 )
+        if hasattr(settings, "ACCOUNT_LOGIN_ATTEMPTS_LIMIT") or hasattr(
+            settings, "ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT"
+        ):
+            warnings.warn(
+                "settings.ACCOUNT_LOGIN_ATTEMPTS_LIMIT/TIMEOUT is deprecated, use: settings.ACCOUNT_RATE_LIMITS['login_failed']"
+            )
+
+        if hasattr(settings, "ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN"):
+            warnings.warn(
+                "settings.ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN is deprecated, use: settings.ACCOUNT_RATE_LIMITS['confirm_email']"
+            )
 
     def _setting(self, name, dflt):
         from allauth.utils import get_setting
@@ -90,14 +105,6 @@ class AppSettings(object):
             "EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL", settings.LOGIN_URL
         )
 
-    @property
-    def EMAIL_CONFIRMATION_COOLDOWN(self):
-        """
-        The cooldown in seconds during which, after an email confirmation has
-        been sent, a second confirmation email will not be sent.
-        """
-        return self._setting("EMAIL_CONFIRMATION_COOLDOWN", 3 * 60)
-
     @property
     def EMAIL_REQUIRED(self):
         """
@@ -180,24 +187,40 @@ class AppSettings(object):
 
     @property
     def RATE_LIMITS(self):
-        dflt = {
+        rls = self._setting("RATE_LIMITS", {})
+        if rls is False:
+            return {}
+        attempts_amount = self._setting("LOGIN_ATTEMPTS_LIMIT", 5)
+        attempts_timeout = self._setting("LOGIN_ATTEMPTS_TIMEOUT", 60 * 5)
+        login_failed_rl = None
+        if attempts_amount and attempts_timeout:
+            login_failed_rl = f"10/m/ip,{attempts_amount}/{attempts_timeout}s/key"
+        cooldown = self._setting("EMAIL_CONFIRMATION_COOLDOWN", 3 * 60)
+        confirm_email_rl = None
+        if cooldown:
+            confirm_email_rl = f"1/{cooldown}s/key"
+        ret = {
             # Change password view (for users already logged in)
-            "change_password": "5/m",
+            "change_password": "5/m/user",
             # Email management (e.g. add, remove, change primary)
-            "manage_email": "10/m",
+            "manage_email": "10/m/user",
             # Request a password reset, global rate limit per IP
-            "reset_password": "20/m",
-            # Rate limit measured per individual email address
-            "reset_password_email": "5/m",
-            # Reauthentication for users already logged in)
-            "reauthenticate": "10/m",
+            "reset_password": "20/m/ip,5/m/key",
+            # Reauthentication for users already logged in
+            "reauthenticate": "10/m/user",
             # Password reset (the view the password reset email links to).
-            "reset_password_from_key": "20/m",
+            "reset_password_from_key": "20/m/ip",
             # Signups.
-            "signup": "20/m",
-            # NOTE: Login is already protected via `ACCOUNT_LOGIN_ATTEMPTS_LIMIT`
+            "signup": "20/m/ip",
+            # Logins.
+            "login": "30/m/ip",
+            # Logins.
+            "login_failed": login_failed_rl,
+            # Confirm email
+            "confirm_email": confirm_email_rl,
         }
-        return self._setting("RATE_LIMITS", dflt)
+        ret.update(rls)
+        return ret
 
     @property
     def EMAIL_SUBJECT_PREFIX(self):
@@ -319,24 +342,6 @@ class AppSettings(object):
     def FORMS(self):
         return self._setting("FORMS", {})
 
-    @property
-    def LOGIN_ATTEMPTS_LIMIT(self):
-        """
-        Number of failed login attempts. When this number is
-        exceeded, the user is prohibited from logging in for the
-        specified `LOGIN_ATTEMPTS_TIMEOUT`
-        """
-        return self._setting("LOGIN_ATTEMPTS_LIMIT", 5)
-
-    @property
-    def LOGIN_ATTEMPTS_TIMEOUT(self):
-        """
-        Time period from last unsuccessful login attempt, during
-        which the user is prohibited from trying to log in.  Defaults to
-        5 minutes.
-        """
-        return self._setting("LOGIN_ATTEMPTS_TIMEOUT", 60 * 5)
-
     @property
     def EMAIL_CONFIRMATION_HMAC(self):
         return self._setting("EMAIL_CONFIRMATION_HMAC", True)
diff --git a/allauth/account/views.py b/allauth/account/views.py
index 257720f5..8e4386a9 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -467,6 +467,7 @@ class ConfirmEmailView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
 confirm_email = ConfirmEmailView.as_view()
 
 
+@method_decorator(login_required, name="dispatch")
 @method_decorator(rate_limit(action="manage_email"), name="dispatch")
 @method_decorator(
     reauthentication_required(
@@ -663,9 +664,10 @@ class EmailView(AjaxCapableProcessFormViewMixin, FormView):
         return data
 
 
-email = login_required(EmailView.as_view())
+email = EmailView.as_view()
 
 
+@method_decorator(login_required, name="dispatch")
 @method_decorator(rate_limit(action="change_password"), name="dispatch")
 class PasswordChangeView(AjaxCapableProcessFormViewMixin, FormView):
     template_name = "account/password_change." + app_settings.TEMPLATE_EXTENSION
@@ -717,9 +719,10 @@ class PasswordChangeView(AjaxCapableProcessFormViewMixin, FormView):
         return ret
 
 
-password_change = login_required(PasswordChangeView.as_view())
+password_change = PasswordChangeView.as_view()
 
 
+@method_decorator(login_required, name="dispatch")
 @method_decorator(
     # NOTE: 'change_password' (iso 'set_') is intentional, there is no need to
     # differentiate between set and change.
@@ -773,10 +776,9 @@ class PasswordSetView(AjaxCapableProcessFormViewMixin, FormView):
         return ret
 
 
-password_set = login_required(PasswordSetView.as_view())
+password_set = PasswordSetView.as_view()
 
 
-@method_decorator(rate_limit(action="reset_password"), name="dispatch")
 class PasswordResetView(AjaxCapableProcessFormViewMixin, FormView):
     template_name = "account/password_reset." + app_settings.TEMPLATE_EXTENSION
     form_class = ResetPasswordForm
@@ -789,7 +791,7 @@ class PasswordResetView(AjaxCapableProcessFormViewMixin, FormView):
     def form_valid(self, form):
         r429 = ratelimit.consume_or_429(
             self.request,
-            action="reset_password_email",
+            action="reset_password",
             key=form.cleaned_data["email"].lower(),
         )
         if r429:
@@ -896,7 +898,7 @@ class PasswordResetFromKeyView(
         form.save()
         adapter = get_adapter(self.request)
 
-        if self.reset_user and app_settings.LOGIN_ATTEMPTS_LIMIT:
+        if self.reset_user:
             # User successfully reset the password, clear any
             # possible cache entries for all email addresses.
             for email in self.reset_user.emailaddress_set.all():
@@ -1065,6 +1067,7 @@ class BaseReauthenticateView(FormView):
         return alts
 
 
+@method_decorator(login_required, name="dispatch")
 class ReauthenticateView(BaseReauthenticateView):
     form_class = ReauthenticateForm
     template_name = "account/reauthenticate." + app_settings.TEMPLATE_EXTENSION
@@ -1078,4 +1081,4 @@ class ReauthenticateView(BaseReauthenticateView):
         return ret
 
 
-reauthenticate = login_required(ReauthenticateView.as_view())
+reauthenticate = ReauthenticateView.as_view()
diff --git a/allauth/core/ratelimit.py b/allauth/core/ratelimit.py
index 99f03940..9817f197 100644
--- a/allauth/core/ratelimit.py
+++ b/allauth/core/ratelimit.py
@@ -3,70 +3,115 @@ import time
 from collections import namedtuple
 
 from django.core.cache import cache
+from django.core.exceptions import ImproperlyConfigured
 from django.shortcuts import render
 
 
-Rate = namedtuple("Rate", "amount duration")
+Rate = namedtuple("Rate", "amount duration per")
 
 
-def parse(rate):
-    ret = None
-    if rate:
-        amount, duration = rate.split("/")
-        amount = int(amount)
-        duration_map = {"s": 1, "m": 60, "h": 3600, "d": 86400}
-        if duration not in duration_map:
-            raise ValueError("Invalid duration: %s" % duration)
-        duration = duration_map[duration]
-        ret = Rate(amount, duration)
+def _parse_duration(duration):
+    if len(duration) == 0:
+        raise ValueError(duration)
+    unit = duration[-1]
+    value = duration[0:-1]
+    unit_map = {"s": 1, "m": 60, "h": 3600, "d": 86400}
+    if unit not in unit_map:
+        raise ValueError("Invalid duration unit: %s" % unit)
+    if len(value) == 0:
+        value = 1
+    else:
+        value = float(value)
+    return value * unit_map[unit]
+
+
+def _parse_rate(rate):
+    parts = rate.split("/")
+    if len(parts) == 2:
+        amount, duration = parts
+        per = "ip"
+    elif len(parts) == 3:
+        amount, duration, per = parts
+    else:
+        raise ValueError(rate)
+    amount = int(amount)
+    duration = _parse_duration(duration)
+    return Rate(amount, duration, per)
+
+
+def _parse_rates(rates):
+    ret = []
+    if rates:
+        rates = rates.strip()
+        if rates:
+            parts = rates.split(",")
+            for part in parts:
+                ret.append(_parse_rate(part.strip()))
     return ret
 
 
-def _cache_key(request, *, action, key=None, user=None):
+def _cache_key(request, *, action, rate, key=None, user=None):
     from allauth.account.adapter import get_adapter
 
-    if key:
-        source = ()
-    elif user or request.user.is_authenticated:
-        source = ("user", str((user or request.user).pk))
-    else:
+    if rate.per == "ip":
         source = ("ip", get_adapter().get_client_ip(request))
-    keys = ["allauth", "rl", action, *source]
-    if key is not None:
+    elif rate.per == "user":
+        if user is None:
+            if not request.user.is_authenticated:
+                raise ImproperlyConfigured(
+                    "ratelimit configured per user but used anonymously"
+                )
+            user = request.user
+        source = ("user", str(user.pk))
+    elif rate.per == "key":
+        if key is None:
+            raise ImproperlyConfigured(
+                "ratelimit configured per key but no key specified"
+            )
         key_hash = hashlib.sha256(key.encode("utf8")).hexdigest()
-        keys.append(key_hash)
+        source = (key_hash,)
+    else:
+        raise ValueError(rate.per)
+    keys = ["allauth", "rl", action, *source]
     return ":".join(keys)
 
 
 def clear(request, *, action, key=None, user=None):
-    cache_key = _cache_key(request, action=action, key=key, user=user)
-    cache.delete(cache_key)
+    from allauth.account import app_settings
 
+    rates = _parse_rates(app_settings.RATE_LIMITS.get(action))
+    for rate in rates:
+        cache_key = _cache_key(request, action=action, rate=rate, key=key, user=user)
+        cache.delete(cache_key)
 
-def consume(request, *, action, key=None, amount=None, duration=None, user=None):
-    allowed = True
+
+def consume(request, *, action, key=None, user=None):
     from allauth.account import app_settings
 
-    rate = app_settings.RATE_LIMITS.get(action)
-    if rate:
-        rate = parse(rate)
-        if not amount:
-            amount = rate.amount
-        if not duration:
-            duration = rate.duration
+    if not request or request.method == "GET":
+        return True
 
-    if not request or request.method == "GET" or not amount or not duration:
-        pass
-    else:
-        cache_key = _cache_key(request, action=action, key=key, user=user)
-        history = cache.get(cache_key, [])
-        now = time.time()
-        while history and history[-1] <= now - duration:
-            history.pop()
-        allowed = len(history) < amount
-        if allowed:
-            history.insert(0, now)
-            cache.set(cache_key, history, duration)
+    rates = _parse_rates(app_settings.RATE_LIMITS.get(action))
+    if not rates:
+        return True
+
+    allowed = True
+    for rate in rates:
+        if not _consume_rate(request, action=action, rate=rate, key=key, user=user):
+            allowed = False
+    return allowed
+
+
+def _consume_rate(request, *, action, rate, key=None, user=None):
+    cache_key = _cache_key(request, action=action, rate=rate, key=key, user=user)
+    history = cache.get(cache_key, [])
+    now = time.time()
+    while history and history[-1] <= now - rate.duration:
+        history.pop()
+    allowed = len(history) < rate.amount
+    if allowed:
+        history.insert(0, now)
+        cache.set(cache_key, history, rate.duration)
     return allowed
 
 
diff --git a/allauth/decorators.py b/allauth/decorators.py
index 768ed004..fc0be185 100644
--- a/allauth/decorators.py
+++ b/allauth/decorators.py
@@ -4,14 +4,6 @@ from allauth.core import ratelimit
 
 
 def rate_limit(*, action, **rl_kwargs):
-    from allauth.account import app_settings
-
-    rate = app_settings.RATE_LIMITS.get(action)
-    if rate:
-        rate = ratelimit.parse(rate)
-        rl_kwargs.setdefault("duration", rate.duration)
-        rl_kwargs.setdefault("amount", rate.amount)
-
     def decorator(function):
         @wraps(function)
         def wrap(request, *args, **kwargs):
diff --git a/allauth/mfa/forms.py b/allauth/mfa/forms.py
index 02f40004..0ebc57ab 100644
--- a/allauth/mfa/forms.py
+++ b/allauth/mfa/forms.py
@@ -1,7 +1,6 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
-from allauth.account import app_settings as account_settings
 from allauth.account.adapter import get_adapter as get_account_adapter
 from allauth.account.models import EmailAddress
 from allauth.core import context, ratelimit
@@ -24,23 +23,21 @@ class AuthenticateForm(forms.Form):
         super().__init__(*args, **kwargs)
 
     def clean_code(self):
-        if account_settings.LOGIN_ATTEMPTS_LIMIT:
-            if not ratelimit.consume(
-                context.request,
-                action="login_failed",
-                user=self.user,
-                amount=account_settings.LOGIN_ATTEMPTS_LIMIT,
-                duration=account_settings.LOGIN_ATTEMPTS_TIMEOUT,
-            ):
-                raise forms.ValidationError(
-                    get_account_adapter().error_messages["too_many_login_attempts"]
-                )
+        key = f"mfa-auth-user-{str(self.user.pk)}"
+        if not ratelimit.consume(
+            context.request,
+            action="login_failed",
+            key=key,
+        ):
+            raise forms.ValidationError(
+                get_account_adapter().error_messages["too_many_login_attempts"]
+            )
 
         code = self.cleaned_data["code"]
         for auth in Authenticator.objects.filter(user=self.user):
             if auth.wrap().validate_code(code):
                 self.authenticator = auth
-                ratelimit.clear(context.request, action="login_failed", user=self.user)
+                ratelimit.clear(context.request, action="login_failed", key=key)
                 return code
         raise forms.ValidationError(get_adapter().error_messages["incorrect_code"])
 
diff --git a/docs/account/configuration.rst b/docs/account/configuration.rst
index de398ac2..86263baa 100644
--- a/docs/account/configuration.rst
+++ b/docs/account/configuration.rst
@@ -86,13 +86,6 @@ Available settings:
   password forgotten procedure. Note that this is a default only --
   see the section on HTTPS for more information.
 
-``ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN`` (default: ``180``)
-  Users can request email confirmation mails via the email management view, and,
-  implicitly, when logging in with an unverified account. In order to prevent
-  users from sending too many of these mails, a rate limit is in place that
-  allows for one confirmation mail to be sent per the specified cooldown period
-  (in seconds).
-
 ``ACCOUNT_EMAIL_MAX_LENGTH`` (default: ``254``)
   Maximum length of the email field. You won't need to alter this unless using
   MySQL with the InnoDB storage engine and the ``utf8mb4`` charset, and only in
@@ -127,18 +120,6 @@ Available settings:
         'user_token': 'allauth.account.forms.UserTokenForm',
     }
 
-``ACCOUNT_LOGIN_ATTEMPTS_LIMIT`` (default: ``5``)
-  Number of failed login attempts. When this number is
-  exceeded, the user is prohibited from logging in for the
-  specified ``ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT`` seconds. Set to ``None``
-  to disable this functionality. Important: while this protects the
-  allauth login view, it does not protect Django's admin login from
-  being brute forced.
-
-``ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT`` (default: ``300``)
-  Time period, in seconds, from last unsuccessful login attempt, during
-  which the user is prohibited from trying to log in.
-
 ``ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION`` (default: ``False``)
   The default behaviour is not log users in and to redirect them to
   ``ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL``.
@@ -205,28 +186,9 @@ Available settings:
   email address will be avoided, thus leaking information. Set it to
   ``"strict"`` to allow for signups to go through.
 
-``ACCOUNT_RATE_LIMITS``
-  In order to be secure out of the box various rate limits are in place. The
-  rate limit mechanism is backed by a Django cache. Hence, rate limiting will
-  not work properly if you are using the `DummyCache`. To disable, set to
-  ``{}``. When rate limits are hit the ``429.html`` template is rendered.
-  Defaults to::
-
-    ACCOUNT_RATE_LIMITS = {
-        # Change password view (for users already logged in)
-        "change_password": "5/m",
-        # Email management (e.g. add, remove, change primary)
-        "manage_email": "10/m",
-        # Request a password reset, global rate limit per IP
-        "reset_password": "20/m",
-        # Rate limit measured per individual email address
-        "reset_password_email": "5/m",
-        # Password reset (the view the password reset email links to).
-        "reset_password_from_key": "20/m",
-        # Signups.
-        "signup": "20/m",
-        # NOTE: Login is already protected via `ACCOUNT_LOGIN_ATTEMPTS_LIMIT`
-    }
+``ACCOUNT_RATE_LIMITS`` (default: ``{...}``)
+  In order to be secure out of the box various rate limits are in place.
+  See :doc:`Rate Limits <./rate_limits>` for details.
 
 ``ACCOUNT_REAUTHENTICATION_TIMEOUT`` (default: ``300``)
   Before asking the user to reauthenticate, we check if a successful
diff --git a/docs/account/index.rst b/docs/account/index.rst
index 482496b9..9017c775 100644
--- a/docs/account/index.rst
+++ b/docs/account/index.rst
@@ -5,6 +5,7 @@ Regular Accounts
 
    introduction
    configuration
+   rate_limits
    views
    templates
    forms
diff --git a/docs/account/rate_limits.rst b/docs/account/rate_limits.rst
new file mode 100644
index 00000000..a0435540
--- /dev/null
+++ b/docs/account/rate_limits.rst
@@ -0,0 +1,59 @@
+Rate Limits
+===========
+
+In order to be secure out of the box various rate limits are in place. The rate
+limit mechanism is backed by a Django cache. Hence, rate limiting will not work
+properly if you are using the `DummyCache`. When rate limits are hit the
+``429.html`` template is rendered.
+
+Rate limits are consumed by triggering actions, the full list of which is
+documented below.  Per action, the rate can be configured. The rate itself is an
+amount, per time unit, per either IP address, user or action-specific key.
+
+For example, requesting a password reset is an action that is both limited
+globally by IP address, as well as per email. Here, the email address used is
+the specific key.
+
+The rate limits are configured through the ``ACCOUNT_RATE_LIMITS`` setting:
+
+- Set it to ``False`` to disable all rate limits.
+
+- Set it to a dictionary, e.g. ``{"action": "your-rate-limit", ...}`` to use the
+  default configuration but with your specific actions overriden.
+
+
+The following actions are available for configuration:
+
+``"change_password"`` (default: ``"5/m/user"``)
+  Changing the password (for already authenticated users).
+
+``"manage_email"`` (default: ``"10/m/user"``)
+  Email management related actions, such as add, remove, change primary.
+
+``"reset_password"`` (default: ``"20/m/ip,5/m/key"``)
+  Requesting a password reset. The email for which the password is to be reset is
+  passed as the key.
+
+``"reauthenticate"`` (default: ``"10/m/user"``)
+  Reauthentication (for users already logged in).
+
+``"reset_password_from_key"`` (default: ``"20/m/ip"``)
+  Password reset (the view the password reset email links to).
+
+``"signup"`` (default: ``"20/m/ip"``)
+  Signups.
+
+``"login"`` (default: ``"30/m/ip"``)
+  Logins.
+
+``"login_failed"`` (default: ``"10/m/ip,5/5m/key"``)
+  Restricts the allowed number of failed login attempts. When exceeded, the user
+  is prohibited from logging in for the remainder of the rate limit. Important:
+  while this protects the allauth login view, it does not protect Django's admin
+  login from being brute forced. Note that a successful login will clear this
+  rate limit.
+
+``"confirm_email"`` (default: ``"1/3m/key"``)
+  Users can request email confirmation mails via the email management view, and,
+  implicitly, when logging in with an unverified account. This rate limit
+  prevents users from sending too many of these mails.


Test patch:
diff --git a/allauth/account/tests/test_confirm_email.py b/allauth/account/tests/test_confirm_email.py
index 7929d927..bac3ffb1 100644
--- a/allauth/account/tests/test_confirm_email.py
+++ b/allauth/account/tests/test_confirm_email.py
@@ -3,6 +3,7 @@ from unittest.mock import Mock, patch
 
 from django.conf import settings
 from django.contrib.auth import SESSION_KEY, get_user_model
+from django.core.cache import cache
 from django.http import HttpResponseRedirect
 from django.urls import reverse
 from django.utils.timezone import now
@@ -101,7 +102,7 @@ def test_email_verification_failed(settings, user_factory, client):
     assertTemplateUsed(resp, "account/messages/email_confirmation_failed.txt")
 
 
-def test_email_verification_mandatory(settings, db, client, mailoutbox):
+def test_email_verification_mandatory(settings, db, client, mailoutbox, enable_cache):
     settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = False
     settings.ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = 10
     settings.ACCOUNT_EMAIL_VERIFICATION = app_settings.EmailVerificationMethod.MANDATORY
@@ -151,8 +152,8 @@ def test_email_verification_mandatory(settings, db, client, mailoutbox):
             ).count()
             == attempt
         )
-        # Wait for cooldown
-        EmailConfirmation.objects.update(sent=now() - timedelta(days=1))
+        # Wait for cooldown -- wipe cache (incl. rate limits)
+        cache.clear()
     # Verify, and re-attempt to login.
     confirmation = EmailConfirmation.objects.filter(
         email_address__user__username="johndoe"
diff --git a/allauth/account/tests/test_ratelimit.py b/allauth/account/tests/test_ratelimit.py
index 9863013e..7784ee18 100644
--- a/allauth/account/tests/test_ratelimit.py
+++ b/allauth/account/tests/test_ratelimit.py
@@ -1,26 +1,10 @@
-from django.contrib.auth import get_user_model
-from django.test.utils import override_settings
 from django.urls import reverse
 
-from allauth.tests import TestCase
 
-
-@override_settings(
-    CACHES={
-        "default": {
-            "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
-        },
-    },
-    ACCOUNT_RATE_LIMITS={"reset_password_email": "1/m"},
-)
-class RateLimitTests(TestCase):
-    def test_case_insensitive_password_reset(self):
-        get_user_model().objects.create(email="a@b.com")
-        resp = self.client.post(
-            reverse("account_reset_password"), data={"email": "a@b.com"}
-        )
-        assert resp.status_code == 302
-        resp = self.client.post(
-            reverse("account_reset_password"), data={"email": "A@B.COM"}
-        )
-        assert resp.status_code == 429
+def test_case_insensitive_password_reset(settings, enable_cache, user_factory, client):
+    settings.ACCOUNT_RATE_LIMITS = {"reset_password": "1/m"}
+    user_factory(email="a@b.com")
+    resp = client.post(reverse("account_reset_password"), data={"email": "a@b.com"})
+    assert resp.status_code == 302
+    resp = client.post(reverse("account_reset_password"), data={"email": "A@B.COM"})
+    assert resp.status_code == 429
diff --git a/allauth/conftest.py b/allauth/conftest.py
index a5d36bcb..faccc9f4 100644
--- a/allauth/conftest.py
+++ b/allauth/conftest.py
@@ -104,9 +104,12 @@ def clear_context_request():
 
 @pytest.fixture
 def enable_cache(settings):
+    from django.core.cache import cache
+
     settings.CACHES = {
         "default": {
             "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
         }
     }
+    cache.clear()
     yield
diff --git a/allauth/core/tests/__init__.py b/allauth/core/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/allauth/core/tests/test_ratelimit.py b/allauth/core/tests/test_ratelimit.py
new file mode 100644
index 00000000..3d9fd209
--- /dev/null
+++ b/allauth/core/tests/test_ratelimit.py
@@ -0,0 +1,23 @@
+import pytest
+
+from allauth.core import ratelimit
+
+
+@pytest.mark.parametrize(
+    "rate,values",
+    [
+        ("5/m", [(5, 60, "ip")]),
+        ("5/m/user", [(5, 60, "user")]),
+        ("2/3.5m/key", [(2, 210, "key")]),
+        ("3/5m/user,20/0.5m/ip", [(3, 300, "user"), (20, 30, "ip")]),
+        ("7/2h", [(7, 7200, "ip")]),
+        ("7/0.25d", [(7, 21600, "ip")]),
+    ],
+)
+def test_parse(rate, values):
+    rates = ratelimit._parse_rates(rate)
+    assert len(rates) == len(values)
+    for i, rate in enumerate(rates):
+        assert rate.amount == values[i][0]
+        assert rate.duration == values[i][1]
+        assert rate.per == values[i][2]
diff --git a/test_settings.py b/test_settings.py
index 1a36cd1a..c51cd4e7 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -219,7 +219,6 @@ PASSWORD_HASHERS = [
 
 
 ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = 0
-ACCOUNT_RATE_LIMITS = {}
 
 SOCIALACCOUNT_QUERY_EMAIL = True
 SOCIALACCOUNT_PROVIDERS = {