Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/auth.py
Consultoría Alcaraz Salazar a718592291 Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
2026-07-20 07:44:23 +00:00

128 lines
3.6 KiB
Python

# -*- coding: utf-8 -*-
"""Autenticación ligera (token HMAC) para el frontend React de SKEEN.
Token = base64url(payload_json) + "." + firma HMAC-SHA256, sin tabla de sesiones.
El secreto vive en ir.config_parameter 'skeen.frontend.secret' (se genera al primer uso).
"""
import base64
import functools
import hashlib
import hmac
import json
import secrets
import time
from odoo.http import request, Response
TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret'
RANK = {
'lectura': 10,
'medico': 50,
'recepcion': 50,
'admin': 100,
}
def _cors_headers():
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers=_cors_headers(),
)
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(s: str) -> bytes:
pad = '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _get_secret(env) -> str:
ICP = env['ir.config_parameter'].sudo()
secret = ICP.get_param(SECRET_KEY)
if not secret:
secret = secrets.token_urlsafe(48)
ICP.set_param(SECRET_KEY, secret)
return secret
def sign_token(env, user) -> str:
payload = {
'uid': user.id,
'login': user.login,
'role': user.role,
'exp': int(time.time()) + TTL_SECONDS,
}
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
sig = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
return f'{body}.{sig}'
def verify_token(env, token: str):
if not token or '.' not in token:
return None
body, sig = token.rsplit('.', 1)
expected = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
try:
payload = json.loads(_b64url_decode(body).decode('utf-8'))
except Exception:
return None
if int(payload.get('exp', 0)) < int(time.time()):
return None
return payload
def current_user(env):
"""Devuelve el usuario autenticado (record) o None."""
auth = request.httprequest.headers.get('Authorization', '') or ''
if not auth.lower().startswith('bearer '):
return None
token = auth[7:].strip()
payload = verify_token(env, token)
if not payload:
return None
user = env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
if not user.exists() or not user.active:
return None
return user
def require_role(min_role='lectura'):
"""Decorator: exige token válido y un rol con rango >= min_role.
Deja pasar OPTIONS (preflight CORS) sin autenticación.
"""
required = RANK.get(min_role, 10)
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
user = current_user(request.env)
if not user:
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
if user.role_rank() < required:
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
request.frontend_user = user
return func(self, *args, **kw)
return wrapper
return decorator