Skip to content

Commit e37e00c

Browse files
Add built-in strategy for rate limiting (#21)
1 parent f40be78 commit e37e00c

File tree

16 files changed

+982
-78
lines changed

16 files changed

+982
-78
lines changed

.github/workflows/build.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
strategy:
2121
fail-fast: false
2222
matrix:
23-
python-version: [3.9, "3.10", "3.11", "3.12", "3.13"]
23+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
2424

2525
steps:
2626
- uses: actions/checkout@v4
@@ -81,18 +81,18 @@ jobs:
8181
8282
- name: Install distribution dependencies
8383
run: pip install --upgrade build
84-
if: matrix.python-version == 3.12
84+
if: matrix.python-version == 3.13
8585

8686
- name: Create distribution package
8787
run: python -m build
88-
if: matrix.python-version == 3.12
88+
if: matrix.python-version == 3.13
8989

9090
- name: Upload distribution package
9191
uses: actions/upload-artifact@v4
9292
with:
9393
name: dist
9494
path: dist
95-
if: matrix.python-version == 3.12
95+
if: matrix.python-version == 3.13
9696

9797
publish:
9898
runs-on: ubuntu-latest
@@ -105,10 +105,10 @@ jobs:
105105
name: dist
106106
path: dist
107107

108-
- name: Use Python 3.12
108+
- name: Use Python 3.13
109109
uses: actions/setup-python@v3
110110
with:
111-
python-version: "3.12"
111+
python-version: "3.13"
112112

113113
- name: Install dependencies
114114
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ __pycache__
99
.mypy_cache
1010
junit
1111
coverage.xml
12+
.local

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.4] - 2025-10-18 :beers:
9+
10+
- Add a `guardpost.protection` namespace with classes offering a strategy for
11+
brute-force protection against authentication attempts, and to log all failed
12+
authentication attempts consistently.
13+
- Add an `InvalidCredentialsError` exception. `AuthenticationHandler` implementations
14+
can raise `InvalidCredentialsError` when invalid credentials are provided, to
15+
enable automatic logging and, if enabled, brute-force protection.
16+
- Add `RateLimiter` class that can block authentication attempts after a configurable
17+
threshold is exceeded. By default stores failed attempts in-memory.
18+
- Integrate `RateLimiter` into `AuthenticationStrategy` with automatic tracking of
19+
failed authentication attempts and support for blocking excessive requests.
20+
- Add Python `3.14` and remove `3.9` from the build matrix.
21+
- Drop support for Python `3.9` (it reached EOL in October 2025).
22+
- Add an optional dependency on `essentials`, to use its `Secret` class to handle
23+
secrets for JWT validation with symmetric encryption. This is useful to support
24+
rotating secrets by updating env variables.
25+
- Improve exceptions raised for invalid `JWTs` to include the source exception
26+
(`exc.__cause__`).
27+
828
## [1.0.3] - 2025-10-04 :trident:
929

1030
- Add a `roles` property to the `Identity` object.

guardpost.code-workspace

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
"editor.rulers": [
2222
88,
2323
100
24-
]
25-
}
24+
],
25+
"editor.defaultFormatter": "ms-python.black-formatter"
26+
},
27+
"python.formatting.provider": "none",
28+
"makefile.configureOnOpen": false
2629
}
2730
}

guardpost/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.3"
1+
__version__ = "1.0.4"

guardpost/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,28 @@
1111
AuthorizationContext,
1212
AuthorizationError,
1313
AuthorizationStrategy,
14+
ForbiddenError,
1415
Policy,
1516
PolicyNotFoundError,
1617
Requirement,
1718
RequirementConfType,
1819
RolesRequirement,
1920
UnauthorizedError,
2021
)
22+
from .errors import (
23+
AuthException,
24+
InvalidCredentialsError,
25+
RateLimitExceededError,
26+
)
27+
from .protection import (
28+
AuthenticationAttemptsStore,
29+
FailedAuthenticationAttempts,
30+
InMemoryAuthenticationAttemptsStore,
31+
RateLimiter,
32+
)
2133

2234
__all__ = [
35+
"AuthException",
2336
"AuthenticationHandlerConfType",
2437
"AuthenticationSchemesNotFound",
2538
"AuthorizationConfigurationError",
@@ -35,5 +48,12 @@
3548
"RequirementConfType",
3649
"RolesRequirement",
3750
"UnauthorizedError",
51+
"ForbiddenError",
3852
"AuthorizationContext",
53+
"RateLimiter",
54+
"AuthenticationAttemptsStore",
55+
"InMemoryAuthenticationAttemptsStore",
56+
"FailedAuthenticationAttempts",
57+
"InvalidCredentialsError",
58+
"RateLimitExceededError",
3959
]

guardpost/authentication.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import inspect
2+
import logging
23
from abc import ABC, abstractmethod
34
from functools import lru_cache
5+
from logging import Logger
46
from typing import Any, List, Optional, Sequence, Type, Union
57

