Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.9, "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -81,18 +81,18 @@ jobs:

- name: Install distribution dependencies
run: pip install --upgrade build
if: matrix.python-version == 3.12
if: matrix.python-version == 3.13

- name: Create distribution package
run: python -m build
if: matrix.python-version == 3.12
if: matrix.python-version == 3.13

- name: Upload distribution package
uses: actions/upload-artifact@v4
with:
name: dist
path: dist
if: matrix.python-version == 3.12
if: matrix.python-version == 3.13

publish:
runs-on: ubuntu-latest
Expand All @@ -105,10 +105,10 @@ jobs:
name: dist
path: dist

- name: Use Python 3.12
- name: Use Python 3.13
uses: actions/setup-python@v3
with:
python-version: "3.12"
python-version: "3.13"

- name: Install dependencies
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ __pycache__
.mypy_cache
junit
coverage.xml
.local
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.4] - 2025-10-18 :beers:

- Add a `guardpost.protection` namespace with classes offering a strategy for
brute-force protection against authentication attempts, and to log all failed
authentication attempts consistently.
- Add an `InvalidCredentialsError` exception. `AuthenticationHandler` implementations
can raise `InvalidCredentialsError` when invalid credentials are provided, to
enable automatic logging and, if enabled, brute-force protection.
- Add `RateLimiter` class that can block authentication attempts after a configurable
threshold is exceeded. By default stores failed attempts in-memory.
- Integrate `RateLimiter` into `AuthenticationStrategy` with automatic tracking of
failed authentication attempts and support for blocking excessive requests.
- Add Python `3.14` and remove `3.9` from the build matrix.
- Drop support for Python `3.9` (it reached EOL in October 2025).
- Add an optional dependency on `essentials`, to use its `Secret` class to handle
secrets for JWT validation with symmetric encryption. This is useful to support
rotating secrets by updating env variables.
- Improve exceptions raised for invalid `JWTs` to include the source exception
(`exc.__cause__`).

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

- Add a `roles` property to the `Identity` object.
Expand Down
7 changes: 5 additions & 2 deletions guardpost.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
"editor.rulers": [
88,
100
]
}
],
"editor.defaultFormatter": "ms-python.black-formatter"
},
"python.formatting.provider": "none",
"makefile.configureOnOpen": false
}
}
2 changes: 1 addition & 1 deletion guardpost/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.3"
__version__ = "1.0.4"
20 changes: 20 additions & 0 deletions guardpost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,28 @@
AuthorizationContext,
AuthorizationError,
AuthorizationStrategy,
ForbiddenError,
Policy,
PolicyNotFoundError,
Requirement,
RequirementConfType,
RolesRequirement,
UnauthorizedError,
)
from .errors import (
AuthException,
InvalidCredentialsError,
RateLimitExceededError,
)
from .protection import (
AuthenticationAttemptsStore,
FailedAuthenticationAttempts,
InMemoryAuthenticationAttemptsStore,
RateLimiter,
)

__all__ = [
"AuthException",
"AuthenticationHandlerConfType",
"AuthenticationSchemesNotFound",
"AuthorizationConfigurationError",
Expand All @@ -35,5 +48,12 @@
"RequirementConfType",
"RolesRequirement",
"UnauthorizedError",
"ForbiddenError",
"AuthorizationContext",
"RateLimiter",
"AuthenticationAttemptsStore",
"InMemoryAuthenticationAttemptsStore",
"FailedAuthenticationAttempts",
"InvalidCredentialsError",
"RateLimitExceededError",
]
58 changes: 53 additions & 5 deletions guardpost/authentication.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import inspect
import logging
from abc import ABC, abstractmethod
from functools import lru_cache
from logging import Logger
from typing import Any, List, Optional, Sequence, Type, Union

from rodi import ContainerProtocol

from guardpost.abc import BaseStrategy
from guardpost.protection import InvalidCredentialsError, RateLimiter


