Enable HTTPS production deployment on Sinbad2 via Apache reverse proxy.

This commit is contained in:
Mireya Cueto Garrido
2026-06-03 10:41:02 +02:00
parent 31be326f2c
commit cccbe15275
22 changed files with 264 additions and 28 deletions
+2 -3
View File
@@ -4,11 +4,10 @@ __pycache__/
*$py.class *$py.class
# Variables de entorno (solo ignoramos las locales/sobre-escrituras) # Variables de entorno (solo ignoramos las locales/sobre-escrituras)
# Mantenemos .env y .env.example versionados para compartir la configuración # Mantenemos .env y .env.example versionados en este repo privado.
# de producción y el esqueleto de variables. Nunca subimos .env.local. # Nunca subimos .env.local.
.env.local .env.local
.env.*.local .env.*.local
.env
# Override de Docker Compose para desarrollo local (no debe llegar a producción) # Override de Docker Compose para desarrollo local (no debe llegar a producción)
docker-compose.override.yml docker-compose.override.yml
+5
View File
@@ -0,0 +1,5 @@
__pycache__
*.pyc
.env
.venv
venv
+11 -5
View File
@@ -16,10 +16,10 @@
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=tu-client-secret GOOGLE_CLIENT_SECRET=tu-client-secret
# URI a la que Google redirige tras el login (debe estar registrada en Google Cloud) # 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 GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback
# Producción (añade la misma URI en Google Console → Credenciales OAuth): # Producción Sinbad2 (HTTPS vía Apache, prefijo /deckofcards):
# GOOGLE_REDIRECT_URI=http://tu-servidor:8071/api/auth/google/callback # 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) # Clave para firmar los JWT (usa algo largo y aleatorio en producción)
SECRET_KEY=cambia-esta-clave-en-produccion 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 # URL del frontend a la que se redirige tras el login con Google
# Con docker-compose en local: http://localhost:8071 # Con docker-compose en local: http://localhost:8071
# Con Vite directo en local: http://localhost:5173 # Con Vite directo en local: http://localhost:5173
# En producción: https://tu-dominio.com # Producción Sinbad2: https://sinbad2.ujaen.es/deckofcards
FRONTEND_URL=http://localhost:5173 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 # Verificación de email por código numérico
# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.). # El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.).
+12 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim FROM python:3.10-slim AS base
WORKDIR /app WORKDIR /app
@@ -8,4 +8,15 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
FROM base AS development
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] 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"]
View File
+41
View File
@@ -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()
+29 -8
View File
@@ -1,7 +1,12 @@
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 contextlib import asynccontextmanager from starlette.middleware.trustedhost import TrustedHostMiddleware
from api.config.settings import get_settings
from api.database.mongodb import db from api.database.mongodb import db
from api.middleware.security_headers import SecurityHeadersMiddleware
# Routers # Routers
from api.routers.test_mongo import router as test_mongo_router 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): async def lifespan(app: FastAPI):
yield yield
settings = get_settings()
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.add_middleware( app.add_middleware(SecurityHeadersMiddleware, settings=settings)
CORSMiddleware,
allow_origins=["*"], if settings.is_production:
allow_credentials=True, app.add_middleware(
allow_methods=["*"], TrustedHostMiddleware,
allow_headers=["*"], 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(test_mongo_router, prefix="/api")
app.include_router(value_router, prefix="/api/criteria/doc") app.include_router(value_router, prefix="/api/criteria/doc")
View File
@@ -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
+39
View File
@@ -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:
+3 -1
View File
@@ -1,7 +1,8 @@
services: services:
backend: backend:
build: build:
context: ./backend context: ./backend
target: development
container_name: backend container_name: backend
ports: ports:
- "8070:8000" - "8070:8000"
@@ -15,6 +16,7 @@ services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
target: development
container_name: frontend container_name: frontend
ports: ports:
- "8071:5173" - "8071:5173"
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local
+3 -2
View File
@@ -17,6 +17,7 @@
# - Backend ejecutado fuera de Docker (uvicorn --port 8000): # - Backend ejecutado fuera de Docker (uvicorn --port 8000):
# VITE_API_URL=http://localhost:8000/api # VITE_API_URL=http://localhost:8000/api
# #
# - Producción (ya definido en .env): # - Producción Sinbad2 (HTTPS, prefijo /deckofcards; build en docker-compose.prod.yaml):
# VITE_API_URL=http://sinbad2.ujaen.es:8070/api # VITE_BASE_PATH=/deckofcards/
# VITE_API_URL=/deckofcards/api
VITE_API_URL=http://localhost:8070/api VITE_API_URL=http://localhost:8070/api
+33 -2
View File
@@ -1,5 +1,36 @@
FROM node:20-alpine FROM node:20-alpine AS development
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
CMD ["npm", "run", "dev", "--", "--host"]
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;"]
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deck of Cards</title> <title>Deck of Cards</title>
</head> </head>
+25
View File
@@ -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;
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ export default function Header() {
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80" className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
> >
<img <img
src="/favicon.svg" src={`${import.meta.env.BASE_URL}favicon.svg`}
alt="Deck of Cards Logo" alt="Deck of Cards Logo"
className="h-10 w-10 rounded-xl object-contain shadow-sm" className="h-10 w-10 rounded-xl object-contain shadow-sm"
/> />
+4 -1
View File
@@ -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 = [ export const CHART_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#3b82f6',
+3 -2
View File
@@ -1,5 +1,6 @@
import Axios from 'axios'; import Axios from 'axios';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
import { isLoginPath, toAppPath } from './paths';
const api = Axios.create({ const api = Axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@@ -28,8 +29,8 @@ api.interceptors.response.use(
localStorage.removeItem('user'); localStorage.removeItem('user');
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login // SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
if (window.location.pathname !== '/login') { if (!isLoginPath(window.location.pathname)) {
window.location.href = '/login'; window.location.href = toAppPath('/login');
} }
} }
+19
View File
@@ -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';
};
+2 -1
View File
@@ -1,4 +1,5 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; 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 MainLayout from '../components/layout/MainLayout';
import DocEditor from '../pages/DocEditor'; import DocEditor from '../pages/DocEditor';
import Login from '../pages/Login'; import Login from '../pages/Login';
@@ -18,7 +19,7 @@ function ProtectedHistoryRoute() {
export default function AppRouter() { export default function AppRouter() {
return ( return (
<Router> <Router basename={APP_BASE_PATH || undefined}>
<MainLayout> <MainLayout>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/editor" replace />} /> <Route path="/" element={<Navigate to="/editor" replace />} />
+3
View File
@@ -2,8 +2,11 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
const basePath = process.env.VITE_BASE_PATH || '/'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: basePath,
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),