From cccbe152752a9b3873673506e515807afe9c1cd6 Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Wed, 3 Jun 2026 10:41:02 +0200 Subject: [PATCH] Enable HTTPS production deployment on Sinbad2 via Apache reverse proxy. --- .gitignore | 5 ++- backend/.dockerignore | 5 +++ backend/.env.example | 16 ++++++--- backend/Dockerfile | 13 ++++++- backend/api/config/__init__.py | 0 backend/api/config/settings.py | 41 ++++++++++++++++++++++ backend/api/main.py | 37 ++++++++++++++----- backend/api/middleware/__init__.py | 0 backend/api/middleware/security_headers.py | 24 +++++++++++++ docker-compose.prod.yaml | 39 ++++++++++++++++++++ docker-compose.yaml | 4 ++- frontend/.dockerignore | 4 +++ frontend/.env.example | 5 +-- frontend/Dockerfile | 35 ++++++++++++++++-- frontend/index.html | 2 +- frontend/nginx.conf | 25 +++++++++++++ frontend/src/components/layout/Header.jsx | 2 +- frontend/src/config.js | 5 ++- frontend/src/lib/api.js | 5 +-- frontend/src/lib/paths.js | 19 ++++++++++ frontend/src/routers/AppRouter.jsx | 3 +- frontend/vite.config.js | 3 ++ 22 files changed, 264 insertions(+), 28 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/api/config/__init__.py create mode 100644 backend/api/config/settings.py create mode 100644 backend/api/middleware/__init__.py create mode 100644 backend/api/middleware/security_headers.py create mode 100644 docker-compose.prod.yaml create mode 100644 frontend/.dockerignore create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/lib/paths.js diff --git a/.gitignore b/.gitignore index 39f2adb..f3f0a42 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,10 @@ __pycache__/ *$py.class # Variables de entorno (solo ignoramos las locales/sobre-escrituras) -# Mantenemos .env y .env.example versionados para compartir la configuración -# de producción y el esqueleto de variables. Nunca subimos .env.local. +# Mantenemos .env y .env.example versionados en este repo privado. +# Nunca subimos .env.local. .env.local .env.*.local -.env # Override de Docker Compose para desarrollo local (no debe llegar a producción) docker-compose.override.yml diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c9685b0 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.env +.venv +venv diff --git a/backend/.env.example b/backend/.env.example index 277157a..0c15239 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,10 +16,10 @@ GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=tu-client-secret # URI a la que Google redirige tras el login (debe estar registrada en Google Cloud) -# Con Docker: suele ser el frontend (8071) porque /api hace proxy al backend +# Con Docker local: el frontend (8071) hace proxy de /api al backend GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback -# Producción (añade la misma URI en Google Console → Credenciales OAuth): -# GOOGLE_REDIRECT_URI=http://tu-servidor:8071/api/auth/google/callback +# Producción Sinbad2 (HTTPS vía Apache, prefijo /deckofcards): +# GOOGLE_REDIRECT_URI=https://sinbad2.ujaen.es/deckofcards/api/auth/google/callback # Clave para firmar los JWT (usa algo largo y aleatorio en producción) SECRET_KEY=cambia-esta-clave-en-produccion @@ -27,8 +27,14 @@ SECRET_KEY=cambia-esta-clave-en-produccion # URL del frontend a la que se redirige tras el login con Google # Con docker-compose en local: http://localhost:8071 # Con Vite directo en local: http://localhost:5173 -# En producción: https://tu-dominio.com -FRONTEND_URL=http://localhost:5173 +# Producción Sinbad2: https://sinbad2.ujaen.es/deckofcards +FRONTEND_URL=http://localhost:8071 + +# Entorno y seguridad HTTPS (producción en Sinbad2) +# ENVIRONMENT=production +# CORS_ALLOWED_ORIGINS=https://sinbad2.ujaen.es +# TRUSTED_HOSTS=sinbad2.ujaen.es,backend,localhost +# SECURITY_HSTS_SECONDS=31536000 # Verificación de email por código numérico # El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.). diff --git a/backend/Dockerfile b/backend/Dockerfile index 3639ea9..cfd5686 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim +FROM python:3.10-slim AS base WORKDIR /app @@ -8,4 +8,15 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +FROM base AS development + CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +FROM base AS production + +CMD ["uvicorn", "api.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--proxy-headers", \ + "--forwarded-allow-ips", "*", \ + "--no-server-header"] diff --git a/backend/api/config/__init__.py b/backend/api/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/config/settings.py b/backend/api/config/settings.py new file mode 100644 index 0000000..f65c395 --- /dev/null +++ b/backend/api/config/settings.py @@ -0,0 +1,41 @@ +import os +from functools import lru_cache + + +def _split_csv(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +class Settings: + ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development") + SECURITY_HSTS_SECONDS: int = int(os.getenv("SECURITY_HSTS_SECONDS", "31536000")) + + @property + def is_production(self) -> bool: + return self.ENVIRONMENT.strip().lower() == "production" + + @property + def cors_allowed_origins(self) -> list[str]: + configured = _split_csv(os.getenv("CORS_ALLOWED_ORIGINS")) + if configured: + return configured + if self.is_production: + frontend = os.getenv("FRONTEND_URL", "").rstrip("/") + return [frontend] if frontend else [] + return ["*"] + + @property + def trusted_hosts(self) -> list[str]: + configured = _split_csv(os.getenv("TRUSTED_HOSTS")) + if configured: + return configured + if self.is_production: + return ["sinbad2.ujaen.es"] + return ["*"] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/api/main.py b/backend/api/main.py index a5b71b5..0b5386e 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -1,7 +1,12 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager +from starlette.middleware.trustedhost import TrustedHostMiddleware + +from api.config.settings import get_settings from api.database.mongodb import db +from api.middleware.security_headers import SecurityHeadersMiddleware # Routers from api.routers.test_mongo import router as test_mongo_router @@ -20,15 +25,31 @@ from api.routers.google_auth import router as google_auth_router async def lifespan(app: FastAPI): yield +settings = get_settings() + app = FastAPI(lifespan=lifespan) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +app.add_middleware(SecurityHeadersMiddleware, settings=settings) + +if settings.is_production: + app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=settings.trusted_hosts, + ) + +cors_origins = settings.cors_allowed_origins +cors_kwargs = { + "allow_methods": ["*"], + "allow_headers": ["*"], +} +if cors_origins == ["*"]: + cors_kwargs["allow_origins"] = ["*"] + cors_kwargs["allow_credentials"] = False +else: + cors_kwargs["allow_origins"] = cors_origins + cors_kwargs["allow_credentials"] = True + +app.add_middleware(CORSMiddleware, **cors_kwargs) app.include_router(test_mongo_router, prefix="/api") app.include_router(value_router, prefix="/api/criteria/doc") diff --git a/backend/api/middleware/__init__.py b/backend/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/middleware/security_headers.py b/backend/api/middleware/security_headers.py new file mode 100644 index 0000000..ca7af7d --- /dev/null +++ b/backend/api/middleware/security_headers.py @@ -0,0 +1,24 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from api.config.settings import Settings, get_settings + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + def __init__(self, app, settings: Settings | None = None): + super().__init__(app) + self._settings = settings or get_settings() + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + + if request.url.scheme == "https" or self._settings.is_production: + hsts = f"max-age={self._settings.SECURITY_HSTS_SECONDS}" + response.headers.setdefault("Strict-Transport-Security", hsts) + + return response diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000..47d29f3 --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,39 @@ +services: + backend: + build: + context: ./backend + target: production + container_name: backend + restart: unless-stopped + ports: + - "8070:8000" + depends_on: + - db + env_file: + - backend/.env + + frontend: + build: + context: ./frontend + target: production + args: + VITE_BASE_PATH: /deckofcards/ + VITE_API_URL: /deckofcards/api + container_name: frontend + restart: unless-stopped + ports: + - "8071:80" + depends_on: + - backend + + db: + image: mongo:4.4 + container_name: mongo + restart: always + ports: + - "27018:27017" + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index 4f36992..a862878 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,8 @@ services: backend: - build: + build: context: ./backend + target: development container_name: backend ports: - "8070:8000" @@ -15,6 +16,7 @@ services: frontend: build: context: ./frontend + target: development container_name: frontend ports: - "8071:5173" diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b1640b5 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +.env.local diff --git a/frontend/.env.example b/frontend/.env.example index 3c23460..cc42d24 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -17,6 +17,7 @@ # - Backend ejecutado fuera de Docker (uvicorn --port 8000): # VITE_API_URL=http://localhost:8000/api # -# - Producción (ya definido en .env): -# VITE_API_URL=http://sinbad2.ujaen.es:8070/api +# - Producción Sinbad2 (HTTPS, prefijo /deckofcards; build en docker-compose.prod.yaml): +# VITE_BASE_PATH=/deckofcards/ +# VITE_API_URL=/deckofcards/api VITE_API_URL=http://localhost:8070/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 82936ab..06c2aa7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,36 @@ -FROM node:20-alpine +FROM node:20-alpine AS development + WORKDIR /app + COPY package.json package-lock.json* ./ + RUN npm install -CMD ["npm", "run", "dev", "--", "--host"] \ No newline at end of file + +CMD ["npm", "run", "dev", "--", "--host"] + +FROM node:20-alpine AS build + +WORKDIR /app + +ARG VITE_BASE_PATH=/deckofcards/ +ARG VITE_API_URL=/deckofcards/api + +ENV VITE_BASE_PATH=$VITE_BASE_PATH +ENV VITE_API_URL=$VITE_API_URL + +COPY package.json package-lock.json* ./ + +RUN npm ci + +COPY . . + +RUN npm run build + +FROM nginx:1.27-alpine AS production + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 609e6e7..52eabd3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Deck of Cards diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..357114c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Apache en Sinbad2 termina TLS en https://sinbad2.ujaen.es/deckofcards + # y reenvía el tráfico a este contenedor (puerto 8071) sin el prefijo público: + # ProxyPass /deckofcards http://host.docker.internal:8071/ + # El navegador sigue viendo /deckofcards/...; aquí llegan rutas como /, /api/, /assets/. + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + 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 $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Host $http_x_forwarded_host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 770f16d..10da8f4 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -89,7 +89,7 @@ export default function Header() { className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80" > Deck of Cards Logo diff --git a/frontend/src/config.js b/frontend/src/config.js index 91fea1e..b8eb757 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,4 +1,7 @@ -export const API_BASE_URL = import.meta.env.VITE_API_URL; +import { APP_BASE_PATH } from './lib/paths'; + +const configuredApiUrl = import.meta.env.VITE_API_URL; +export const API_BASE_URL = configuredApiUrl || (APP_BASE_PATH ? `${APP_BASE_PATH}/api` : '/api'); export const CHART_COLORS = [ '#ef4444', '#f59e0b', '#10b981', '#3b82f6', diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e94beef..eafc70a 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,5 +1,6 @@ import Axios from 'axios'; import { API_BASE_URL } from '../config'; +import { isLoginPath, toAppPath } from './paths'; const api = Axios.create({ baseURL: API_BASE_URL, @@ -28,8 +29,8 @@ api.interceptors.response.use( localStorage.removeItem('user'); // SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login - if (window.location.pathname !== '/login') { - window.location.href = '/login'; + if (!isLoginPath(window.location.pathname)) { + window.location.href = toAppPath('/login'); } } diff --git a/frontend/src/lib/paths.js b/frontend/src/lib/paths.js new file mode 100644 index 0000000..ed77b3d --- /dev/null +++ b/frontend/src/lib/paths.js @@ -0,0 +1,19 @@ +const normalizeBasePath = (value) => { + if (!value || value === '/') { + return ''; + } + const withLeadingSlash = value.startsWith('/') ? value : `/${value}`; + return withLeadingSlash.replace(/\/$/, ''); +}; + +export const APP_BASE_PATH = normalizeBasePath(import.meta.env.VITE_BASE_PATH); + +export const toAppPath = (path) => { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${APP_BASE_PATH}${normalizedPath}` || normalizedPath; +}; + +export const isLoginPath = (pathname) => { + const loginPath = toAppPath('/login'); + return pathname === loginPath || pathname === '/login'; +}; diff --git a/frontend/src/routers/AppRouter.jsx b/frontend/src/routers/AppRouter.jsx index e6efa81..a26d556 100644 --- a/frontend/src/routers/AppRouter.jsx +++ b/frontend/src/routers/AppRouter.jsx @@ -1,4 +1,5 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { APP_BASE_PATH } from '../lib/paths'; import MainLayout from '../components/layout/MainLayout'; import DocEditor from '../pages/DocEditor'; import Login from '../pages/Login'; @@ -18,7 +19,7 @@ function ProtectedHistoryRoute() { export default function AppRouter() { return ( - + } /> diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4ee34f7..3cdfb27 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,8 +2,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +const basePath = process.env.VITE_BASE_PATH || '/' + // https://vite.dev/config/ export default defineConfig({ + base: basePath, plugins: [ react(), tailwindcss(),