Fix aiomysql pool_pre_ping crash, add rate limiting and security hardening

Remove pool_pre_ping=True to fix startup crash caused by aiomysql 0.2.0
async adapter requiring a reconnect argument SQLAlchemy does not pass.
Add slowapi rate limiting, structured logging, CORS config, backend
health check, and nginx security headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:44:23 -07:00
parent 866c2e0bed
commit ef8b44934c
10 changed files with 67 additions and 11 deletions
+3
View File
@@ -12,3 +12,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES=480
# Admin account (seeded on every container start) # Admin account (seeded on every container start)
ADMIN_USERNAME=admin@example.com ADMIN_USERNAME=admin@example.com
ADMIN_PASSWORD=changeme_admin ADMIN_PASSWORD=changeme_admin
# CORS: comma-separated list of allowed origins
ALLOWED_ORIGINS=https://yourdomain.com
+1
View File
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
algorithm: str = "HS256" algorithm: str = "HS256"
admin_username: str admin_username: str
admin_password: str admin_password: str
allowed_origins: str = "http://localhost"
class Config: class Config:
env_file = ".env" env_file = ".env"
+1 -1
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import DeclarativeBase
from app.config import settings from app.config import settings
engine = create_async_engine(settings.database_url, echo=False, pool_pre_ping=True, pool_recycle=1800) engine = create_async_engine(settings.database_url, echo=False, pool_recycle=1800)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+21 -1
View File
@@ -1,13 +1,23 @@
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.config import settings from app.config import settings
from app.database import init_db, AsyncSessionLocal from app.database import init_db, AsyncSessionLocal
from app.limiter import limiter
from app.routers import auth, users, entries, public, admin from app.routers import auth, users, entries, public, admin
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("bourbonacci")
async def _seed_admin() -> None: async def _seed_admin() -> None:
from app.models.user import User from app.models.user import User
@@ -33,14 +43,19 @@ async def _seed_admin() -> None:
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
await _seed_admin() await _seed_admin()
logger.info("Bourbonacci started")
yield yield
logger.info("Bourbonacci shutting down")
app = FastAPI(title="Bourbonacci", lifespan=lifespan) app = FastAPI(title="Bourbonacci", lifespan=lifespan)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[o.strip() for o in settings.allowed_origins.split(",")],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -50,3 +65,8 @@ app.include_router(users.router)
app.include_router(entries.router) app.include_router(entries.router)
app.include_router(public.router) app.include_router(public.router)
app.include_router(admin.router) app.include_router(admin.router)
@app.get("/health")
async def health():
return {"status": "ok"}
+12 -3
View File
@@ -1,17 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException, status import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.dependencies import get_db from app.dependencies import get_db
from app.limiter import limiter
from app.models.user import User from app.models.user import User
from app.schemas.user import UserCreate, Token, LoginRequest from app.schemas.user import UserCreate, Token, LoginRequest
from app.utils.security import hash_password, verify_password, create_token from app.utils.security import hash_password, verify_password, create_token
logger = logging.getLogger("bourbonacci.auth")
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): @limiter.limit("5/minute")
async def register(request: Request, body: UserCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email)) result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none(): if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
@@ -25,15 +30,19 @@ async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
logger.info("New user registered: %s", body.email)
return Token(access_token=create_token(user.id)) return Token(access_token=create_token(user.id))
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): @limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email)) result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash): if not user or not verify_password(body.password, user.password_hash):
logger.warning("Failed login for: %s", body.email)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
logger.info("Login: %s (id=%s)", body.email, user.id)
return Token(access_token=create_token(user.id)) return Token(access_token=create_token(user.id))
+10 -6
View File
@@ -1,24 +1,28 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db from app.dependencies import get_db
from app.limiter import limiter
from app.models.user import User from app.models.user import User
from app.models.entry import Entry, EntryType from app.models.entry import EntryType
from app.schemas.entry import PublicUserStats from app.schemas.entry import PublicUserStats
router = APIRouter(prefix="/api/public", tags=["public"]) router = APIRouter(prefix="/api/public", tags=["public"])
@router.get("/stats", response_model=list[PublicUserStats]) @router.get("/stats", response_model=list[PublicUserStats])
async def public_stats(db: AsyncSession = Depends(get_db)): @limiter.limit("30/minute")
users_result = await db.execute(select(User)) async def public_stats(request: Request, db: AsyncSession = Depends(get_db)):
users_result = await db.execute(
select(User).options(selectinload(User.entries))
)
users = users_result.scalars().all() users = users_result.scalars().all()
stats: list[PublicUserStats] = [] stats: list[PublicUserStats] = []
for user in users: for user in users:
entries_result = await db.execute(select(Entry).where(Entry.user_id == user.id)) entries = user.entries
entries = entries_result.scalars().all()
adds = [e for e in entries if e.entry_type == EntryType.add] adds = [e for e in entries if e.entry_type == EntryType.add]
removes = [e for e in entries if e.entry_type == EntryType.remove] removes = [e for e in entries if e.entry_type == EntryType.remove]
+1
View File
@@ -9,3 +9,4 @@ bcrypt==4.0.1
python-multipart==0.0.20 python-multipart==0.0.20
pytz==2024.2 pytz==2024.2
email-validator==2.2.0 email-validator==2.2.0
slowapi==0.1.9
+7
View File
@@ -18,10 +18,17 @@ services:
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480} - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480}
- ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
db: db:
image: mysql:8 image: mysql:8
+7
View File
@@ -1,9 +1,16 @@
server { server {
listen 80; listen 80;
server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';" always;
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }