diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 1c04c01..2e2c295 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -9,8 +9,7 @@ from app.core.config import settings from app.core.rate_limit import limiter from app.db.models import Publication, PublicationDownload, Researcher from app.db.session import get_db -from app.security.api_key import get_api_key -from app.security.jwt import get_optional_current_researcher +from app.security.export_auth import require_export_access from app.services.sword_generator import SWORDGenerator from app.services.zip_generator import ZIPGenerator from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid @@ -89,8 +88,7 @@ async def export_multiple_sword( request: Request, pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH), db: Session = Depends(get_db), - _: str = Depends(get_api_key), - current: Researcher | None = Depends(get_optional_current_researcher), + current: Researcher | None = Depends(require_export_access), ): _validate_pub_ids(pub_ids) @@ -118,8 +116,7 @@ async def export_researcher_sword( request: Request, orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), - _: str = Depends(get_api_key), - current: Researcher | None = Depends(get_optional_current_researcher), + current: Researcher | None = Depends(require_export_access), ): if not is_valid_orcid(orcid_id): raise HTTPException(status_code=400, detail="Invalid ORCID iD") @@ -149,8 +146,7 @@ async def export_multiple_zip( request: Request, pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH), db: Session = Depends(get_db), - _: str = Depends(get_api_key), - current: Researcher | None = Depends(get_optional_current_researcher), + current: Researcher | None = Depends(require_export_access), ): _validate_pub_ids(pub_ids) @@ -178,8 +174,7 @@ async def export_researcher_zip( request: Request, orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), - _: str = Depends(get_api_key), - current: Researcher | None = Depends(get_optional_current_researcher), + current: Researcher | None = Depends(require_export_access), ): if not is_valid_orcid(orcid_id): raise HTTPException(status_code=400, detail="Invalid ORCID iD") diff --git a/backend/app/security/api_key.py b/backend/app/security/api_key.py index c4b4336..19e32c2 100644 --- a/backend/app/security/api_key.py +++ b/backend/app/security/api_key.py @@ -27,6 +27,10 @@ def _is_valid_key(provided: str | None) -> bool: return hmac.compare_digest(provided.encode("utf-8"), settings.API_KEY_VALUE.encode("utf-8")) +def is_valid_api_key(provided: str | None) -> bool: + return _is_valid_key(provided) + + def get_api_key(api_key: str | None = Depends(api_key_header)) -> str: if not _is_valid_key(api_key): raise HTTPException( diff --git a/backend/app/security/export_auth.py b/backend/app/security/export_auth.py new file mode 100644 index 0000000..6451a45 --- /dev/null +++ b/backend/app/security/export_auth.py @@ -0,0 +1,35 @@ +""" +Autorización para exportaciones. + +Permite descargas desde la web (proxy inyecta X-API-Key) o con JWT de usuario, +pero bloquea llamadas directas anónimas sin credenciales. +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, status + +from app.db.models import Researcher +from app.security.api_key import api_key_header, is_valid_api_key +from app.security.jwt import get_optional_current_researcher + + +def require_export_access( + api_key: str | None = Depends(api_key_header), + current: Researcher | None = Depends(get_optional_current_researcher), +) -> Researcher | None: + if api_key is not None: + if not is_valid_api_key(api_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) + return current + + if current is not None: + return current + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key", + ) diff --git a/frontend/.env.example b/frontend/.env.example index f61b7bf..5095694 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ VITE_API_URL=https://api.tudominio.com/api VITE_API_PROXY_TARGET= -VITE_API_KEY= +# Debe coincidir con API_KEY_VALUE del backend. La inyecta el proxy (Vite/nginx). +VITE_API_KEY= VITE_USE_MOCKS=false \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6b9fd82..b8faa79 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -20,4 +20,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/dist /app/dist EXPOSE 5173 -CMD ["nginx", "-g", "daemon off;"] +CMD ["/bin/sh", "-c", "sed -i \"s|__API_KEY__|${API_KEY_VALUE:-$VITE_API_KEY}|g\" /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ac315ac..5760fdc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -49,6 +49,8 @@ server { 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; + # Sustituido al arrancar el contenedor (ver CMD en Dockerfile). + proxy_set_header X-API-Key __API_KEY__; } } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 443cad2..bd54e73 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -10,17 +10,18 @@ * (p. ej. `http://localhost:8000/api`). Si se deja vacío, las * peticiones se hacen contra `/api` y las redirige el proxy de * Vite (ver `vite.config.js`). - * - `VITE_API_KEY`: clave compartida con el backend, se manda en el - * header `X-API-Key` de TODAS las peticiones. + * - `VITE_API_KEY` / `API_KEY_VALUE`: opcional en el navegador. En + * producción y dev la inyecta el proxy (nginx / Vite). Solo hace falta + * en el bundle si el front llama al backend sin proxy intermedio. * * Contrato actual del backend (todo bajo `/api`): * - GET /researchers/search → buscador grupal (todo en uno) * - GET /researchers/search/{orcid_id} → buscador individual (todo en uno) * - POST /researchers/{orcid_id}/sync → re-sync manual - * - POST /export/sword/publications body=[ids] → SWORD XML de selección (requiere X-API-Key) - * - POST /export/zip/publications body=[ids] → ZIP de selección (requiere X-API-Key) - * - GET /export/sword/researcher/{orcid_id} → SWORD XML de todo el investigador (requiere X-API-Key) - * - GET /export/zip/researcher/{orcid_id} → ZIP de todo el investigador (requiere X-API-Key) + * - POST /export/sword/publications body=[ids] → SWORD XML (API key o JWT) + * - POST /export/zip/publications body=[ids] → ZIP (API key o JWT) + * - GET /export/sword/researcher/{orcid_id} → SWORD XML (API key o JWT) + * - GET /export/zip/researcher/{orcid_id} → ZIP (API key o JWT) */ import { @@ -35,6 +36,7 @@ import { const BASE_URL = (import.meta.env.VITE_API_URL ? import.meta.env.VITE_API_URL : `${import.meta.env.BASE_URL}api`).replace(/\/$/, ""); +// Fallback solo si no hay proxy que inyecte la cabecera (p. ej. VITE_API_URL absoluta). const API_KEY = import.meta.env.VITE_API_KEY ?? ""; const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true"; @@ -81,16 +83,10 @@ export class ApiError extends Error { } /** - * Construye la cabecera base que llevan TODAS las peticiones (incluidas - * las descargas de blob). Incluye X-API-Key siempre y, si existe un JWT - * en localStorage, también Authorization: Bearer . + * Cabeceras base del navegador. El proxy (nginx/Vite) inyecta X-API-Key en + * rutas /api; aquí solo la añadimos como fallback directo al backend. */ function buildAuthHeaders(extra = {}) { - if (!API_KEY && import.meta.env.DEV) { - console.warn( - "[api] VITE_API_KEY no está definida; las peticiones serán rechazadas por el backend.", - ); - } const token = localStorage.getItem("orcid_auth_token"); return { Accept: "application/json", @@ -390,9 +386,8 @@ function exportSegmentFor(format) { * dato meramente informativo en los toasts de éxito; las descargas * reales se disparan vía blob para poder forzar el download. * - * El backend exige la cabecera `X-API-Key` (misma que `VITE_API_KEY` en el - * front). No sirven como `` simple: hay que descargar con `fetch` - * (p. ej. `downloadExport`) o añadir la cabecera de otro modo. + * Requiere API key (inyectada por proxy) o JWT. No sirve como `` simple; + * usar `downloadExport` para POST con IDs o Bearer opcional. */ export function getExportUrl(orcidId, format) { const segment = exportSegmentFor(format); @@ -420,13 +415,6 @@ export async function downloadExport( return { blob: null, url: getExportUrl(orcidId, format) }; } - if (!API_KEY) { - throw new ApiError( - "Configura VITE_API_KEY (debe coincidir con API_KEY_VALUE del backend): las exportaciones exigen la cabecera X-API-Key.", - { status: 401, payload: { missingApiKey: true } }, - ); - } - const segment = exportSegmentFor(format); const ids = Array.isArray(publicationIds) && publicationIds.length > 0 diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a1b565e..7c20999 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,6 +6,19 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, import.meta.dirname, '') const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000' const base = env.VITE_BASE_PATH || '/' + const proxyApiKey = env.API_KEY_VALUE || env.VITE_API_KEY || '' + + const proxyCommon = { + target: proxyTarget, + changeOrigin: true, + ...(proxyApiKey && { + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.setHeader('X-API-Key', proxyApiKey) + }) + }, + }), + } return { base, @@ -15,16 +28,15 @@ export default defineConfig(({ mode }) => { allowedHosts: true, port: 5173, proxy: { - '/api': { target: proxyTarget, changeOrigin: true }, + '/api': proxyCommon, '/health': { target: proxyTarget, changeOrigin: true }, ...(base !== '/' && { [`${base}api`]: { - target: proxyTarget, - changeOrigin: true, - rewrite: (path) => path.replace(new RegExp(`^${base}api`), '/api') - } + ...proxyCommon, + rewrite: (path) => path.replace(new RegExp(`^${base}api`), '/api'), + }, }), }, }, } -}) \ No newline at end of file +})