68
from rodi import ContainerProtocol
79

810
from guardpost.abc import BaseStrategy
11+
from guardpost.protection import InvalidCredentialsError, RateLimiter
912

1013

1114
class Identity:
@@ -108,9 +111,26 @@ def __init__(
108111
self,
109112
*handlers: AuthenticationHandlerConfType,
110113
container: Optional[ContainerProtocol] = None,
114+
rate_limiter: Optional[RateLimiter] = None,
115+
logger: Optional[Logger] = None,
111116
):
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+
"""
112130
super().__init__(container)
113131
self.handlers = list(handlers)
132+
self._logger = logger or logging.getLogger("guardpost")
133+
self._rate_limiter = rate_limiter
114134

115135
def add(self, handler: AuthenticationHandlerConfType) -> "AuthenticationStrategy":
116136
self.handlers.append(handler)
@@ -151,21 +171,49 @@ async def authenticate(
151171
self, context: Any, authentication_schemes: Optional[Sequence[str]] = None
152172
) -> Optional[Identity]:
153173
"""
154-
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.
155176
"""
156177
if not context:
157178
raise ValueError("Missing context to evaluate authentication")
158179

180+
if self._rate_limiter:
181+
await self._rate_limiter.validate_authentication_attempt(context)
182+
183+
identity = None
159184
for handler in self._get_handlers_by_schemes(authentication_schemes, context):
160-
if _is_async_handler(type(handler)):
161-
identity = await handler.authenticate(context) # type: ignore
162-
else:
163-
identity = handler.authenticate(context)
185+
try:
186+
identity = await self._authenticate_with_handler(handler, context)
187+
except InvalidCredentialsError as invalid_credentials_error:
188+
# A client provided credentials of a given type, and they were invalid.
189+
# Store the information, so later calls can be validated without
190+
# attempting authentication.
191+
self._logger.info(
192+
"Invalid credentials received from client IP %s for scheme: %s",
193+
invalid_credentials_error.client_ip,
194+
handler.scheme,
195+
)
196+
if self._rate_limiter:
197+
await self._rate_limiter.store_authentication_failure(
198+
invalid_credentials_error
199+
)
164200

165201
if identity:
166202
try:
167203
context.identity = identity
168204
except AttributeError:
169205
pass
170206
return identity
207+
else:
208+
try:
209+
if context.identity is None:
210+
context.identity = Identity()
211+
except AttributeError:
212+
pass
171213
return None
214+
215+
async def _authenticate_with_handler(self, handler: AuthenticationHandler, context):
216+
if _is_async_handler(type(handler)):
217+
return await handler.authenticate(context) # type: ignore
218+
else:
219+
return handler.authenticate(context)

guardpost/authorization.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77

88
from guardpost.abc import BaseStrategy
99
from guardpost.authentication import Identity
10+
from guardpost.errors import AuthException
1011

1112

12-
class AuthorizationError(Exception):
13-
pass
13+
class AuthorizationError(AuthException):
14+
"""
15+
Base class for all kinds of AuthorizationErrors.
16+
"""
1417

1518

16-
class AuthorizationConfigurationError(Exception):
17-
pass
19+
class AuthorizationConfigurationError(AuthException):
20+
"""
21+
Exception to describe errors in user-defined authorization configuration.
22+
"""
1823

1924

2025
class PolicyNotFoundError(AuthorizationConfigurationError, RuntimeError):

guardpost/errors.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
11
class AuthException(Exception):
2-
"""Base class for all exception risen by the library."""
2+
"""Base class for exceptions raised by GuardPost."""
33

44

55
class UnsupportedFeatureError(AuthException):
6-
"""Exception risen for unsupported features."""
6+
"""Exception raised for unsupported features."""
7+
8+
9+
class InvalidCredentialsError(AuthException):
10+
"""
11+
Exception to be raised when invalid credentials are provided. The purpose of this
12+
class is to implement rate limiting and provide protection against brute-force
13+
attacks.
14+
"""
15+
16+
def __init__(self, client_ip: str, key: str = "") -> None:
17+
super().__init__(f"Invalid credentials received from {client_ip}.")
18+
19+
if not client_ip:
20+
raise ValueError("Missing or empty client IP")
21+
22+
self._client_ip = client_ip
23+
self._key = key or client_ip
24+
25+
@property
26+
def client_ip(self) -> str:
27+
return self._client_ip
28+
29+
@property
30+
def key(self) -> str:
31+
return self._key
32+
33+
@key.setter
34+
def key(self, value: str):
35+
self._key = value
36+
37+
38+
class RateLimitExceededError(Exception):
39+
"""
40+
Exception raised when too many authentication attempts have been made,
41+
triggering rate limiting protection against brute-force attacks.
42+
"""
43+
44+
def __init__(self) -> None:
45+
super().__init__(
46+
"Too many authentication attempts. Access temporarily blocked due to rate "
47+
"limiting."
48+
)

0 commit comments

Comments
 (0)