class Identity:
Expand Down Expand Up @@ -108,9 +111,26 @@ def __init__(
self,
*handlers: AuthenticationHandlerConfType,
container: Optional[ContainerProtocol] = None,
rate_limiter: Optional[RateLimiter] = None,
logger: Optional[Logger] = None,
):
"""
Initializes an AuthenticationStrategy instance.

Args:
*handlers: One or more authentication handler instances or types to be used
for authentication.
container: Optional dependency injection container for resolving handler
instances.
rate_limiter: Optional RateLimiter to apply rate limiting to authentication
attempts.
logger: Optional logger instance for logging authentication events. If not
provided, defaults to `logging.getLogger("guardpost")`
"""
super().__init__(container)
self.handlers = list(handlers)
self._logger = logger or logging.getLogger("guardpost")
self._rate_limiter = rate_limiter

def add(self, handler: AuthenticationHandlerConfType) -> "AuthenticationStrategy":
self.handlers.append(handler)
Expand Down Expand Up @@ -151,21 +171,49 @@ async def authenticate(
self, context: Any, authentication_schemes: Optional[Sequence[str]] = None
) -> Optional[Identity]:
"""
Tries to obtain the user for a context, applying authentication rules.
Tries to obtain the user for a context, applying authentication rules and
optional rate limiting.
"""
if not context:
raise ValueError("Missing context to evaluate authentication")

if self._rate_limiter:
await self._rate_limiter.validate_authentication_attempt(context)

identity = None
for handler in self._get_handlers_by_schemes(authentication_schemes, context):
if _is_async_handler(type(handler)):
identity = await handler.authenticate(context) # type: ignore
else:
identity = handler.authenticate(context)
try:
identity = await self._authenticate_with_handler(handler, context)
except InvalidCredentialsError as invalid_credentials_error:
# A client provided credentials of a given type, and they were invalid.
# Store the information, so later calls can be validated without
# attempting authentication.
self._logger.info(
"Invalid credentials received from client IP %s for scheme: %s",
invalid_credentials_error.client_ip,
handler.scheme,
)
if self._rate_limiter:
await self._rate_limiter.store_authentication_failure(
invalid_credentials_error
)

if identity:
try:
context.identity = identity
except AttributeError:
pass
return identity
else:
try:
if context.identity is None:
context.identity = Identity()
except AttributeError:
pass
return None

async def _authenticate_with_handler(self, handler: AuthenticationHandler, context):
if _is_async_handler(type(handler)):
return await handler.authenticate(context) # type: ignore
else:
return handler.authenticate(context)
13 changes: 9 additions & 4 deletions guardpost/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@

from guardpost.abc import BaseStrategy
from guardpost.authentication import Identity
from guardpost.errors import AuthException


class AuthorizationError(Exception):
pass
class AuthorizationError(AuthException):
"""
Base class for all kinds of AuthorizationErrors.
"""


class AuthorizationConfigurationError(Exception):
pass
class AuthorizationConfigurationError(AuthException):
"""
Exception to describe errors in user-defined authorization configuration.
"""


class PolicyNotFoundError(AuthorizationConfigurationError, RuntimeError):
Expand Down
46 changes: 44 additions & 2 deletions guardpost/errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,48 @@
class AuthException(Exception):
"""Base class for all exception risen by the library."""
"""Base class for exceptions raised by GuardPost."""


class UnsupportedFeatureError(AuthException):
"""Exception risen for unsupported features."""
"""Exception raised for unsupported features."""


class InvalidCredentialsError(AuthException):
"""
Exception to be raised when invalid credentials are provided. The purpose of this
class is to implement rate limiting and provide protection against brute-force
attacks.
"""

def __init__(self, client_ip: str, key: str = "") -> None:
super().__init__(f"Invalid credentials received from {client_ip}.")

if not client_ip:
raise ValueError("Missing or empty client IP")

self._client_ip = client_ip
self._key = key or client_ip

@property
def client_ip(self) -> str:
return self._client_ip

@property
def key(self) -> str:
return self._key

@key.setter
def key(self, value: str):
self._key = value


class RateLimitExceededError(Exception):
"""
Exception raised when too many authentication attempts have been made,
triggering rate limiting protection against brute-force attacks.
"""

def __init__(self) -> None:
super().__init__(
"Too many authentication attempts. Access temporarily blocked due to rate "
"limiting."
)
Loading