Skip to content

Commit f08c6fe

Browse files
committed
Merge branch '65.8.x'
2 parents f4b7311 + 7dceff9 commit f08c6fe

File tree

8 files changed

+280
-141
lines changed

8 files changed

+280
-141
lines changed

ChangeLog.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ Note worthy changes
99
Ninja.
1010

1111

12+
65.8.1 (2025-05-21)
13+
*******************
14+
15+
Fixes
16+
-----
17+
18+
- Fixed a compatibility issue with the newly released fido2 2.0.0 package.
19+
20+
21+
Security notice
22+
---------------
23+
24+
- After a successful login, the rate limits for that login were cleared,
25+
allowing a succesful login on a specific IP address to be used as a means to
26+
clear the login failed rate limit for that IP address. Fixed.
27+
28+
1229
65.8.0 (2025-05-08)
1330
*******************
1431

allauth/account/adapter.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333

3434
from allauth import app_settings as allauth_app_settings
3535
from allauth.account import app_settings, signals
36-
from allauth.core import context, ratelimit
36+
from allauth.core import context
37+
from allauth.core.internal import ratelimit
3738
from allauth.core.internal.adapter import BaseAdapter
3839
from allauth.core.internal.cryptokit import generate_user_code
3940
from allauth.core.internal.httpkit import (
@@ -675,15 +676,30 @@ def _get_login_attempts_cache_key(self, request, **credentials):
675676

676677
def _delete_login_attempts_cached_email(self, request, **credentials):
677678
cache_key = self._get_login_attempts_cache_key(request, **credentials)
678-
ratelimit.clear(request, action="login_failed", key=cache_key)
679+
# Here, we wipe the login failed rate limit, completely. This is safe,
680+
# as we only do this on a succesful password reset, which is rate limited
681+
# on itself (e.g. sending of email etc.).
682+
ratelimit.clear(
683+
request,
684+
config=app_settings.RATE_LIMITS,
685+
action="login_failed",
686+
key=cache_key,
687+
)
688+
689+
def _rollback_login_failed_rl_usage(self) -> None:
690+
usage = getattr(self, "_login_failed_rl_usage", None)
691+
if usage:
692+
usage.rollback()
679693

680694
def pre_authenticate(self, request, **credentials):
681695
cache_key = self._get_login_attempts_cache_key(request, **credentials)
682-
if not ratelimit.consume(
696+
self._login_failed_rl_usage = ratelimit.consume(
683697
request,
698+
config=app_settings.RATE_LIMITS,
684699
action="login_failed",
685700
key=cache_key,
686-
):
701+
)
702+
if not self._login_failed_rl_usage:
687703
raise self.validation_error("too_many_login_attempts")
688704

689705
def authenticate(self, request, **credentials):
@@ -696,7 +712,12 @@ def authenticate(self, request, **credentials):
696712
alt_user = AuthenticationBackend.unstash_authenticated_user()
697713
user = user or alt_user
698714
if user:
699-
self._delete_login_attempts_cached_email(request, **credentials)
715+
# On a succesful login, we cannot just wipe the login failed rate
716+
# limit. That consists of 2 parts, a per IP limit, and, a per
717+
# key(email) limit. Wiping it completely would allow an attacker to
718+
# insert periodic successful logins during a brute force
719+
# process. So instead, we are rolling back our consumption.
720+
self._rollback_login_failed_rl_usage()
700721
else:
701722
self.authentication_failed(request, **credentials)
702723
return user
@@ -731,7 +752,7 @@ def is_ajax(self, request):
731752
]
732753
)
733754

734-
def get_client_ip(self, request):
755+
def get_client_ip(self, request) -> str:
735756
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
736757
if x_forwarded_for:
737758
ip = x_forwarded_for.split(",")[0]

allauth/core/internal/ratelimit.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
Rate limiting in this implementation relies on a cache and uses non-atomic
3+
operations, making it vulnerable to race conditions. As a result, users may
4+
occasionally bypass the intended rate limit due to concurrent access. However,
5+
such race conditions are rare in practice. For example, if the limit is set to
6+
10 requests per minute and a large number of parallel processes attempt to test
7+
that limit, you may occasionally observe slight overruns—such as 11 or 12
8+
requests slipping through. Nevertheless, exceeding the limit by a large margin
9+
is highly unlikely due to the low probability of many processes entering the
10+
critical non-atomic code section simultaneously.
11+
"""
12+
13+
import hashlib
14+
import time
15+
from collections import namedtuple
16+
from dataclasses import dataclass
17+
from typing import Dict, List, Optional, Tuple, Union
18+
19+
from django.core.cache import cache
20+
from django.core.exceptions import ImproperlyConfigured
21+
from django.http import HttpRequest, HttpResponse
22+
from django.shortcuts import render
23+
24+
from allauth.core.exceptions import RateLimited
25+
26+
27+
Rate = namedtuple("Rate", "amount duration per")
28+
29+
30+
@dataclass
31+
class SingleRateLimitUsage:
32+
cache_key: str
33+
cache_duration: Union[float, int]
34+
timestamp: float
35+
36+
def rollback(self) -> None:
37+
history = cache.get(self.cache_key, [])
38+
history = [ts for ts in history if ts != self.timestamp]
39+
cache.set(self.cache_key, history, self.cache_duration)
40+
41+
42+
@dataclass
43+
class RateLimitUsage:
44+
usage: List[SingleRateLimitUsage]
45+
46+
def rollback(self) -> None:
47+
for usage in self.usage:
48+
usage.rollback()
49+
50+
51+
def parse_duration(duration) -> Union[int, float]:
52+
if len(duration) == 0:
53+
raise ValueError(duration)
54+
unit = duration[-1]
55+
value = duration[0:-1]
56+
unit_map = {"s": 1, "m": 60, "h": 3600, "d": 86400}
57+
if unit not in unit_map:
58+
raise ValueError("Invalid duration unit: %s" % unit)
59+
if len(value) == 0:
60+
value = 1
61+
else:
62+
value = float(value)
63+
return value * unit_map[unit]
64+
65+
66+
def parse_rate(rate: str) -> Rate:
67+
parts = rate.split("/")
68+
if len(parts) == 2:
69+
amount, duration = parts
70+
per = "ip"
71+
elif len(parts) == 3:
72+
amount, duration, per = parts
73+
else:
74+
raise ValueError(rate)
75+
amount_v = int(amount)
76+
duration_v = parse_duration(duration)
77+
return Rate(amount_v, duration_v, per)
78+
79+
80+
def parse_rates(rates: Optional[str]) -> List[Rate]:
81+
ret = []
82+
if rates:
83+
rates = rates.strip()
84+
if rates:
85+
parts = rates.split(",")
86+
for part in parts:
87+
ret.append(parse_rate(part.strip()))
88+
return ret
89+
90+
91+
def get_cache_key(request, *, action: str, rate: Rate, key=None, user=None):
92+
from allauth.account.adapter import get_adapter
93+
94+
source: Tuple[str, ...]
95+
if rate.per == "ip":
96+
source = ("ip", get_adapter().get_client_ip(request))
97+
elif rate.per == "user":
98+
if user is None:
99+
if not request.user.is_authenticated:
100+
raise ImproperlyConfigured(
101+
"ratelimit configured per user but used anonymously"
102+
)
103+
user = request.user
104+
source = ("user", str(user.pk))
105+
elif rate.per == "key":
106+
if key is None:
107+
raise ImproperlyConfigured(
108+
"ratelimit configured per key but no key specified"
109+
)
110+
key_hash = hashlib.sha256(key.encode("utf8")).hexdigest()
111+
source = (key_hash,)
112+
else:
113+
raise ValueError(rate.per)
114+
keys = ["allauth", "rl", action, *source]
115+
return ":".join(keys)
116+
117+
118+
def _consume_single_rate(
119+
request,
120+
*,
121+
action: str,
122+
rate: Rate,
123+
key=None,
124+
user=None,
125+
dry_run: bool = False,
126+
raise_exception: bool = False
127+
) -> Optional[SingleRateLimitUsage]:
128+
cache_key = get_cache_key(request, action=action, rate=rate, key=key, user=user)
129+
history = cache.get(cache_key, [])
130+
now = time.time()
131+
while history and history[-1] <= now - rate.duration:
132+
history.pop()
133+
allowed = len(history) < rate.amount
134+
if allowed:
135+
usage = SingleRateLimitUsage(
136+
cache_key=cache_key, timestamp=now, cache_duration=rate.duration
137+
)
138+
else:
139+
usage = None
140+
if allowed and not dry_run:
141+
history.insert(0, now)
142+
cache.set(cache_key, history, rate.duration)
143+
if not allowed and raise_exception:
144+
raise RateLimited
145+
return usage
146+
147+
148+
def consume(
149+
request: HttpRequest,
150+
*,
151+
action: str,
152+
config: Dict[str, str],
153+
key=None,
154+
user=None,
155+
dry_run: bool = False,
156+
raise_exception: bool = False
157+
) -> Optional[RateLimitUsage]:
158+
usage = RateLimitUsage(usage=[])
159+
if request.method == "GET":
160+
return usage
161+
rates = parse_rates(config.get(action))
162+
if not rates:
163+
return usage
164+
allowed = True
165+
for rate in rates:
166+
single_usage = _consume_single_rate(
167+
request,
168+
action=action,
169+
rate=rate,
170+
key=key,
171+
user=user,
172+
dry_run=dry_run,
173+
raise_exception=raise_exception,
174+
)
175+
if not single_usage:
176+
allowed = False
177+
break
178+
usage.usage.append(single_usage)
179+
return usage if allowed else None
180+
181+
182+
def handler429(request) -> HttpResponse:
183+
from allauth.account import app_settings
184+
185+
return render(request, "429." + app_settings.TEMPLATE_EXTENSION, status=429)
186+
187+
188+
def clear(request, *, config: dict, action: str, key=None, user=None):
189+
rates = parse_rates(config.get(action))
190+
for rate in rates:
191+
cache_key = get_cache_key(request, action=action, rate=rate, key=key, user=user)
192+
cache.delete(cache_key)

allauth/core/tests/test_ratelimit.py renamed to allauth/core/internal/tests/test_ratelimit.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import pytest
22

3-
from allauth.core import ratelimit
3+
from allauth.core.internal import ratelimit
4+
5+
6+
def test_rollback_consume(rf, enable_cache):
7+
def consume():
8+
request = rf.post("/")
9+
config = {"foo": "2/m/ip"}
10+
return ratelimit.consume(request, config=config, action="foo")
11+
12+
usage1 = consume()
13+
assert len(usage1.usage) > 0
14+
usage2 = consume()
15+
assert len(usage2.usage) > 0
16+
no_usage = consume()
17+
assert no_usage is None
18+
usage1.rollback()
19+
assert consume()
20+
assert not consume()
421

522

623
@pytest.mark.parametrize(
@@ -15,7 +32,7 @@
1532
],
1633
)
1734
def test_parse(rate, values):
18-
rates = ratelimit._parse_rates(rate)
35+
rates = ratelimit.parse_rates(rate)
1936
assert len(rates) == len(values)
2037
for i, rate in enumerate(rates):
2138
assert rate.amount == values[i][0]

0 commit comments

Comments
 (0)