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. # Esta URI debe estar registrada también en la consola de Google Cloud.
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=tu-client-secret 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) # Clave para firmar los JWT (usa algo largo y aleatorio en producción)
SECRET_KEY=cambia-esta-clave-en-produccion SECRET_KEY=cambia-esta-clave-en-produccion
@@ -27,16 +31,41 @@ SECRET_KEY=cambia-esta-clave-en-produccion
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
# Verificación de email por código numérico # 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_CODE_TTL_MINUTES=15
EMAIL_VERIFICATION_MAX_ATTEMPTS=5 EMAIL_VERIFICATION_MAX_ATTEMPTS=5
EMAIL_VERIFICATION_SECRET=cambia-este-secreto-en-produccion 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_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USE_TLS=true SMTP_USE_TLS=true
SMTP_USE_SSL=false SMTP_USE_SSL=false
SMTP_TIMEOUT_SECONDS=30
SMTP_USERNAME=tu-correo@gmail.com SMTP_USERNAME=tu-correo@gmail.com
SMTP_PASSWORD=tu-password-o-app-password SMTP_PASSWORD=tu-password-o-app-password
SMTP_FROM_EMAIL=tu-correo@gmail.com SMTP_FROM_EMAIL=tu-correo@gmail.com
+45 -14
View File
@@ -1,3 +1,5 @@
import logging
import os
from datetime import datetime from datetime import datetime
from bson import ObjectId from bson import ObjectId
@@ -10,7 +12,11 @@ from api.models.user_models import (
UserCreate, UserCreate,
UserLogin, 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 ( from api.utils.email_verification import (
MAX_EMAIL_VERIFICATION_ATTEMPTS, MAX_EMAIL_VERIFICATION_ATTEMPTS,
generate_verification_code, generate_verification_code,
@@ -27,6 +33,40 @@ from api.utils.security import (
) )
router = APIRouter(prefix="/auth", tags=["auth"]) 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") @router.post("/register")
@@ -68,13 +108,10 @@ async def register_user(user: UserCreate):
result = await users_collection.insert_one(user_doc) result = await users_collection.insert_one(user_doc)
try: try:
send_verification_email(email, user.username, verification_code) await _dispatch_verification_email(email, user.username, verification_code)
except Exception as exc: except HTTPException:
await users_collection.delete_one({"_id": result.inserted_id}) await users_collection.delete_one({"_id": result.inserted_id})
raise HTTPException( raise
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No se pudo enviar el correo de verificación",
) from exc
return { return {
"message": "Usuario registrado. Revisa tu correo para verificar la cuenta.", "message": "Usuario registrado. Revisa tu correo para verificar la cuenta.",
@@ -214,13 +251,7 @@ async def resend_verification_email(payload: EmailVerificationResendRequest):
}, },
) )
try: await _dispatch_verification_email(email, user["username"], verification_code)
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
return {"message": "Nuevo código de verificación enviado"} 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 import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from datetime import datetime, timedelta
import httpx
import os
import jwt
from api.database.mongodb import users_collection from api.database.mongodb import users_collection
@@ -17,11 +19,18 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
@router.get("/login") @router.get("/login")
async def google_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 = ( google_auth_url = (
"https://accounts.google.com/o/oauth2/auth" "https://accounts.google.com/o/oauth2/auth"
"?response_type=code" "?response_type=code"
f"&client_id={GOOGLE_CLIENT_ID}" f"&client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}" f"&redirect_uri={redirect_uri}"
"&scope=openid%20email%20profile" "&scope=openid%20email%20profile"
"&access_type=offline" "&access_type=offline"
"&prompt=consent" "&prompt=consent"
+139 -31
View File
@@ -1,12 +1,24 @@
import html import html
import logging
import os import os
import smtplib import smtplib
import ssl import ssl
from datetime import datetime from datetime import datetime
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr
from api.utils.email_verification import EMAIL_VERIFICATION_CODE_TTL_MINUTES 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: def _env_bool(name: str, default: str = "false") -> bool:
return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"} 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>""" </html>"""
def send_verification_email(recipient_email: str, username: str, code: str) -> bool: def _log_verification_code_for_dev(recipient_email: str, code: str) -> None:
smtp_host = os.getenv("SMTP_HOST") logger.warning(
if not smtp_host: "[email-verification] Modo desarrollo: código para %s%s",
print( recipient_email,
"[email-verification] SMTP_HOST no configurado. " code,
f"Código para {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_port = int(os.getenv("SMTP_PORT", "587"))
smtp_username = os.getenv("SMTP_USERNAME") smtp_username = (os.getenv("SMTP_USERNAME") or "").strip()
smtp_password = os.getenv("SMTP_PASSWORD") smtp_password = (os.getenv("SMTP_PASSWORD") or "").replace(" ", "")
smtp_from_email = os.getenv("SMTP_FROM_EMAIL") or smtp_username smtp_from_email = (os.getenv("SMTP_FROM_EMAIL") or smtp_username or "").strip()
smtp_from_name = os.getenv("SMTP_FROM_NAME", "Deck of Cards") smtp_from_name = os.getenv("SMTP_FROM_NAME", "Deck of Cards")
use_tls = _env_bool("SMTP_USE_TLS", "true") use_tls = _env_bool("SMTP_USE_TLS", "true")
use_ssl = _env_bool("SMTP_USE_SSL", "false") use_ssl = _env_bool("SMTP_USE_SSL", "false")
if not smtp_from_email: 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 = _build_verification_message(
message["Subject"] = "Código de verificación — Deck of Cards" recipient_email,
message["From"] = f"{smtp_from_name} <{smtp_from_email}>" username,
message["To"] = recipient_email code,
message.set_content(_build_verification_plain(username, code)) smtp_from_email,
message.add_alternative(_build_verification_html(username, code), subtype="html") smtp_from_name,
)
context = ssl.create_default_context() try:
_send_via_smtp(
if use_ssl: message,
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as server: recipient_email,
if smtp_username and smtp_password: smtp_from_email,
server.login(smtp_username, smtp_password) smtp_host,
server.send_message(message) smtp_port,
else: smtp_username,
with smtplib.SMTP(smtp_host, smtp_port) as server: smtp_password,
if use_tls: use_tls,
server.starttls(context=context) use_ssl,
if smtp_username and smtp_password: )
server.login(smtp_username, smtp_password) except EmailDeliveryError:
server.send_message(message) 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 return True