# -*- 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 from ..roles import ROLE_RANKS TTL_SECONDS = 60 * 60 * 12 # 12 horas SECRET_KEY = 'skeen.frontend.secret' RANK = ROLE_RANKS # Módulos de dinero globales vetados para el rol 'medico' por API: su menú ya # los oculta (MENU_ROLES) y aquí se cierra la vía directa con token. Su propio # reporte (/my/sales) no pasa por esta lista. MEDICO_BLOCKED_PREFIXES = ( '/skeen/frontend/v1/reports/', '/skeen/frontend/v1/sales', '/skeen/frontend/v1/payments', '/skeen/frontend/v1/wallets', '/skeen/frontend/v1/pos/', '/skeen/frontend/v1/cash_closings', '/skeen/frontend/v1/doctors/', ) 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 forbid_roles(*roles): """Decorator: niega el acceso a una lista explícita de roles. Se usa para cerrar operaciones de dinero a roles con rango de escritura que no deben tocarlas (p. ej. marketing, que comparte rango con recepción para poder agendar, pero no debe cobrar). """ 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 user and user.role in roles: return _json({'status': 'error', 'message': 'Sin permiso'}, 403) return func(self, *args, **kw) return wrapper return decorator 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) if user.role == 'medico' and request.httprequest.path.startswith(MEDICO_BLOCKED_PREFIXES): return _json({'status': 'error', 'message': 'Sin permiso'}, 403) request.frontend_user = user return func(self, *args, **kw) return wrapper return decorator