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/
This commit is contained in:
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user