import json import functools import logging import time import jwt as pyjwt from odoo.http import request _logger = logging.getLogger(__name__) _jwt_secret_cache = {"secret": None, "ts": 0} _JWT_SECRET_TTL = 300 _user_exists_cache = {} _USER_CACHE_TTL = 60 _USER_CACHE_MAX = 200 def _get_jwt_secret(): now = time.time() if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL: return _jwt_secret_cache["secret"] secret = ( request.env["ir.config_parameter"] .sudo() .get_param("encoach.jwt_secret") ) if secret: _jwt_secret_cache["secret"] = secret _jwt_secret_cache["ts"] = now return secret def validate_token(): """Decode JWT Bearer token and return the corresponding ``res.users`` record or None.""" auth_header = request.httprequest.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return None token = auth_header[7:] secret = _get_jwt_secret() if not secret: _logger.error("System parameter 'encoach.jwt_secret' is not configured") return None try: payload = pyjwt.decode(token, secret, algorithms=["HS256"]) except pyjwt.ExpiredSignatureError: return None except pyjwt.InvalidTokenError: return None user_id = payload.get("user_id") if not user_id: return None now = time.time() cache_key = int(user_id) cached = _user_exists_cache.get(cache_key) if cached and (now - cached["ts"]) < _USER_CACHE_TTL: return request.env["res.users"].sudo().browse(cache_key) user = request.env["res.users"].sudo().browse(cache_key) if not user.exists(): return None _user_exists_cache[cache_key] = {"ts": now} if len(_user_exists_cache) > _USER_CACHE_MAX: oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"]) del _user_exists_cache[oldest_key] return user def jwt_required(func): """Decorator that validates the JWT token and sets request.env user context.""" @functools.wraps(func) def wrapper(*args, **kwargs): user = validate_token() if not user: return _error_response("Authentication required", status=401) request.update_env(user=user.id) return func(*args, **kwargs) return wrapper def _json_response(data, status=200): return request.make_json_response(data, status=status) def _error_response(message, status=400, code=None): body = {"error": message} if code: body["code"] = code return request.make_json_response(body, status=status) def _get_json_body(): try: return json.loads(request.httprequest.get_data(as_text=True)) except (ValueError, TypeError): return {} def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'): """Paginate an Odoo model search or extract params from a kwargs dict. Two calling conventions: _paginate(Model, domain, page, size, order) → (recordset, total_count) _paginate(kwargs_dict) → (offset, limit, page) """ if isinstance(model_or_kwargs, dict): kwargs = model_or_kwargs limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200) page_raw = int(kwargs.get("page", 0)) pg = max(page_raw, 0) offset = pg * limit return offset, limit, pg Model = model_or_kwargs limit = min(max(int(size), 1), 200) pg = max(int(page), 0) offset = pg * limit total = Model.search_count(domain or []) records = Model.search(domain or [], offset=offset, limit=limit, order=order) return records, total class EncoachMixin: """Shared authentication and response helpers for all EnCoach API controllers.""" def _get_jwt_secret(self): return _get_jwt_secret() def _authenticate(self): return validate_token() def _json_response(self, data, status=200): return _json_response(data, status=status) def _error_response(self, message, status=400, code=None): return _error_response(message, status=status, code=code) def _get_json_body(self): return _get_json_body() def _paginate_params(self, kwargs): return _paginate(kwargs) def _serialize(self, record): if hasattr(record, "to_encoach_dict"): return record.to_encoach_dict() return {"id": record.id} def _serialize_list(self, records): return [self._serialize(r) for r in records]