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 f95a4d813e
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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user 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.core.security import create_access_token, hash_password, verify_password
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
@@ -12,7 +13,8 @@ router = APIRouter()
@router.post("/register", response_model=TokenResponse, status_code=201) @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: if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if "@" not in body.email: 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) @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())) result = await db.execute(select(User).where(User.email == body.email.lower()))
user = result.scalar_one_or_none() 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.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.config import settings as config
from app.core.limiter import limiter
app = FastAPI( app = FastAPI(
title="PocketVeto", title="PocketVeto",
@@ -10,6 +13,9 @@ app = FastAPI(
version="1.0.0", version="1.0.0",
) )
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[o for o in [config.LOCAL_URL, config.PUBLIC_URL] if o], 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 passlib[bcrypt]==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
# Rate limiting
slowapi==0.1.9
limits==3.13.0
# Utilities # Utilities
python-dateutil==2.9.0 python-dateutil==2.9.0
tiktoken==0.8.0 tiktoken==0.8.0

View File

@@ -22,6 +22,10 @@ http {
'"$http_user_agent"'; '"$http_user_agent"';
access_log /var/log/nginx/access.log main; 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. # Use Docker's internal DNS; valid=10s forces re-resolution after container restarts.
# Variables in proxy_pass activate this resolver (upstream blocks do not). # Variables in proxy_pass activate this resolver (upstream blocks do not).
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
@@ -32,6 +36,17 @@ http {
client_max_body_size 10M; 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 # API — variable forces re-resolution via resolver on each request cycle
location /api/ { location /api/ {
set $api http://api:8000; set $api http://api:8000;