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:
markosbenitez
2026-05-25 22:33:50 -03:00
parent 6487a7857c
commit 7865008618
8 changed files with 457 additions and 4 deletions

View File

@@ -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)