Skip to content

Commit bad2fde

Browse files
Improvements
1 parent f999142 commit bad2fde

5 files changed

Lines changed: 70 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
authentication attempts consistently.
1313
- Add an `InvalidCredentialsError` exception. `AuthenticationHandler` implementations
1414
can raise `InvalidCredentialsError` when invalid credentials are provided, to
15-
enable automatic logging and, if desired, brute-force protection.
16-
- Integrate `RateLimiter` into `AuthenticationStrategy` with automatic tracking of
17-
failed authentication attempts and support for blocking excessive requests.
18-
- Add `RateLimiter` class that cam block authentication attempts after a configurable
15+
enable automatic logging and, if enabled, brute-force protection.
16+
- Add `RateLimiter` class that can block authentication attempts after a configurable
1917
threshold is exceeded. By default stores failed attempts in-memory. This feature is
2018
disabled unless a `key_getter` function is provided (a function to obtain a context
2119
key from the user-defined authentication context, like a client IP).
20+
- Integrate `RateLimiter` into `AuthenticationStrategy` with automatic tracking of
21+
failed authentication attempts and support for blocking excessive requests.
2222
- Add Python `3.14` and remove `3.9` from the build matrix.
23-
- Improve exceptions raised for invalid `JWTs` to include the source exception
24-
(`exc.__cause__`).
2523
- Drop support for Python `3.9` (it reached EOL in October 2025).
2624
- Add an optional dependency on `essentials`, to use its `Secret` class to handle
2725
secrets for JWT validation with symmetric encryption. This is useful to support
2826
rotating secrets by updating env variables.
27+
- Improve exceptions raised for invalid `JWTs` to include the source exception
28+
(`exc.__cause__`).
2929

3030
## [1.0.3] - 2025-10-04 :trident:
3131

guardpost/authentication.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from rodi import ContainerProtocol
99

1010
from guardpost.abc import BaseStrategy
11-
from guardpost.errors import RateLimitExceededError
1211
from guardpost.protection import InvalidCredentialsError, RateLimiter
1312

1413

@@ -115,10 +114,23 @@ def __init__(
115114
rate_limiter: Optional[RateLimiter] = None,
116115
logger: Optional[Logger] = None,
117116
):
117+
"""
118+
Initializes an AuthenticationStrategy instance.
119+
120+
Args:
121+
*handlers: One or more authentication handler instances or types to be used
122+
for authentication.
123+
container: Optional dependency injection container for resolving handler
124+
instances.
125+
rate_limiter: Optional RateLimiter to apply rate limiting to authentication
126+
attempts.
127+
logger: Optional logger instance for logging authentication events. If not
128+
provided, defaults to `logging.getLogger("guardpost")`
129+
"""
118130
super().__init__(container)
119131
self.handlers = list(handlers)
120-
self._rate_limiter = rate_limiter or RateLimiter()
121132
self._logger = logger or logging.getLogger("guardpost")
133+
self._rate_limiter = rate_limiter
122134

123135
def add(self, handler: AuthenticationHandlerConfType) -> "AuthenticationStrategy":
124136
self.handlers.append(handler)
@@ -159,15 +171,14 @@ async def authenticate(
159171
self, context: Any, authentication_schemes: Optional[Sequence[str]] = None
160172
) -> Optional[Identity]:
161173
"""
162-
Tries to obtain the user for a context, applying authentication rules.
174+
Tries to obtain the user for a context, applying authentication rules and
175+
optional rate limiting.
163176
"""
164177
if not context:
165178
raise ValueError("Missing context to evaluate authentication")
166179

167-
valid_context = await self._rate_limiter.allow_authentication_attempt(context)
168-
169-
if not valid_context:
170-
raise RateLimitExceededError()
180+
if self._rate_limiter:
181+
await self._rate_limiter.validate_authentication_attempt(context)
171182

172183
identity = None
173184
for handler in self._get_handlers_by_schemes(authentication_schemes, context):
@@ -182,9 +193,10 @@ async def authenticate(
182193
invalid_credentials_error.client_ip,
183194
handler.scheme,
184195
)
185-
await self._rate_limiter.store_authentication_failure(
186-
invalid_credentials_error
187-
)
196+
if self._rate_limiter:
197+
await self._rate_limiter.store_authentication_failure(
198+
invalid_credentials_error
199+
)
188200

189201
if identity:
190202
try:

