169 lines
5.8 KiB
Python
169 lines
5.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Claves de API de terceros (`skeen.api.key`).
|
|
|
|
La clave se muestra UNA sola vez al crearla: solo se guarda su hash SHA-256 y
|
|
un prefijo visible para identificarla. La comparación es en tiempo constante.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
# Alcances disponibles (vacío = todos).
|
|
SCOPES = [
|
|
('citas.read', 'Consultar citas y disponibilidad'),
|
|
('citas.write', 'Crear y cancelar citas'),
|
|
('pacientes.read', 'Consultar pacientes y saldos'),
|
|
('servicios.read', 'Consultar el catálogo de servicios'),
|
|
('monedero.write', 'Redimir puntos del monedero'),
|
|
]
|
|
SCOPE_KEYS = tuple(k for k, _l in SCOPES)
|
|
SCOPE_LABEL = dict(SCOPES)
|
|
|
|
PREFIJO_LARGO = 14 # caracteres visibles de la clave
|
|
|
|
|
|
def hash_clave(raw):
|
|
return hashlib.sha256((raw or '').encode('utf-8')).hexdigest()
|
|
|
|
|
|
class SkeenApiKey(models.Model):
|
|
_name = 'skeen.api.key'
|
|
_description = 'Clave de API de terceros'
|
|
_order = 'create_date desc, id desc'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
prefijo = fields.Char(string='Prefijo visible', required=True, index=True)
|
|
hash = fields.Char(string='Hash', required=True)
|
|
activo = fields.Boolean(string='Activa', default=True)
|
|
scopes = fields.Char(string='Alcances', default='[]',
|
|
help='Lista JSON de alcances. Vacío = todos.')
|
|
vence = fields.Date(string='Vence')
|
|
ultimo_uso = fields.Datetime(string='Último uso')
|
|
usos = fields.Integer(string='Usos', default=0)
|
|
notas = fields.Text(string='Notas')
|
|
creado_por_login = fields.Char(string='Creada por')
|
|
revocada_at = fields.Datetime(string='Revocada el')
|
|
revocada_por = fields.Char(string='Revocada por')
|
|
|
|
_sql_constraints = [
|
|
('hash_uniq', 'unique(hash)', 'Ya existe una clave de API con ese hash.'),
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def generar(self, vals, login=''):
|
|
"""Crea una clave y devuelve (record, clave_en_claro).
|
|
|
|
La clave en claro solo se devuelve aquí; no se persiste.
|
|
"""
|
|
raw = 'sk_' + secrets.token_urlsafe(32)
|
|
record = self.create({
|
|
'name': vals.get('name') or 'Clave de API',
|
|
'prefijo': raw[:PREFIJO_LARGO],
|
|
'hash': hash_clave(raw),
|
|
'scopes': json.dumps(self._normalizar_scopes(vals.get('scopes') or [])),
|
|
'vence': vals.get('vence') or False,
|
|
'notas': vals.get('notas') or '',
|
|
'creado_por_login': login or '',
|
|
'activo': True,
|
|
})
|
|
return record, raw
|
|
|
|
@api.model
|
|
def _normalizar_scopes(self, scopes):
|
|
if not isinstance(scopes, (list, tuple)):
|
|
return []
|
|
limpios = []
|
|
for s in scopes:
|
|
s = str(s or '').strip()
|
|
if s and s in SCOPE_KEYS and s not in limpios:
|
|
limpios.append(s)
|
|
return limpios
|
|
|
|
def scopes_list(self):
|
|
try:
|
|
val = json.loads(self.scopes or '[]')
|
|
except (ValueError, TypeError):
|
|
val = []
|
|
return val if isinstance(val, list) else []
|
|
|
|
def scopes_label(self):
|
|
"""Texto para la UI: 'todos' o la lista de etiquetas."""
|
|
scopes = self.scopes_list()
|
|
if not scopes:
|
|
return 'Todos'
|
|
return ', '.join(SCOPE_LABEL.get(s, s) for s in scopes)
|
|
|
|
def permite(self, scope):
|
|
"""True si la clave concede el alcance (vacío = todos). Nunca si inactiva."""
|
|
if not self.activo:
|
|
return False
|
|
if self.vence and self.vence < fields.Date.today():
|
|
return False
|
|
scopes = self.scopes_list()
|
|
if not scopes:
|
|
return True
|
|
return scope in scopes
|
|
|
|
def estado(self):
|
|
if self.revocada_at or not self.activo:
|
|
return 'revocada'
|
|
if self.vence and self.vence < fields.Date.today():
|
|
return 'vencida'
|
|
return 'activa'
|
|
|
|
def to_public_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'prefijo': self.prefijo,
|
|
'activo': self.activo,
|
|
'scopes': self.scopes_list(),
|
|
'scopes_label': self.scopes_label(),
|
|
'vence': self.vence.strftime('%Y-%m-%d') if self.vence else '',
|
|
'ultimo_uso': self.ultimo_uso.strftime('%Y-%m-%d %H:%M') if self.ultimo_uso else '',
|
|
'usos': self.usos,
|
|
'notas': self.notas or '',
|
|
'creado_por_login': self.creado_por_login or '',
|
|
'revocada_at': self.revocada_at.strftime('%Y-%m-%d %H:%M') if self.revocada_at else '',
|
|
'revocada_por': self.revocada_por or '',
|
|
'estado': self.estado(),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _autenticar(self, raw):
|
|
"""Devuelve la clave activa que corresponde a `raw` (o recordset vacío)."""
|
|
raw = (raw or '').strip()
|
|
if not raw:
|
|
return self.browse()
|
|
candidatas = self.search([
|
|
('prefijo', '=', raw[:PREFIJO_LARGO]), ('activo', '=', True),
|
|
])
|
|
digest = hash_clave(raw)
|
|
for key in candidatas:
|
|
if hmac.compare_digest(key.hash or '', digest):
|
|
return key
|
|
# Respaldo: por si el prefijo almacenado difiere, comparar por hash.
|
|
return self.search([('hash', '=', digest), ('activo', '=', True)], limit=1)
|
|
|
|
def _registrar_uso(self):
|
|
if not self:
|
|
return
|
|
self.sudo().write({
|
|
'ultimo_uso': fields.Datetime.now(),
|
|
'usos': self.usos + 1,
|
|
})
|
|
|
|
def action_revocar(self, login=''):
|
|
self.write({
|
|
'activo': False,
|
|
'revocada_at': fields.Datetime.now(),
|
|
'revocada_por': login or '',
|
|
})
|