fix: update frontend API key handling and improve export documentation
This commit is contained in:
@@ -9,8 +9,7 @@ from app.core.config import settings
|
|||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.db.models import Publication, PublicationDownload, Researcher
|
from app.db.models import Publication, PublicationDownload, Researcher
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.security.api_key import get_api_key
|
from app.security.export_auth import require_export_access
|
||||||
from app.security.jwt import get_optional_current_researcher
|
|
||||||
from app.services.sword_generator import SWORDGenerator
|
from app.services.sword_generator import SWORDGenerator
|
||||||
from app.services.zip_generator import ZIPGenerator
|
from app.services.zip_generator import ZIPGenerator
|
||||||
from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid
|
from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid
|
||||||
@@ -89,8 +88,7 @@ async def export_multiple_sword(
|
|||||||
request: Request,
|
request: Request,
|
||||||
pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH),
|
pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(get_api_key),
|
current: Researcher | None = Depends(require_export_access),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
|
||||||
):
|
):
|
||||||
_validate_pub_ids(pub_ids)
|
_validate_pub_ids(pub_ids)
|
||||||
|
|
||||||
@@ -118,8 +116,7 @@ async def export_researcher_sword(
|
|||||||
request: Request,
|
request: Request,
|
||||||
orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN),
|
orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(get_api_key),
|
current: Researcher | None = Depends(require_export_access),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
|
||||||
):
|
):
|
||||||
if not is_valid_orcid(orcid_id):
|
if not is_valid_orcid(orcid_id):
|
||||||
raise HTTPException(status_code=400, detail="Invalid ORCID iD")
|
raise HTTPException(status_code=400, detail="Invalid ORCID iD")
|
||||||
@@ -149,8 +146,7 @@ async def export_multiple_zip(
|
|||||||
request: Request,
|
request: Request,
|
||||||
pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH),
|
pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(get_api_key),
|
current: Researcher | None = Depends(require_export_access),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
|
||||||
):
|
):
|
||||||
_validate_pub_ids(pub_ids)
|
_validate_pub_ids(pub_ids)
|
||||||
|
|
||||||
@@ -178,8 +174,7 @@ async def export_researcher_zip(
|
|||||||
request: Request,
|
request: Request,
|
||||||
orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN),
|
orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: str = Depends(get_api_key),
|
current: Researcher | None = Depends(require_export_access),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
|
||||||
):
|
):
|
||||||
if not is_valid_orcid(orcid_id):
|
if not is_valid_orcid(orcid_id):
|
||||||
raise HTTPException(status_code=400, detail="Invalid ORCID iD")
|
raise HTTPException(status_code=400, detail="Invalid ORCID iD")
|
||||||
|
|||||||
@@ -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"))
|
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:
|
def get_api_key(api_key: str | None = Depends(api_key_header)) -> str:
|
||||||
if not _is_valid_key(api_key):
|
if not _is_valid_key(api_key):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
VITE_API_URL=https://api.tudominio.com/api
|
VITE_API_URL=https://api.tudominio.com/api
|
||||||
VITE_API_PROXY_TARGET=
|
VITE_API_PROXY_TARGET=
|
||||||
VITE_API_KEY=<misma API_KEY_VALUE backend si la usas desde frontend>
|
# Debe coincidir con API_KEY_VALUE del backend. La inyecta el proxy (Vite/nginx).
|
||||||
|
VITE_API_KEY=
|
||||||
VITE_USE_MOCKS=false
|
VITE_USE_MOCKS=false
|
||||||
+1
-1
@@ -20,4 +20,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
COPY --from=builder /app/dist /app/dist
|
COPY --from=builder /app/dist /app/dist
|
||||||
|
|
||||||
EXPOSE 5173
|
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;'"]
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Sustituido al arrancar el contenedor (ver CMD en Dockerfile).
|
||||||
|
proxy_set_header X-API-Key __API_KEY__;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,18 @@
|
|||||||
* (p. ej. `http://localhost:8000/api`). Si se deja vacío, las
|
* (p. ej. `http://localhost:8000/api`). Si se deja vacío, las
|
||||||
* peticiones se hacen contra `/api` y las redirige el proxy de
|
* peticiones se hacen contra `/api` y las redirige el proxy de
|
||||||
* Vite (ver `vite.config.js`).
|
* Vite (ver `vite.config.js`).
|
||||||
* - `VITE_API_KEY`: clave compartida con el backend, se manda en el
|
* - `VITE_API_KEY` / `API_KEY_VALUE`: opcional en el navegador. En
|
||||||
* header `X-API-Key` de TODAS las peticiones.
|
* 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`):
|
* Contrato actual del backend (todo bajo `/api`):
|
||||||
* - GET /researchers/search → buscador grupal (todo en uno)
|
* - GET /researchers/search → buscador grupal (todo en uno)
|
||||||
* - GET /researchers/search/{orcid_id} → buscador individual (todo en uno)
|
* - GET /researchers/search/{orcid_id} → buscador individual (todo en uno)
|
||||||
* - POST /researchers/{orcid_id}/sync → re-sync manual
|
* - 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/sword/publications body=[ids] → SWORD XML (API key o JWT)
|
||||||
* - POST /export/zip/publications body=[ids] → ZIP de selección (requiere X-API-Key)
|
* - POST /export/zip/publications body=[ids] → ZIP (API key o JWT)
|
||||||
* - GET /export/sword/researcher/{orcid_id} → SWORD XML de todo el investigador (requiere X-API-Key)
|
* - GET /export/sword/researcher/{orcid_id} → SWORD XML (API key o JWT)
|
||||||
* - GET /export/zip/researcher/{orcid_id} → ZIP de todo el investigador (requiere X-API-Key)
|
* - GET /export/zip/researcher/{orcid_id} → ZIP (API key o JWT)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
const BASE_URL = (import.meta.env.VITE_API_URL
|
const BASE_URL = (import.meta.env.VITE_API_URL
|
||||||
? import.meta.env.VITE_API_URL
|
? import.meta.env.VITE_API_URL
|
||||||
: `${import.meta.env.BASE_URL}api`).replace(/\/$/, "");
|
: `${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 API_KEY = import.meta.env.VITE_API_KEY ?? "";
|
||||||
|
|
||||||
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
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
|
* Cabeceras base del navegador. El proxy (nginx/Vite) inyecta X-API-Key en
|
||||||
* las descargas de blob). Incluye X-API-Key siempre y, si existe un JWT
|
* rutas /api; aquí solo la añadimos como fallback directo al backend.
|
||||||
* en localStorage, también Authorization: Bearer <token>.
|
|
||||||
*/
|
*/
|
||||||
function buildAuthHeaders(extra = {}) {
|
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");
|
const token = localStorage.getItem("orcid_auth_token");
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -390,9 +386,8 @@ function exportSegmentFor(format) {
|
|||||||
* dato meramente informativo en los toasts de éxito; las descargas
|
* dato meramente informativo en los toasts de éxito; las descargas
|
||||||
* reales se disparan vía blob para poder forzar el download.
|
* 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
|
* Requiere API key (inyectada por proxy) o JWT. No sirve como `<a href>` simple;
|
||||||
* front). No sirven como `<a href>` simple: hay que descargar con `fetch`
|
* usar `downloadExport` para POST con IDs o Bearer opcional.
|
||||||
* (p. ej. `downloadExport`) o añadir la cabecera de otro modo.
|
|
||||||
*/
|
*/
|
||||||
export function getExportUrl(orcidId, format) {
|
export function getExportUrl(orcidId, format) {
|
||||||
const segment = exportSegmentFor(format);
|
const segment = exportSegmentFor(format);
|
||||||
@@ -420,13 +415,6 @@ export async function downloadExport(
|
|||||||
return { blob: null, url: getExportUrl(orcidId, format) };
|
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 segment = exportSegmentFor(format);
|
||||||
const ids =
|
const ids =
|
||||||
Array.isArray(publicationIds) && publicationIds.length > 0
|
Array.isArray(publicationIds) && publicationIds.length > 0
|
||||||
|
|||||||
+18
-6
@@ -6,6 +6,19 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, import.meta.dirname, '')
|
const env = loadEnv(mode, import.meta.dirname, '')
|
||||||
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
||||||
const base = env.VITE_BASE_PATH || '/'
|
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 {
|
return {
|
||||||
base,
|
base,
|
||||||
@@ -15,16 +28,15 @@ export default defineConfig(({ mode }) => {
|
|||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: proxyTarget, changeOrigin: true },
|
'/api': proxyCommon,
|
||||||
'/health': { target: proxyTarget, changeOrigin: true },
|
'/health': { target: proxyTarget, changeOrigin: true },
|
||||||
...(base !== '/' && {
|
...(base !== '/' && {
|
||||||
[`${base}api`]: {
|
[`${base}api`]: {
|
||||||
target: proxyTarget,
|
...proxyCommon,
|
||||||
changeOrigin: true,
|
rewrite: (path) => path.replace(new RegExp(`^${base}api`), '/api'),
|
||||||
rewrite: (path) => path.replace(new RegExp(`^${base}api`), '/api')
|
},
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user