diff --git a/backend/.env.example b/backend/.env.example index f420a06..277157a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,7 +15,11 @@ # Esta URI debe estar registrada también en la consola de Google Cloud. GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=tu-client-secret -GOOGLE_REDIRECT_URI=http://localhost:8070/api/auth/google/callback +# 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 +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 # Clave para firmar los JWT (usa algo largo y aleatorio en producción) SECRET_KEY=cambia-esta-clave-en-produccion @@ -27,16 +31,41 @@ SECRET_KEY=cambia-esta-clave-en-produccion FRONTEND_URL=http://localhost:5173 # Verificación de email por código numérico -# Si SMTP_HOST no está configurado, el backend imprimirá el código en consola. +# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.). +# El fallo de envío suele deberse a SMTP mal configurado, no al dominio del usuario. EMAIL_VERIFICATION_CODE_TTL_MINUTES=15 EMAIL_VERIFICATION_MAX_ATTEMPTS=5 EMAIL_VERIFICATION_SECRET=cambia-este-secreto-en-produccion -# SMTP para enviar los códigos de verificación +# Solo desarrollo local: imprime el código en logs si SMTP falla o no existe +# EMAIL_VERIFICATION_DEV_LOG_CODE=true +# Muestra el error SMTP real en la respuesta HTTP (no usar en producción) +# EMAIL_VERIFICATION_SHOW_ERRORS=true + +# SMTP para enviar los códigos de verificación (cuenta REMITENTE, no la del usuario) +# Gmail: crea una "Contraseña de aplicación" en https://myaccount.google.com/apppasswords +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USE_SSL=false +# SMTP_USERNAME=tu-correo@gmail.com +# SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx +# SMTP_FROM_EMAIL=tu-correo@gmail.com + +# Outlook / Hotmail / Microsoft 365 (cuenta remitente @outlook.com, @hotmail.com, etc.) +# SMTP_HOST=smtp-mail.outlook.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USE_SSL=false +# SMTP_USERNAME=tu-correo@hotmail.com +# SMTP_PASSWORD=tu-contraseña-o-app-password +# SMTP_FROM_EMAIL=tu-correo@hotmail.com + SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USE_TLS=true SMTP_USE_SSL=false +SMTP_TIMEOUT_SECONDS=30 SMTP_USERNAME=tu-correo@gmail.com SMTP_PASSWORD=tu-password-o-app-password SMTP_FROM_EMAIL=tu-correo@gmail.com diff --git a/backend/api/routers/auth.py b/backend/api/routers/auth.py index f05aa5d..34e0ba8 100644 --- a/backend/api/routers/auth.py +++ b/backend/api/routers/auth.py @@ -1,3 +1,5 @@ +import logging +import os from datetime import datetime from bson import ObjectId @@ -10,7 +12,11 @@ from api.models.user_models import ( UserCreate, UserLogin, ) -from api.services.email_service import send_verification_email +from api.services.email_service import ( + EmailConfigurationError, + EmailDeliveryError, + send_verification_email, +) from api.utils.email_verification import ( MAX_EMAIL_VERIFICATION_ATTEMPTS, generate_verification_code, @@ -27,6 +33,40 @@ from api.utils.security import ( ) router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger(__name__) + + +def _env_bool(name: str, default: str = "false") -> bool: + return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} + + +def _email_send_error_detail(exc: Exception) -> str: + if _env_bool("EMAIL_VERIFICATION_SHOW_ERRORS"): + return f"No se pudo enviar el correo de verificación: {exc}" + return "No se pudo enviar el correo de verificación" + + +async def _dispatch_verification_email(email: str, username: str, code: str) -> None: + try: + sent = send_verification_email(email, username, code) + except (EmailConfigurationError, EmailDeliveryError) as exc: + logger.error("Fallo al enviar verificación a %s: %s", email, exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_email_send_error_detail(exc), + ) from exc + except Exception as exc: + logger.exception("Error inesperado al enviar verificación a %s", email) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_email_send_error_detail(exc), + ) from exc + + if not sent: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No se pudo enviar el correo de verificación", + ) @router.post("/register") @@ -68,13 +108,10 @@ async def register_user(user: UserCreate): result = await users_collection.insert_one(user_doc) try: - send_verification_email(email, user.username, verification_code) - except Exception as exc: + await _dispatch_verification_email(email, user.username, verification_code) + except HTTPException: await users_collection.delete_one({"_id": result.inserted_id}) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No se pudo enviar el correo de verificación", - ) from exc + raise return { "message": "Usuario registrado. Revisa tu correo para verificar la cuenta.", @@ -214,13 +251,7 @@ async def resend_verification_email(payload: EmailVerificationResendRequest): }, ) - try: - send_verification_email(email, user["username"], verification_code) - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No se pudo enviar el correo de verificación", - ) from exc + await _dispatch_verification_email(email, user["username"], verification_code) return {"message": "Nuevo código de verificación enviado"} diff --git a/backend/api/routers/google_auth.py b/backend/api/routers/google_auth.py index c168acb..316cdfc 100644 --- a/backend/api/routers/google_auth.py +++ b/backend/api/routers/google_auth.py @@ -1,9 +1,11 @@ +from datetime import datetime, timedelta +from urllib.parse import quote + +import httpx +import jwt +import os from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse -from datetime import datetime, timedelta -import httpx -import os -import jwt from api.database.mongodb import users_collection @@ -17,11 +19,18 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") @router.get("/login") async def google_login(): + if not GOOGLE_CLIENT_ID or not REDIRECT_URI: + raise HTTPException( + status_code=500, + detail="GOOGLE_CLIENT_ID o GOOGLE_REDIRECT_URI no configurados", + ) + + redirect_uri = quote(REDIRECT_URI, safe="") google_auth_url = ( "https://accounts.google.com/o/oauth2/auth" "?response_type=code" f"&client_id={GOOGLE_CLIENT_ID}" - f"&redirect_uri={REDIRECT_URI}" + f"&redirect_uri={redirect_uri}" "&scope=openid%20email%20profile" "&access_type=offline" "&prompt=consent" diff --git a/backend/api/services/email_service.py b/backend/api/services/email_service.py index 8976813..86bca83 100644 --- a/backend/api/services/email_service.py +++ b/backend/api/services/email_service.py @@ -1,12 +1,24 @@ import html +import logging import os import smtplib import ssl from datetime import datetime from email.message import EmailMessage +from email.utils import formataddr from api.utils.email_verification import EMAIL_VERIFICATION_CODE_TTL_MINUTES +logger = logging.getLogger(__name__) + + +class EmailConfigurationError(RuntimeError): + """SMTP no está configurado o falta información obligatoria.""" + + +class EmailDeliveryError(RuntimeError): + """El servidor SMTP rechazó el envío o no se pudo conectar.""" + def _env_bool(name: str, default: str = "false") -> bool: return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} @@ -142,46 +154,142 @@ def _build_verification_html(username: str, code: str) -> str: """ -def send_verification_email(recipient_email: str, username: str, code: str) -> bool: - smtp_host = os.getenv("SMTP_HOST") - if not smtp_host: - print( - "[email-verification] SMTP_HOST no configurado. " - f"Código para {recipient_email}: {code}" +def _log_verification_code_for_dev(recipient_email: str, code: str) -> None: + logger.warning( + "[email-verification] Modo desarrollo: código para %s → %s", + recipient_email, + code, + ) + + +def _build_verification_message( + recipient_email: str, + username: str, + code: str, + smtp_from_email: str, + smtp_from_name: str, +) -> EmailMessage: + message = EmailMessage() + message["Subject"] = "Codigo de verificacion - Deck of Cards" + message["From"] = formataddr((smtp_from_name, smtp_from_email)) + message["To"] = recipient_email + message["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + message.set_content(_build_verification_plain(username, code)) + message.add_alternative(_build_verification_html(username, code), subtype="html") + return message + + +def _send_via_smtp( + message: EmailMessage, + recipient_email: str, + smtp_from_email: str, + smtp_host: str, + smtp_port: int, + smtp_username: str | None, + smtp_password: str | None, + use_tls: bool, + use_ssl: bool, +) -> None: + context = ssl.create_default_context() + timeout = int(os.getenv("SMTP_TIMEOUT_SECONDS", "30")) + debug_level = 1 if _env_bool("SMTP_DEBUG") else 0 + + try: + if use_ssl: + server = smtplib.SMTP_SSL( + smtp_host, + smtp_port, + timeout=timeout, + context=context, + ) + else: + server = smtplib.SMTP(smtp_host, smtp_port, timeout=timeout) + + with server: + server.set_debuglevel(debug_level) + server.ehlo() + if use_tls and not use_ssl: + server.starttls(context=context) + server.ehlo() + if smtp_username and smtp_password: + server.login(smtp_username, smtp_password) + server.sendmail( + smtp_from_email, + [recipient_email], + message.as_string(), + ) + except smtplib.SMTPException as exc: + logger.exception( + "Error SMTP al enviar verificación a %s via %s:%s", + recipient_email, + smtp_host, + smtp_port, + ) + raise EmailDeliveryError(str(exc)) from exc + except OSError as exc: + logger.exception( + "No se pudo conectar al servidor SMTP %s:%s", + smtp_host, + smtp_port, + ) + raise EmailDeliveryError(str(exc)) from exc + + +def send_verification_email(recipient_email: str, username: str, code: str) -> bool: + recipient_email = recipient_email.strip().lower() + smtp_host = os.getenv("SMTP_HOST", "").strip() + dev_log_code = _env_bool("EMAIL_VERIFICATION_DEV_LOG_CODE") + + if not smtp_host: + if dev_log_code: + _log_verification_code_for_dev(recipient_email, code) + return True + logger.error( + "[email-verification] SMTP_HOST no configurado; no se envió correo a %s", + recipient_email, + ) + raise EmailConfigurationError( + "SMTP_HOST no está configurado en el servidor" ) - return False smtp_port = int(os.getenv("SMTP_PORT", "587")) - smtp_username = os.getenv("SMTP_USERNAME") - smtp_password = os.getenv("SMTP_PASSWORD") - smtp_from_email = os.getenv("SMTP_FROM_EMAIL") or smtp_username + smtp_username = (os.getenv("SMTP_USERNAME") or "").strip() + smtp_password = (os.getenv("SMTP_PASSWORD") or "").replace(" ", "") + smtp_from_email = (os.getenv("SMTP_FROM_EMAIL") or smtp_username or "").strip() smtp_from_name = os.getenv("SMTP_FROM_NAME", "Deck of Cards") use_tls = _env_bool("SMTP_USE_TLS", "true") use_ssl = _env_bool("SMTP_USE_SSL", "false") if not smtp_from_email: - raise RuntimeError("SMTP_FROM_EMAIL o SMTP_USERNAME debe estar configurado") + raise EmailConfigurationError( + "SMTP_FROM_EMAIL o SMTP_USERNAME debe estar configurado" + ) - message = EmailMessage() - message["Subject"] = "Código de verificación — Deck of Cards" - message["From"] = f"{smtp_from_name} <{smtp_from_email}>" - message["To"] = recipient_email - message.set_content(_build_verification_plain(username, code)) - message.add_alternative(_build_verification_html(username, code), subtype="html") + message = _build_verification_message( + recipient_email, + username, + code, + smtp_from_email, + smtp_from_name, + ) - context = ssl.create_default_context() - - if use_ssl: - with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as server: - if smtp_username and smtp_password: - server.login(smtp_username, smtp_password) - server.send_message(message) - else: - with smtplib.SMTP(smtp_host, smtp_port) as server: - if use_tls: - server.starttls(context=context) - if smtp_username and smtp_password: - server.login(smtp_username, smtp_password) - server.send_message(message) + try: + _send_via_smtp( + message, + recipient_email, + smtp_from_email, + smtp_host, + smtp_port, + smtp_username, + smtp_password, + use_tls, + use_ssl, + ) + except EmailDeliveryError: + if dev_log_code: + _log_verification_code_for_dev(recipient_email, code) + return True + raise + logger.info("Correo de verificación enviado a %s", recipient_email) return True