guardpost/jwts/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from typing import Any, Dict, List, Optional, Protocol, Sequence, Union
33

44
import jwt
5-
from jwt.exceptions import InvalidIssuerError, InvalidTokenError
6-
75
from essentials.secrets import Secret
6+
from jwt.exceptions import InvalidTokenError
7+
88
from guardpost.errors import AuthException
99

1010
from ..jwks import JWK, JWKS, KeysProvider

guardpost/protection.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
This module provides classes to protect against brute-force attacks.
33
"""
44

5+
import logging
56
from abc import ABC, abstractmethod
67
from datetime import UTC, datetime
8+
from logging import Logger
79
from typing import Any, Callable, Optional, Sequence
810

9-
from guardpost.errors import InvalidCredentialsError
11+
from guardpost.errors import InvalidCredentialsError, RateLimitExceededError
1012

1113

1214
class FailedAuthenticationAttempts:
@@ -140,6 +142,7 @@ def __init__(
140142
block_time: int = 60,
141143
store: Optional[AuthenticationAttemptsStore] = None,
142144
trusted_keys: Optional[Sequence[str]] = None,
145+
logger: Optional[Logger] = None,
143146
) -> None:
144147
"""
145148
Initialize a RateLimiter instance for brute-force protection.
@@ -171,25 +174,50 @@ def __init__(
171174
max_entry_age=self._block_time + 5
172175
)
173176
self._key_getter = key_getter
177+
self._logger = logger or logging.getLogger("guardpost")
174178

175179
def get_context_key(self, context: Any) -> str:
176180
"""
177-
Extracts the rate limiting key from the authentication context.
178-
179-
Raises an AuthException if no key_getter is provided. Custom extractors
180-
should be used carefully as they may introduce security vulnerabilities.
181+
Obtains the rate limiting key from the authentication context.
182+
If a key_getter is not configured, rate limiting is disabled and
183+
returns an empty string.
181184
"""
182185
if not self._key_getter:
183186
return ""
184187
return self._key_getter(context)
185188

186-
async def allow_authentication_attempt(self, context: Any) -> bool:
189+
async def validate_authentication_attempt(self, context: Any) -> None:
190+
"""
191+
Checks if an authentication attempt should be allowed for the given context.
192+
It always returns true if a `key_getter` function is not configured (function to
193+
obtain a key from the user-defined authentication context).
194+
195+
Args:
196+
context: The authentication context (e.g., request object).
197+
198+
Raises:
199+
**RateLimitExceededError**: If the rate limit has been exceeded and the
200+
attempt is blocked.
201+
"""
202+
key = self.get_context_key(context)
203+
204+
valid_context = await self.allow_authentication_attempt(key)
205+
206+
if not valid_context:
207+
self._logger.warning(
208+
"Blocking authentication attempt for '%s' because of rate limiting.",
209+
key,
210+
)
211+
raise RateLimitExceededError()
212+
213+
async def allow_authentication_attempt(self, key: Any) -> bool:
187214
"""
188215
Determines if an authentication attempt should be allowed based on rate limiting
189216
rules. Returns True if the attempt should proceed, False if it should be
190-
blocked.
217+
blocked. Key can be a str or a context.
191218
"""
192-
key = self.get_context_key(context)
219+
if not isinstance(key, str):
220+
key = self.get_context_key(key)
193221

194222
if not key:
195223
# BF protection disabled for backward compatibility.
@@ -212,7 +240,9 @@ async def allow_authentication_attempt(self, context: Any) -> bool:
212240

213241
return True
214242

215-
async def store_authentication_failure(self, error: InvalidCredentialsError):
243+
async def store_authentication_failure(
244+
self, error: InvalidCredentialsError
245+
) -> FailedAuthenticationAttempts:
216246
"""
217247
Tracks information about a failed authentication attempt.
218248
"""
@@ -225,6 +255,7 @@ async def store_authentication_failure(self, error: InvalidCredentialsError):
225255
else:
226256
failed_attempt.increase_counter()
227257
await self._store.set_failed_attempts(failed_attempt)
258+
return failed_attempt
228259

229260

230261
class SelfCleaningInMemoryAuthenticationAttemptsStore(

tests/test_protection.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import warnings
21
from datetime import UTC, datetime, timedelta
32
from unittest.mock import AsyncMock
43

0 commit comments

Comments
 (0)