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