From b8215d27366612a63cce2555c4a575380264f52d Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 27 Oct 2022 10:44:23 +0200 Subject: [PATCH 1/3] A more robust cron start logic If may happen (usually because of a race condition) that a cronjob has already been started, but it hasn't yet changed its status from IDLE to RUNNING when the scheduler checks it. This fix guards the application against such events. If they occur, we should just report them and move on, not terminate the whole scheduler. --- platypush/cron/scheduler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platypush/cron/scheduler.py b/platypush/cron/scheduler.py index 86fbff62a..18f1ee8bd 100644 --- a/platypush/cron/scheduler.py +++ b/platypush/cron/scheduler.py @@ -153,7 +153,10 @@ class CronScheduler(threading.Thread): for (job_name, job_config) in self.jobs_config.items(): job = self._get_job(name=job_name, config=job_config) if job.state == CronjobState.IDLE: - job.start() + try: + job.start() + except Exception as e: + logger.warning(f'Could not start cronjob {job_name}: {e}') t_before_wait = get_now().timestamp() self._should_stop.wait(timeout=self._poll_seconds) From 02f89258b8b2cb2d6724e42ada5479382922cca1 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 21 Nov 2022 09:49:57 +0100 Subject: [PATCH 2/3] FIX: Task.set_name only works on Python >= 3.8 --- platypush/plugins/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index f35f06a4e..fa7a06fc6 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -158,7 +158,8 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC): asyncio.set_event_loop(self._loop) self._task = self._loop.create_task(self._listen()) - self._task.set_name(self.__class__.__name__ + '.listen') + if hasattr(self._task, 'set_name'): + self._task.set_name(self.__class__.__name__ + '.listen') self._loop.run_forever() def main(self): From a2c8e27bd8a4c302d1eac6331960ebdbc560edf7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 21 Nov 2022 12:30:38 +0100 Subject: [PATCH 3/3] Removed PyJWT dependency. PyJWT is a very brittle and cumbersome dependency that expects several cryptography libraries to be already installed on the system, and it can lead to hard-to-debug errors when ported to different systems. Moreover, it installs the whole `cryptography` package, which is several MBs in size, takes time to compile, and it requires a Rust compiler to be present on the target machine. Platypush will now use the Python-native `rsa` module to handle JWT tokens. --- platypush/user/__init__.py | 45 ++++++++++++++++++++----------------- platypush/utils/__init__.py | 45 +++++++++++++++---------------------- requirements.txt | 3 +-- setup.py | 3 +-- 4 files changed, 45 insertions(+), 51 deletions(-) diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py index ccc2040aa..6568b2cf6 100644 --- a/platypush/user/__init__.py +++ b/platypush/user/__init__.py @@ -1,17 +1,14 @@ +import base64 import datetime import hashlib +import json import random +import rsa import time from typing import Optional, Dict import bcrypt -try: - from jwt.exceptions import PyJWTError - from jwt import encode as jwt_encode, decode as jwt_decode -except ImportError: - from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode - from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.ext.declarative import declarative_base @@ -197,17 +194,20 @@ class UserManager: if not user: raise InvalidCredentialsException() - pub_key, priv_key = get_or_generate_jwt_rsa_key_pair() - payload = { - 'username': username, - 'created_at': datetime.datetime.now().timestamp(), - 'expires_at': expires_at.timestamp() if expires_at else None, - } + pub_key, _ = get_or_generate_jwt_rsa_key_pair() + payload = json.dumps( + { + 'username': username, + 'created_at': datetime.datetime.now().timestamp(), + 'expires_at': expires_at.timestamp() if expires_at else None, + }, + sort_keys=True, + indent=None, + ) - token = jwt_encode(payload, priv_key, algorithm='RS256') - if isinstance(token, bytes): - token = token.decode() - return token + return base64.b64encode( + rsa.encrypt(payload.encode('ascii'), pub_key) + ).decode() @staticmethod def validate_jwt_token(token: str) -> Dict[str, str]: @@ -227,12 +227,17 @@ class UserManager: :raises: :class:`platypush.exceptions.user.InvalidJWTTokenException` in case of invalid token. """ - pub_key, priv_key = get_or_generate_jwt_rsa_key_pair() + _, priv_key = get_or_generate_jwt_rsa_key_pair() try: - payload = jwt_decode(token.encode(), pub_key, algorithms=['RS256']) - except PyJWTError as e: - raise InvalidJWTTokenException(str(e)) + payload = json.loads( + rsa.decrypt( + base64.b64decode(token.encode('ascii')), + priv_key + ).decode('ascii') + ) + except (TypeError, ValueError) as e: + raise InvalidJWTTokenException(f'Could not decode JWT token: {e}') expires_at = payload.get('expires_at') if expires_at and time.time() > expires_at: diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index c75615c85..efd13f17d 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -7,6 +7,7 @@ import logging import os import pathlib import re +import rsa import signal import socket import ssl @@ -15,6 +16,7 @@ from typing import Optional, Tuple, Union from dateutil import parser, tz from redis import Redis +from rsa.key import PublicKey, PrivateKey logger = logging.getLogger('utils') @@ -366,7 +368,8 @@ def run(action, *args, **kwargs): return response.output -def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]: +def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) \ + -> Tuple[PublicKey, PrivateKey]: """ Generate an RSA key pair. @@ -384,28 +387,11 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T :param size: Key size (default: 2048 bits). :return: A tuple with the generated ``(priv_key_str, pub_key_str)``. """ - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.backends import default_backend - - public_exp = 65537 - private_key = rsa.generate_private_key( - public_exponent=public_exp, - key_size=size, - backend=default_backend() - ) - - logger.info('Generating RSA {} key pair'.format(size)) - private_key_str = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode() - - public_key_str = private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.PKCS1, - ).decode() + logger.info('Generating RSA keypair') + pub_key, priv_key = rsa.newkeys(size) + logger.info('Generated RSA keypair') + public_key_str = pub_key.save_pkcs1('PEM').decode() + private_key_str = priv_key.save_pkcs1('PEM').decode() if key_file: logger.info('Saving private key to {}'.format(key_file)) @@ -415,7 +401,7 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T f2.write(public_key_str) os.chmod(key_file, 0o600) - return public_key_str, private_key_str + return pub_key, priv_key def get_or_generate_jwt_rsa_key_pair(): @@ -426,9 +412,14 @@ def get_or_generate_jwt_rsa_key_pair(): pub_key_file = priv_key_file + '.pub' if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file): - with open(pub_key_file, 'r') as f1, \ - open(priv_key_file, 'r') as f2: - return f1.read(), f2.read() + with ( + open(pub_key_file, 'r') as f1, + open(priv_key_file, 'r') as f2 + ): + return ( + rsa.PublicKey.load_pkcs1(f1.read().encode()), + rsa.PrivateKey.load_pkcs1(f2.read().encode()), + ) pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755) return generate_rsa_key_pair(priv_key_file, size=2048) diff --git a/requirements.txt b/requirements.txt index 0ac8538f1..3381ad9b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,7 @@ frozendict requests sqlalchemy bcrypt -cryptography -pyjwt +rsa zeroconf paho-mqtt websocket-client diff --git a/setup.py b/setup.py index 9b2aff4c1..f650ebc74 100755 --- a/setup.py +++ b/setup.py @@ -64,8 +64,7 @@ setup( 'zeroconf>=0.27.0', 'tz', 'python-dateutil', - # 'cryptography', - 'pyjwt', + 'rsa', 'marshmallow', 'frozendict', 'flask',