Mejorar verificacion por email y OAuth de Google para despliegue en sinbad2.

Endurece el envio SMTP (EHLO/STARTTLS, timeouts, errores tipados y logging), centraliza el manejo de fallos en registro y reenvio, codifica la redirect_uri de Google OAuth y documenta la configuracion de produccion en .env.example.
This commit is contained in:
Mireya Cueto Garrido
2026-05-21 12:14:17 +02:00
parent 2b7428df56
commit 6d87a32bc4
4 changed files with 230 additions and 53 deletions
+32 -3
View File
@@ -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
+45 -14
View File
@@ -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"}
+14 -5
View File
@@ -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"
+140 -32
View File
@@ -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:
</html>"""
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")
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)
message = _build_verification_message(
recipient_email,
username,
code,
smtp_from_email,
smtp_from_name,
)
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