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 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()
|
||||||
|
|
||||||
|
|||||||
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 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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user