Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9ac1325
feat: add slowapi for rate limiting functionality
seapagan Jan 5, 2026
fbd85d4
implement rate-limiting config and decorators
seapagan Jan 5, 2026
af85cef
feat: add rate limiting to authentication routes
seapagan Jan 5, 2026
ec3b6c7
feat: add integration and unit tests for rate limiting functionality …
seapagan Jan 5, 2026
892b335
feat: add rate limiting settings to .env.example for authentication p…
seapagan Jan 5, 2026
312d3b8
docs: add comprehensive rate limiting documentation and decorator usa…
seapagan Jan 6, 2026
444536e
update TODO.md
seapagan Jan 6, 2026
30aae89
docs: mark rate limiting (item #3) as complete and add user-based enh…
seapagan Jan 7, 2026
2a1495b
test: add rate limit initialization tests for 100% coverage
seapagan Jan 7, 2026
e58a60b
refactor: move sync logging tests to separate file to fix pytest warn…
seapagan Jan 7, 2026
8d4e7f1
config: filter slowapi deprecation warning from test output
seapagan Jan 7, 2026
cb4c9db
fix: improve null safety in rate limiter and strengthen test assertions
seapagan Jan 8, 2026
a4176b5
refactor: improve rate limit maintainability and expand test coverage
seapagan Jan 8, 2026
cd5d31e
test: add semantic validation for rate limit count > 0
seapagan Jan 8, 2026
47faee4
refactor: use introspection for rate limit format tests
seapagan Jan 8, 2026
af464de
docs: remove broken link and fix formatting in see also section
seapagan Jan 8, 2026
555bc79
test: disable rate limiting by default in pytest environment
seapagan Jan 8, 2026
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,11 @@ REDIS_DB=0
# How long cached responses are stored before expiring
# Individual endpoints may override this value
CACHE_DEFAULT_TTL=300

# Rate Limiting Settings (opt-in, disabled by default)
# When enabled, uses Redis if available, otherwise in-memory storage
# Protects authentication endpoints from brute force and abuse
# Note: Rate limiting automatically uses Redis when both
# RATE_LIMIT_ENABLED=true and REDIS_ENABLED=true
# No additional Redis configuration needed
RATE_LIMIT_ENABLED=false
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ following advantages to starting your own from scratch :
collection tracks HTTP performance (requests, latency, in-flight), business
metrics (auth failures, API key usage, login attempts), and custom
application metrics. Exposed via `/metrics` endpoint when enabled.
- **Rate limiting** for authentication endpoints to protect against brute force
attacks, spam, and abuse. Disabled by default, supports both in-memory (for
single-instance) and Redis (for multi-instance) backends. Conservative limits
applied to login (5/15min), registration (3/hour), password recovery (3/hour),
and other auth routes. Returns HTTP 429 with `Retry-After` header when limits
exceeded. Violations tracked via Prometheus metrics when enabled.
- **A command-line admin tool**. This allows to configure the project metadata
very easily, add users (and make admin), and run a development server. This
can easily be modified to add your own functionality (for example bulk add
Expand Down
64 changes: 50 additions & 14 deletions SECURITY-REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@
### 3. No Rate Limiting on Authentication Endpoints

> [!NOTE]
>
> In the TODO, was waiting until Redis was integrated - it now has been d/t
> caching implementation.
> ✅ **Done**: Rate limiting implemented using slowapi with conservative limits
> on all 7 authentication endpoints. Supports both in-memory (single-instance)
> and Redis (multi-instance) backends. Disabled by default, opt-in via
> `RATE_LIMIT_ENABLED=true`. Returns HTTP 429 with Retry-After header when
> limits exceeded. Violations tracked via Prometheus metrics.

**Location**: All routes in `app/resources/auth.py`, entire codebase
**Location**: All routes in `app/resources/auth.py`, `app/rate_limit/`

- **Issue**: No rate limiting implementation found anywhere. Vulnerable to:
- Brute force login attacks (`/login/`)
Expand All @@ -65,10 +67,13 @@
- **Impact**: Attackers can perform unlimited login attempts, spam registration
emails, and exhaust server resources.
- **Fix**: Implement rate limiting middleware (e.g., slowapi, fastapi-limiter):
- Login: 5 attempts per 15 minutes per IP
- Registration: 3 per hour per IP
- Password reset: 3 per hour per email
- API requests: 100 per minute per user/IP
- Login: 5 attempts per 15 minutes per IP ✅
- Registration: 3 per hour per IP ✅
- Password reset: 3 per hour per email ✅
- Email verification: 10 per minute per IP ✅
- Token refresh: 20 per minute per IP ✅
- Reset password GET: 10 per minute per IP ✅
- Reset password POST: 5 per hour per IP ✅

### 4. API Key Scopes Stored But Never Enforced

Expand Down Expand Up @@ -509,6 +514,37 @@

## Feature Ideas (Future Enhancements)

- **User-based rate limiting for authenticated endpoints**: Current IP-based
limiting (#3) protects auth endpoints. Next enhancement: add user/API-key based
limiting for authenticated API operations. Benefits:
- Fair usage across shared IPs (corporate NAT, proxies)
- Per-user quotas and tiered limits (free vs premium)
- Better tracking of individual user API consumption
- Prevent individual account abuse

Implementation approach:
- Create middleware to extract user ID from JWT/API key before rate limiting
- Add `user_limiter` alongside existing IP-based `limiter`
- Use custom key function: `user:{user_id}` if authenticated, fallback to
`ip:{address}` for unauthenticated
- Apply to protected endpoints (keep IP-based for auth endpoints)
- Support role-based dynamic limits (admins get higher quotas)

Example usage:

```python
@router.get("/api/data")
@user_rate_limited("500/hour") # Per user, not per IP
async def get_data(user: User = Depends(AuthManager())):
pass
```

Technical considerations:
- Middleware runs before dependencies, so need early auth extraction
- Reuse existing Redis/in-memory backend
- No impact on current auth endpoint protection
- Enables per-user analytics and quota enforcement

- **Session management**: list/revoke active refresh tokens per user (ties into
logout/invalidations and #8 jti claims)
- **Account security notifications**: email user when password/email changes or
Expand All @@ -531,7 +567,7 @@

| Priority | Count | Must Fix Before Production? |
|--------------|---------------|-------------------------------------|
| **CRITICAL** | 5 (3 closed) | ✅ YES - Security vulnerabilities |
| **CRITICAL** | 5 (4 closed) | ✅ YES - Security vulnerabilities |
| **High** | 9 (0 closed) | ✅ YES - Important security/quality |
| **Medium** | 14 (0 closed) | ⚠️ Recommended - Hardening needed |
| **Low** | 5 (0 closed) | 💡 Optional - Nice to have |
Expand All @@ -556,10 +592,10 @@ rate limiting, token validation, and API key scope enforcement.

### Sprint 1 - CRITICAL (This Week)

1. **Fix CORS configuration** (#2) - Remove credentials or restrict origins
2. **Implement rate limiting** (#3) - All auth endpoints
3. **Add token type validation** (#1) - `get_jwt_user()` enforcement
4. **Redact sensitive data from logs** (#5) - Query parameter filtering
1. **Fix CORS configuration** (#2) - Remove credentials or restrict origins
2. **Implement rate limiting** (#3) - All auth endpoints
3. **Add token type validation** (#1) - `get_jwt_user()` enforcement
4. **Redact sensitive data from logs** (#5) - Query parameter filtering
5. **Fix API key scope enforcement** (#4) - Add validation logic

### Sprint 2 - High Priority (Next Week)
Expand Down Expand Up @@ -613,7 +649,7 @@ rate limiting, token validation, and API key scope enforcement.
- `app/managers/api_key.py` - Scope enforcement (#4), last_used_at (#23)
- `app/managers/user.py` - Timing attacks (#6), email enumeration (#10), race
condition (#11)
- **NEW FILE**: `app/middleware/rate_limiting.py` - Rate limiting (#3)
- `app/rate_limit/` - Rate limiting implementation (#3)

**High Priority:**

Expand Down
2 changes: 2 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- allow to tag endpoints as belonging to a group so can then have similar auth
etc.
- expand the IP-based rate-limiting to allow user-based limiting. this will help
to set user-based quotas.
- add time-limited bans (configurable)
- Add certain users that will not time-expire (or much longer time) for eg for
owner or premium access.
Expand Down
20 changes: 20 additions & 0 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ class Settings(BaseSettings):
redis_db: int = 0
cache_default_ttl: int = 300 # 5 minutes

# Rate limiting settings (opt-in, disabled by default)
# Automatically uses Redis when both rate_limit_enabled and
# redis_enabled are True
rate_limit_enabled: bool = False

# gatekeeper settings!
# this is to ensure that people read the damn instructions and changelogs
i_read_the_damn_docs: bool = False
Expand All @@ -160,6 +165,21 @@ def redis_url(self) -> str:
)
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"

@property
def rate_limit_storage_url(self) -> str:
"""Generate rate limit storage URL.

Returns Redis URL when both redis_enabled and rate_limit_enabled
are True, otherwise returns empty string to trigger in-memory
storage.

Returns:
Redis URL for rate limiting or empty string for in-memory.
"""
if self.rate_limit_enabled and self.redis_enabled:
return self.redis_url
return ""

@field_validator("api_root")
@classmethod
def check_api_root(cls: type[Settings], value: str) -> str:
Expand Down
19 changes: 18 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from typing import Any, cast

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -16,6 +16,8 @@
from loguru import logger as loguru_logger
from redis import RedisError
from redis.asyncio import Redis
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy.exc import SQLAlchemyError

from app.admin import register_admin
Expand All @@ -27,6 +29,8 @@
from app.metrics.instrumentator import register_metrics
from app.middleware.cache_logging import CacheLoggingMiddleware
from app.middleware.logging_middleware import LoggingMiddleware
from app.rate_limit import limiter
from app.rate_limit.handlers import rate_limit_handler
from app.resources import config_error
from app.resources.routes import api_router

Expand Down Expand Up @@ -160,6 +164,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
# Customize OpenAPI schema for special endpoints
app.openapi = lambda: custom_openapi(app) # type: ignore[method-assign]

# Register custom exception handler for rate limits
app.add_exception_handler(
RateLimitExceeded,
cast("Any", rate_limit_handler),
)


# register the API routes
app.include_router(api_router)

Expand Down Expand Up @@ -190,5 +201,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
# Add cache logging middleware
app.add_middleware(CacheLoggingMiddleware)

# Add SlowAPI middleware and state (required for rate limiting)
# NOTE: app.state.limiter must be set regardless of rate_limit_enabled
# The limiter itself has enabled=False when rate limiting is disabled
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)

# Add pagination support
add_pagination(app)
2 changes: 2 additions & 0 deletions app/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
increment_api_key_validation,
increment_auth_failure,
increment_login_attempt,
increment_rate_limit_exceeded,
)
from app.metrics.instrumentator import get_instrumentator
from app.metrics.namespace import METRIC_NAMESPACE
Expand All @@ -14,4 +15,5 @@
"increment_api_key_validation",
"increment_auth_failure",
"increment_login_attempt",
"increment_rate_limit_exceeded",
]
17 changes: 17 additions & 0 deletions app/metrics/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
namespace=METRIC_NAMESPACE,
)

# Rate limit tracking
rate_limit_exceeded_total = Counter(
"rate_limit_exceeded_total",
"Total rate limit violations by endpoint and limit",
["endpoint", "limit"],
namespace=METRIC_NAMESPACE,
)


# Helper functions (only increment if metrics enabled)
def increment_auth_failure(reason: str, method: str) -> None:
Expand All @@ -47,3 +55,12 @@ def increment_login_attempt(status: str) -> None:
"""Increment login attempt counter."""
if get_settings().metrics_enabled:
login_attempts_total.labels(status=status).inc()


def increment_rate_limit_exceeded(endpoint: str, limit: str) -> None:
"""Increment rate limit exceeded counter."""
if get_settings().metrics_enabled:
rate_limit_exceeded_total.labels(
endpoint=endpoint,
limit=limit,
).inc()
76 changes: 76 additions & 0 deletions app/rate_limit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Rate limiting module using slowapi."""

from typing import TYPE_CHECKING

from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from app.config.settings import get_settings
from app.logs import LogCategory, category_logger

if TYPE_CHECKING:
from slowapi import Limiter as LimiterType

# Initialize limiter with storage backend selection
_limiter: "LimiterType | None" = None


def get_limiter() -> Limiter:
"""Get or create the rate limiter instance.

Returns:
Configured Limiter with Redis or in-memory storage.

Note:
When RATE_LIMIT_ENABLED=False, returns a limiter that
allows all requests (enabled=False tells slowapi to
not enforce any limits).
"""
global _limiter # noqa: PLW0603

if _limiter is not None:
return _limiter

settings = get_settings()

# Determine storage backend
# Always use memory:// for in-memory storage to ensure proper init
if settings.rate_limit_enabled and settings.redis_enabled:
storage_uri = settings.redis_url
category_logger.info(
"Rate limiting initialized with Redis storage",
LogCategory.AUTH,
)
else:
# Use memory:// for in-memory storage (works even when disabled)
storage_uri = "memory://"
if settings.rate_limit_enabled:
category_logger.info(
"Rate limiting initialized with in-memory storage",
LogCategory.AUTH,
)
else:
category_logger.info(
"Rate limiting is disabled (RATE_LIMIT_ENABLED=false)",
LogCategory.AUTH,
)

# Create limiter instance
_limiter = Limiter(
key_func=get_remote_address, # Rate limit by IP address
storage_uri=storage_uri,
enabled=settings.rate_limit_enabled,
)

return _limiter


# Export for convenience
limiter = get_limiter()

__all__ = [
"RateLimitExceeded",
"get_limiter",
"limiter",
]
33 changes: 33 additions & 0 deletions app/rate_limit/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Rate limit configurations for different endpoints."""

from typing import ClassVar


class RateLimits:
"""Rate limit definitions for authentication endpoints.

Following conservative limits from SECURITY-REVIEW.md #3.
Format: "{count}/{period}" where period can be:
- second, minute, hour, day
"""

# Registration - prevent spam account creation
REGISTER: ClassVar[str] = "3/hour"

# Login - brute force protection
LOGIN: ClassVar[str] = "5/15minutes"

# Password recovery - prevent email flooding
FORGOT_PASSWORD: ClassVar[str] = "3/hour" # noqa: S105

# Email verification - prevent abuse
VERIFY: ClassVar[str] = "10/minute"

# Token refresh - prevent token harvesting
REFRESH: ClassVar[str] = "20/minute"

# Password reset GET (form page) - prevent reconnaissance
RESET_PASSWORD_GET: ClassVar[str] = "10/minute" # noqa: S105

# Password reset POST (actual reset) - critical security
RESET_PASSWORD_POST: ClassVar[str] = "5/hour" # noqa: S105
Loading