# -*- 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