Seguridad: rate limiting, códigos de un solo uso, rotación de FERNET_KEY
- utils/rate_limiter.py: ventana deslizante 20 req/60s por chat_id, aplicado en todos los handlers de telegram_bot.py - database.py: tabla invite_codes (un solo uso por código), funciones crear_invite_code() y verificar_y_consumir_invite_code() - onboarding.py: verifica primero en DB (un solo uso), fallback al INVITE_CODE del .env (ilimitado, para uso interno) - scripts/generar_codigo.py: genera 1 o N códigos de invitación - scripts/rotar_fernet_key.py: re-cifra todos los tenants y el .env con nueva FERNET_KEY, backup automático de .env antes de escribir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,12 @@ import audit
|
||||
import vault as vault_mod
|
||||
from config import Settings
|
||||
from csj_client import CSJAuthError, CSJClient, CSJConnectionError, CSJPasswordTemporalError
|
||||
from database import delete_tenant, get_tenant, save_tenant
|
||||
from database import delete_tenant, get_tenant, save_tenant, verificar_y_consumir_invite_code
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Estados (strings internos)
|
||||
_ESPERANDO_CODIGO = "codigo"
|
||||
_INICIO = "inicio"
|
||||
_ESPERANDO_NOMBRE = "nombre"
|
||||
_ESPERANDO_TONO = "tono"
|
||||
@@ -43,6 +44,7 @@ _ESPERANDO_PASSWORD = "password"
|
||||
_ESPERANDO_SALIR = "salir"
|
||||
|
||||
_MAX_INTENTOS = 3
|
||||
_MAX_INTENTOS_CODIGO = 3
|
||||
|
||||
_TZ_MAP = {
|
||||
"py": "America/Asuncion",
|
||||
@@ -87,10 +89,23 @@ class OnboardingFlow:
|
||||
async def iniciar_registro(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Llamado desde cmd_start cuando el usuario no está registrado."""
|
||||
chat_id = update.effective_chat.id # type: ignore[union-attr]
|
||||
self._states[chat_id] = _INICIO
|
||||
self._temp[chat_id] = {}
|
||||
self._attempts[chat_id] = 0
|
||||
await audit.log("onboarding_inicio", chat_id=chat_id)
|
||||
|
||||
if self._settings.invite_code:
|
||||
self._states[chat_id] = _ESPERANDO_CODIGO
|
||||
await update.effective_message.reply_text( # type: ignore[union-attr]
|
||||
"Hola\\! Para registrarte en este bot necesitás un código de acceso\\.\n\n"
|
||||
"Escribí el código que te compartió el administrador:",
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
)
|
||||
return
|
||||
|
||||
await self._mostrar_consentimiento(chat_id, update)
|
||||
|
||||
async def _mostrar_consentimiento(self, chat_id: int, update: Update) -> None:
|
||||
self._states[chat_id] = _INICIO
|
||||
texto = (
|
||||
"Hola\\! Soy Pedrito, el asistente judicial del estudio\\.\n\n"
|
||||
"Antes de empezar, necesito que sepas:\n"
|
||||
@@ -109,7 +124,6 @@ class OnboardingFlow:
|
||||
await update.effective_message.reply_text( # type: ignore[union-attr]
|
||||
texto, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=keyboard
|
||||
)
|
||||
await audit.log("onboarding_inicio", chat_id=chat_id)
|
||||
|
||||
async def iniciar_salir(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Llamado desde cmd_salir."""
|
||||
@@ -142,7 +156,9 @@ class OnboardingFlow:
|
||||
return
|
||||
texto_msg = (update.effective_message.text or "").strip() # type: ignore[union-attr]
|
||||
|
||||
if estado == _ESPERANDO_NOMBRE:
|
||||
if estado == _ESPERANDO_CODIGO:
|
||||
await self._paso_codigo(chat_id, texto_msg, update)
|
||||
elif estado == _ESPERANDO_NOMBRE:
|
||||
await self._paso_nombre(chat_id, texto_msg, update)
|
||||
elif estado == _ESPERANDO_TZ_CUSTOM:
|
||||
await self._paso_tz_custom(chat_id, texto_msg, update)
|
||||
@@ -183,6 +199,38 @@ class OnboardingFlow:
|
||||
# Pasos del flujo
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _paso_codigo(self, chat_id: int, codigo: str, update: Update) -> None:
|
||||
codigo = codigo.strip()
|
||||
|
||||
# Primero: verificar contra la tabla de códigos de un solo uso (DB)
|
||||
valido = await verificar_y_consumir_invite_code(codigo, chat_id)
|
||||
|
||||
# Fallback: código global del .env (ilimitado, para uso interno/pruebas)
|
||||
if not valido and self._settings.invite_code:
|
||||
valido = (codigo == self._settings.invite_code)
|
||||
|
||||
if valido:
|
||||
await audit.log("onboarding_codigo_correcto", chat_id=chat_id)
|
||||
await self._mostrar_consentimiento(chat_id, update)
|
||||
return
|
||||
|
||||
intentos = self._attempts.get(chat_id, 0) + 1
|
||||
self._attempts[chat_id] = intentos
|
||||
await audit.log("onboarding_codigo_incorrecto", chat_id=chat_id, resultado="error", detalle={"intento": intentos})
|
||||
|
||||
if intentos >= _MAX_INTENTOS_CODIGO:
|
||||
self._clear(chat_id)
|
||||
await update.effective_message.reply_text( # type: ignore[union-attr]
|
||||
"Código incorrecto\\. Contactá al administrador para obtener acceso\\.",
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
)
|
||||
return
|
||||
|
||||
await update.effective_message.reply_text( # type: ignore[union-attr]
|
||||
"Código incorrecto\\. Intentá de nuevo:",
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
)
|
||||
|
||||
async def _paso_consentimiento(self, chat_id: int, valor: str, update: Update) -> None:
|
||||
if valor == "no":
|
||||
self._clear(chat_id)
|
||||
|
||||
Reference in New Issue
Block a user