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

@@ -35,6 +35,16 @@ CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_evento ON audit_log(evento);
"""
_CREATE_INVITE_CODES = """
CREATE TABLE IF NOT EXISTS invite_codes (
codigo TEXT PRIMARY KEY,
creado_at TEXT NOT NULL,
usado INTEGER DEFAULT 0,
usado_por INTEGER,
usado_at TEXT
);
"""
_CREATE_TENANTS = """
CREATE TABLE IF NOT EXISTS tenants (
chat_id INTEGER PRIMARY KEY,
@@ -65,6 +75,7 @@ async def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
_conn = await aiosqlite.connect(str(db_path))
_conn.row_factory = aiosqlite.Row
await _conn.execute(_CREATE_INVITE_CODES)
await _conn.execute(_CREATE_TENANTS)
# CREATE INDEX no soporta executescript — ejecutar sentencias separadas
for stmt in _CREATE_AUDIT_LOG.strip().split(";"):
@@ -138,3 +149,40 @@ async def delete_tenant(chat_id: int) -> None:
await conn.execute("DELETE FROM tenants WHERE chat_id = ?", (chat_id,))
await conn.commit()
logger.info("tenant_eliminado", chat_id=chat_id)
# ── Invite codes ──────────────────────────────────────────────────────────────
async def crear_invite_code(codigo: str) -> None:
"""Inserta un código de invitación de un solo uso."""
conn = _assert_conn()
await conn.execute(
"INSERT INTO invite_codes (codigo, creado_at) VALUES (?, ?)",
(codigo, _now_iso()),
)
await conn.commit()
logger.info("invite_code_creado", codigo=codigo[:4] + "****")
async def verificar_y_consumir_invite_code(codigo: str, chat_id: int) -> bool:
"""
Verifica que el código existe y no fue usado, lo marca como consumido y
retorna True. Retorna False si no existe o ya fue usado.
Operación atómica en una sola transacción SQLite.
"""
conn = _assert_conn()
async with conn.execute(
"SELECT usado FROM invite_codes WHERE codigo = ?", (codigo,)
) as cur:
row = await cur.fetchone()
if row is None or row["usado"]:
return False
await conn.execute(
"UPDATE invite_codes SET usado = 1, usado_por = ?, usado_at = ? WHERE codigo = ?",
(chat_id, _now_iso(), codigo),
)
await conn.commit()
logger.info("invite_code_consumido", chat_id=chat_id)
return True