diff --git a/.gitignore b/.gitignore index e327dab..a8ebeb5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ # Variables de entorno .env +.env* + # Configuraciones del editor .vscode/ diff --git a/backend/api/main.py b/backend/api/main.py index d4c740d..775db40 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -14,12 +14,14 @@ from api.routers.auth import router as auth_router from api.routers.history import router as history_router from api.routers.test_mongo import router as test_mongo_router from api.routers.docit2mf_build import router as docit2mf_router +from api.routers.google_auth import router as google_auth_router + + + @asynccontextmanager async def lifespan(app: FastAPI): - # Aquí podrías hacer comprobaciones si quieres yield - # No hace falta cerrar nada con Motor app = FastAPI(lifespan=lifespan) @@ -41,3 +43,4 @@ app.include_router(test_mongo_router, prefix="/api") app.include_router(auth_router, prefix="/api") app.include_router(history_router, prefix="/api") app.include_router(docit2mf_router, prefix="/api") +app.include_router(google_auth_router, prefix="/api") \ No newline at end of file diff --git a/backend/api/routers/docit2mf_build.py b/backend/api/routers/docit2mf_build.py index 75660f4..ba4c06a 100644 --- a/backend/api/routers/docit2mf_build.py +++ b/backend/api/routers/docit2mf_build.py @@ -1,13 +1,19 @@ # api/routers/docit2mf_build.py +import logging from fastapi import APIRouter, HTTPException +from slowapi import Limiter +from slowapi.util import get_remote_address from api.models.docit2mf_models import DoCIT2MFMultiRequest from api.services.docit2mf_build_service import build_it2mf_from_level router = APIRouter(prefix="/criteria", tags=["criteria"]) +limiter = Limiter(key_func=get_remote_address) +logger = logging.getLogger(__name__) @router.post("/doc-it2mf/build") +@limiter.limit("10/minute") async def build_doc_it2mf(request: DoCIT2MFMultiRequest): results = [] @@ -15,6 +21,10 @@ async def build_doc_it2mf(request: DoCIT2MFMultiRequest): for level in request.levels: results.append(build_it2mf_from_level(level)) except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + logger.warning(f"Validation error in doc-it2mf/build: {str(e)}") + raise HTTPException(status_code=400, detail="Invalid input data") + except Exception as e: + logger.error(f"Unexpected error in doc-it2mf/build: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") return {"levels": results} diff --git a/backend/api/routers/google_auth.py b/backend/api/routers/google_auth.py new file mode 100644 index 0000000..3fec964 --- /dev/null +++ b/backend/api/routers/google_auth.py @@ -0,0 +1,98 @@ +# api/routers/google_auth.py + +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from bson import ObjectId +import httpx +import os +import jwt + +from api.database.mongodb import users_collection +from api.utils.security import create_access_token + +router = APIRouter(prefix="/auth/google", tags=["auth"]) + +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") +REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI") + + +# ----------------------------- +# 1. LOGIN → REDIRECCIÓN A GOOGLE +# ----------------------------- +@router.get("/login") +async def google_login(): + google_auth_url = ( + "https://accounts.google.com/o/oauth2/auth" + "?response_type=code" + f"&client_id={GOOGLE_CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + "&scope=openid%20email%20profile" + "&access_type=offline" + "&prompt=consent" + ) + + return RedirectResponse(google_auth_url) + + + +# ----------------------------- +# 2. CALLBACK → GOOGLE DEVUELVE EL CODE +# ----------------------------- +@router.get("/callback") +async def google_callback(request: Request): + + code = request.query_params.get("code") + if not code: + raise HTTPException(status_code=400, detail="Missing code parameter") + + token_url = "https://oauth2.googleapis.com/token" + + data = { + "code": code, + "client_id": GOOGLE_CLIENT_ID, + "client_secret": GOOGLE_CLIENT_SECRET, + "redirect_uri": REDIRECT_URI, + "grant_type": "authorization_code", + } + + async with httpx.AsyncClient() as client: + token_response = await client.post(token_url, data=data) + token_json = token_response.json() + + if "access_token" not in token_json: + raise HTTPException(status_code=400, detail=token_json) + + access_token = token_json["access_token"] + + async with httpx.AsyncClient() as client: + userinfo = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={"Authorization": f"Bearer {access_token}"} + ) + + user_data = userinfo.json() + + google_id = user_data["id"] + email = user_data["email"] + name = user_data.get("name", "Usuario") + + user = await users_collection.find_one({"email": email}) + + if not user: + new_user = { + "username": name, + "email": email, + "password_hash": None, + "google_id": google_id, + "history": [], + } + result = await users_collection.insert_one(new_user) + user_id = result.inserted_id + else: + user_id = user["_id"] + + token = create_access_token({"user_id": str(user_id)}) + + return {"message": "Login con Google exitoso", "token": token} diff --git a/backend/api/services/docit2mf_build_service.py b/backend/api/services/docit2mf_build_service.py index a18d5d8..82083ac 100644 --- a/backend/api/services/docit2mf_build_service.py +++ b/backend/api/services/docit2mf_build_service.py @@ -15,7 +15,6 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li result = [] for item in values: if isinstance(item, int): - # valor fijo → mismo para LMF y UMF result.append(item) else: lo, hi = item @@ -23,8 +22,45 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li return result +def _sort_nodes(nodes): + """Ordena los nodos por su coordenada X.""" + return sorted(nodes, key=lambda p: p[0]) + + +def _enforce_upper_ge_lower(lower, upper): + """ + Garantiza que la UMF (upper) nunca quede por debajo de la LMF (lower). + Ajusta los valores de pertenencia si es necesario. + """ + # left nodes + for i in range(len(lower["left_nodes"])): + lx, ly = lower["left_nodes"][i] + ux, uy = upper["left_nodes"][i] + upper["left_nodes"][i][1] = max(uy, ly) + + # right nodes + for i in range(len(lower["right_nodes"])): + lx, ly = lower["right_nodes"][i] + ux, uy = upper["right_nodes"][i] + upper["right_nodes"][i][1] = max(uy, ly) + + return upper + + def build_it2mf_from_level(level: DoCIT2MFRequest): + """ + Construye una función IT2MF a partir de un nivel con intervalos de cartas blancas. + Devuelve: + { + "term": ..., + "lower": {...}, + "upper": {...} + } + """ + + # ------------------------- # LMF (mínimos) + # ------------------------- left_min = _extract_bounds(level.left_blank_cards, "min") right_min = _extract_bounds(level.right_blank_cards, "min") @@ -39,7 +75,13 @@ def build_it2mf_from_level(level: DoCIT2MFRequest): ) lower = build_doc_mf_level(lower_level) + # Ordenar nodos LMF + lower["left_nodes"] = _sort_nodes(lower["left_nodes"]) + lower["right_nodes"] = _sort_nodes(lower["right_nodes"]) + + # ------------------------- # UMF (máximos) + # ------------------------- left_max = _extract_bounds(level.left_blank_cards, "max") right_max = _extract_bounds(level.right_blank_cards, "max") @@ -54,6 +96,15 @@ def build_it2mf_from_level(level: DoCIT2MFRequest): ) upper = build_doc_mf_level(upper_level) + # Ordenar nodos UMF + upper["left_nodes"] = _sort_nodes(upper["left_nodes"]) + upper["right_nodes"] = _sort_nodes(upper["right_nodes"]) + + # ------------------------- + # FIX: evitar inversión vertical (UMF < LMF) + # ------------------------- + upper = _enforce_upper_ge_lower(lower, upper) + return { "term": level.term, "lower": lower, diff --git a/backend/api/utils/security.py b/backend/api/utils/security.py index 5826361..f6abe1c 100644 --- a/backend/api/utils/security.py +++ b/backend/api/utils/security.py @@ -4,6 +4,10 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from api.database.mongodb import users_collection from bson import ObjectId +import os +import jwt + +SECRET_KEY = os.getenv("SECRET_KEY") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -17,7 +21,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def generate_token() -> str: - return secrets.token_hex(32) # 64 caracteres seguros + return secrets.token_hex(32) security_scheme = HTTPBearer() @@ -35,6 +39,8 @@ async def get_current_user( detail="Token inválido o usuario no autenticado", ) - # devolvemos el documento tal cual (dict) user["id"] = str(user["_id"]) - return user \ No newline at end of file + return user + +def create_access_token(data: dict): + return jwt.encode(data, SECRET_KEY, algorithm="HS256") diff --git a/backend/requirements.txt b/backend/requirements.txt index 26ebe6e..6387273 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,6 @@ pydantic passlib bcrypt==4.0.1 email-validator +slowapi +httpx +PyJWT diff --git a/docker-compose.yaml b/docker-compose.yaml index f0fa360..aead62e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.9" - services: backend: build: @@ -11,6 +9,8 @@ services: - ./backend:/app depends_on: - db + env_file: + - backend\.env frontend: build: