security: brute-force protection on auth endpoints (v1.1.0)
- Nginx rate limit: 20 req/min per IP on /api/auth/login and /register - slowapi rate limit: 10/min on login, 5/hour on register (Redis-backed) - Real client IP extracted from X-Forwarded-For for accurate per-IP limiting Authored by: Jack Levy
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.limiter import limiter
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
@@ -12,7 +13,8 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=201)
|
||||
async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("5/hour")
|
||||
async def register(request: Request, body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if "@" not in body.email:
|
||||
@@ -40,7 +42,8 @@ async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, body: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
13
backend/app/core/limiter.py
Normal file
13
backend/app/core/limiter.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from slowapi import Limiter
|
||||
|
||||
|
||||
def _get_real_ip(request) -> str:
|
||||
"""Extract real client IP, respecting X-Forwarded-For from trusted proxies."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# Redis DB 1 keeps rate-limit counters separate from Celery (DB 0)
|
||||
limiter = Limiter(key_func=_get_real_ip, storage_uri="redis://redis:6379/1")
|
||||
@@ -1,8 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes, collections, share, alignment
|
||||
from app.config import settings as config
|
||||
from app.core.limiter import limiter
|
||||
|
||||
app = FastAPI(
|
||||
title="PocketVeto",
|
||||
@@ -10,6 +13,9 @@ app = FastAPI(
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o for o in [config.LOCAL_URL, config.PUBLIC_URL] if o],
|
||||
|
||||
@@ -44,6 +44,10 @@ python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
|
||||
# Rate limiting
|
||||
slowapi==0.1.9
|
||||
limits==3.13.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.9.0
|
||||
tiktoken==0.8.0
|
||||
|
||||
@@ -22,6 +22,10 @@ http {
|
||||
'"$http_user_agent"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Rate limiting — cap auth endpoints at 20 req/min per IP at the proxy layer
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=20r/m;
|
||||
limit_req_status 429;
|
||||
|
||||
# Use Docker's internal DNS; valid=10s forces re-resolution after container restarts.
|
||||
# Variables in proxy_pass activate this resolver (upstream blocks do not).
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
@@ -32,6 +36,17 @@ http {
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Auth endpoints — rate limited at proxy layer
|
||||
location ~ ^/api/auth/(login|register)$ {
|
||||
set $api http://api:8000;
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
proxy_pass $api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API — variable forces re-resolution via resolver on each request cycle
|
||||
location /api/ {
|
||||
set $api http://api:8000;
|
||||
|
||||
Reference in New Issue
Block a user