diff --git a/.env.example b/.env.example index a1ab28e..1477ce0 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES=480 # Admin account (seeded on every container start) ADMIN_USERNAME=admin@example.com ADMIN_PASSWORD=changeme_admin + +# CORS: comma-separated list of allowed origins +ALLOWED_ORIGINS=https://yourdomain.com diff --git a/backend/app/config.py b/backend/app/config.py index bcf9eac..5f8b3ed 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,6 +8,7 @@ class Settings(BaseSettings): algorithm: str = "HS256" admin_username: str admin_password: str + allowed_origins: str = "http://localhost" class Config: env_file = ".env" diff --git a/backend/app/database.py b/backend/app/database.py index ab666ac..69a40f1 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import DeclarativeBase 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) diff --git a/backend/app/limiter.py b/backend/app/limiter.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/backend/app/limiter.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/app/main.py b/backend/app/main.py index 011dd45..f4992b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,13 +1,23 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from app.config import settings from app.database import init_db, AsyncSessionLocal +from app.limiter import limiter 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: from app.models.user import User @@ -33,14 +43,19 @@ async def _seed_admin() -> None: async def lifespan(app: FastAPI): await init_db() await _seed_admin() + logger.info("Bourbonacci started") yield + logger.info("Bourbonacci shutting down") app = FastAPI(title="Bourbonacci", lifespan=lifespan) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[o.strip() for o in settings.allowed_origins.split(",")], allow_methods=["*"], allow_headers=["*"], ) @@ -50,3 +65,8 @@ app.include_router(users.router) app.include_router(entries.router) app.include_router(public.router) app.include_router(admin.router) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 795e62f..3340cd0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 import select from app.dependencies import get_db +from app.limiter import limiter from app.models.user import User from app.schemas.user import UserCreate, Token, LoginRequest from app.utils.security import hash_password, verify_password, create_token +logger = logging.getLogger("bourbonacci.auth") router = APIRouter(prefix="/api/auth", tags=["auth"]) @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)) if result.scalar_one_or_none(): 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.refresh(user) + logger.info("New user registered: %s", body.email) return Token(access_token=create_token(user.id)) @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)) user = result.scalar_one_or_none() 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") + logger.info("Login: %s (id=%s)", body.email, user.id) return Token(access_token=create_token(user.id)) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index 4296738..3727414 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -1,24 +1,28 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.orm import selectinload from app.dependencies import get_db +from app.limiter import limiter 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 router = APIRouter(prefix="/api/public", tags=["public"]) @router.get("/stats", response_model=list[PublicUserStats]) -async def public_stats(db: AsyncSession = Depends(get_db)): - users_result = await db.execute(select(User)) +@limiter.limit("30/minute") +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() stats: list[PublicUserStats] = [] for user in users: - entries_result = await db.execute(select(Entry).where(Entry.user_id == user.id)) - entries = entries_result.scalars().all() + entries = user.entries adds = [e for e in entries if e.entry_type == EntryType.add] removes = [e for e in entries if e.entry_type == EntryType.remove] diff --git a/backend/requirements.txt b/backend/requirements.txt index a5129fd..368fafd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ bcrypt==4.0.1 python-multipart==0.0.20 pytz==2024.2 email-validator==2.2.0 +slowapi==0.1.9 diff --git a/docker-compose.yml b/docker-compose.yml index 334d443..2d7bf10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,10 +18,17 @@ services: - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480} - ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost} depends_on: db: condition: service_healthy 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: image: mysql:8 diff --git a/nginx/default.conf b/nginx/default.conf index d1d2329..724ddd2 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -1,9 +1,16 @@ server { listen 80; + server_tokens off; root /usr/share/nginx/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 / { try_files $uri $uri/ /index.html; }