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:
48
database.py
48
database.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user