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:
+32
-3
@@ -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
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
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
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user