fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs
This commit is contained in:
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal file
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Autenticación `api_key` para las rutas públicas `/skeen/api/v1/*`.
|
||||
|
||||
Odoo no trae un método de autenticación `api_key`; las rutas de
|
||||
`skeen_whatsapp/controllers/main.py` lo declaraban pero no existía, así que
|
||||
TODAS las peticiones respondían AccessDenied. Aquí se implementa el método que
|
||||
falta, sin cambiar ni el formato de la clave ni el de los encabezados:
|
||||
|
||||
1) CLave legacy de Hermes: `ir.config_parameter 'skeen.api.legacy_key'`
|
||||
(sembrada con `skeen_api_key_2026`).
|
||||
2) Claves del modelo `skeen.api.key` (hash, alcances, vencimiento, revocación).
|
||||
|
||||
La clave se lee de `Authorization: Bearer <clave>`, de `X-API-Key` o del
|
||||
parámetro `api_key` (query/form), que es lo que ya usaba Hermes.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from odoo import SUPERUSER_ID, models, http
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import licencia_de, modulo_bloqueado
|
||||
|
||||
LEGACY_KEY_PARAM = 'skeen.api.legacy_key'
|
||||
LEGACY_KEY_DEFAULT = 'skeen_api_key_2026'
|
||||
|
||||
# Rutas del frontend cuyo módulo se controla por licencia. Los módulos con
|
||||
# bloque `activo` (compras, membresías, comisiones, recursos, notificaciones,
|
||||
# reservas) ya responden 403 solos al apagarse, así que aquí solo hacen falta
|
||||
# los que no tienen ese bloque.
|
||||
RUTAS_LICENCIA = (
|
||||
('/skeen/frontend/v1/formularios', 'formularios'),
|
||||
('/skeen/frontend/v1/wacrm', 'whatsapp'),
|
||||
)
|
||||
|
||||
|
||||
def _respuesta_licencia():
|
||||
"""Respuesta 403 JSON si la ruta pertenece a un módulo fuera de la licencia.
|
||||
|
||||
Devuelve None si la petición puede continuar. No aplica a OPTIONS (preflight
|
||||
CORS) ni cuando no hay licencia en modo bloqueo."""
|
||||
try:
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return None
|
||||
path = request.httprequest.path or ''
|
||||
modulo = next((m for prefijo, m in RUTAS_LICENCIA if path.startswith(prefijo)), None)
|
||||
if not modulo or not request.env:
|
||||
return None
|
||||
if not modulo_bloqueado(licencia_de(request.env), modulo):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
cuerpo = json.dumps({'status': 'error',
|
||||
'message': 'Módulo no incluido en tu plan'},
|
||||
ensure_ascii=False)
|
||||
return Response(cuerpo, status=403, mimetype='application/json',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
|
||||
|
||||
def extraer_clave(req):
|
||||
"""Devuelve la clave de API de la petición (o '')."""
|
||||
try:
|
||||
headers = req.httprequest.headers
|
||||
auth = headers.get('Authorization', '') or ''
|
||||
if auth.lower().startswith('bearer '):
|
||||
return auth[7:].strip()
|
||||
for header in ('X-API-Key', 'X-Api-Key', 'Api-Key'):
|
||||
valor = headers.get(header)
|
||||
if valor:
|
||||
return valor.strip()
|
||||
args = req.httprequest.args
|
||||
for parametro in ('api_key', 'api-key', 'apikey'):
|
||||
if args.get(parametro):
|
||||
return args.get(parametro).strip()
|
||||
except Exception:
|
||||
return ''
|
||||
return ''
|
||||
|
||||
|
||||
def clave_permite_scope(req, scope):
|
||||
"""True si la clave de la petición concede el alcance `scope`.
|
||||
|
||||
La clave legacy de Hermes tiene todos los alcances. Una petición sin
|
||||
`request.api_key` (p. ej. dentro de pruebas) se considera legacy.
|
||||
"""
|
||||
key = getattr(req, 'api_key', None)
|
||||
if key is None:
|
||||
return True
|
||||
return key.permite(scope)
|
||||
|
||||
|
||||
def exigir_scope(req, scope):
|
||||
"""Lanza 403 (HTTP 403) si la clave no tiene el alcance indicado."""
|
||||
if not clave_permite_scope(req, scope):
|
||||
raise Forbidden(description='La clave de API no tiene el alcance "%s".' % scope)
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _dispatch(cls, endpoint):
|
||||
respuesta = _respuesta_licencia()
|
||||
if respuesta is not None:
|
||||
return respuesta
|
||||
return super()._dispatch(endpoint)
|
||||
|
||||
@classmethod
|
||||
def _auth_method_api_key(cls):
|
||||
raw = extraer_clave(request)
|
||||
if not raw:
|
||||
raise AccessDenied('Falta la clave de API (Authorization: Bearer ...).')
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
legacy = ICP.get_param(LEGACY_KEY_PARAM) or LEGACY_KEY_DEFAULT
|
||||
if hmac.compare_digest(raw, legacy):
|
||||
request.api_key = None
|
||||
request.api_key_legacy = True
|
||||
request.update_env(user=SUPERUSER_ID)
|
||||
return
|
||||
key = None
|
||||
if 'skeen.api.key' in request.env:
|
||||
key = request.env['skeen.api.key'].sudo()._autenticar(raw)
|
||||
if not key:
|
||||
raise AccessDenied('Clave de API inválida o revocada.')
|
||||
request.api_key = key
|
||||
request.api_key_legacy = False
|
||||
key._registrar_uso()
|
||||
request.update_env(user=SUPERUSER_ID)
|
||||
Reference in New Issue
Block a user