- Roles: admin, médico, recepción, enfermería, cosmetología, psicóloga, farmacia, entrenador, marketing + lectura (compatibilidad) - Matriz de permisos por rol persistida en Odoo (ir.config_parameter) y editable en Configuración → Roles (secciones × roles) - Prioridad: permisos personales del usuario > matriz por rol > defaults - Protección: admin siempre conserva /configuracion
125 lines
3.6 KiB
Python
125 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
|
|
|
|
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
|