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:
Jack Levy
2026-03-15 18:07:53 -04:00
parent 47bc8babc2
commit d6ebbf75d0
5 changed files with 44 additions and 3 deletions

View File

@@ -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()

View 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")

View File

@@ -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],

View File

@@ -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