From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Max Carrara Date: Tue, 2 Jan 2024 13:02:51 +0000 Subject: [PATCH] backport: mgr/dashboard: simplify authentication protocol This is a backport of https://github.com/ceph/ceph/pull/54710 which fixes the Ceph Dashboard not being able to launch on Ceph Reef running on Debian Bookworm. This is achieved by removing the dependency on `PyJWT` (Python) and thus transitively also removing the dependency on `cryptography` (Python). For more information, see the original pull request. Note that the Ceph Dashboard still cannot be used if TLS is activated, because `pyOpenSSL` is used to verify certs during launch. Disabling TLS via `ceph config set mgr mgr/dashboard/ssl false` and using e.g. a reverse proxy can be used as a workaround. A separate patch is required to allow the dashboard to run with TLS enabled. Fixes: https://forum.proxmox.com/threads/ceph-warning-post-upgrade-to-v8.129371 Signed-off-by: Daniel Persson Signed-off-by: Max Carrara --- ceph.spec.in | 4 -- debian/control | 1 - src/pybind/mgr/dashboard/constraints.txt | 1 - src/pybind/mgr/dashboard/exceptions.py | 12 ++++ .../mgr/dashboard/requirements-lint.txt | 1 + .../mgr/dashboard/requirements-test.txt | 1 + src/pybind/mgr/dashboard/requirements.txt | 1 - src/pybind/mgr/dashboard/services/auth.py | 70 ++++++++++++++++--- 8 files changed, 75 insertions(+), 16 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index e4cbbef6943..457d7a6dc56 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -403,7 +403,6 @@ BuildRequires: xmlsec1-nss BuildRequires: xmlsec1-openssl BuildRequires: xmlsec1-openssl-devel BuildRequires: python%{python3_pkgversion}-cherrypy -BuildRequires: python%{python3_pkgversion}-jwt BuildRequires: python%{python3_pkgversion}-routes BuildRequires: python%{python3_pkgversion}-scipy BuildRequires: python%{python3_pkgversion}-werkzeug @@ -416,7 +415,6 @@ BuildRequires: libxmlsec1-1 BuildRequires: libxmlsec1-nss1 BuildRequires: libxmlsec1-openssl1 BuildRequires: python%{python3_pkgversion}-CherryPy -BuildRequires: python%{python3_pkgversion}-PyJWT BuildRequires: python%{python3_pkgversion}-Routes BuildRequires: python%{python3_pkgversion}-Werkzeug BuildRequires: python%{python3_pkgversion}-numpy-devel @@ -608,7 +606,6 @@ Requires: ceph-prometheus-alerts = %{_epoch_prefix}%{version}-%{release} Requires: python%{python3_pkgversion}-setuptools %if 0%{?fedora} || 0%{?rhel} Requires: python%{python3_pkgversion}-cherrypy -Requires: python%{python3_pkgversion}-jwt Requires: python%{python3_pkgversion}-routes Requires: python%{python3_pkgversion}-werkzeug %if 0%{?weak_deps} @@ -617,7 +614,6 @@ Recommends: python%{python3_pkgversion}-saml %endif %if 0%{?suse_version} Requires: python%{python3_pkgversion}-CherryPy -Requires: python%{python3_pkgversion}-PyJWT Requires: python%{python3_pkgversion}-Routes Requires: python%{python3_pkgversion}-Werkzeug Recommends: python%{python3_pkgversion}-python3-saml diff --git a/debian/control b/debian/control index e3f2073ce8f..ba34a163f54 100644 --- a/debian/control +++ b/debian/control @@ -87,7 +87,6 @@ Build-Depends: automake, python3-all-dev, python3-cherrypy3, python3-natsort, - python3-jwt , python3-pecan , python3-bcrypt , tox , diff --git a/src/pybind/mgr/dashboard/constraints.txt b/src/pybind/mgr/dashboard/constraints.txt index 55f81c92dec..fd614104880 100644 --- a/src/pybind/mgr/dashboard/constraints.txt +++ b/src/pybind/mgr/dashboard/constraints.txt @@ -1,6 +1,5 @@ CherryPy~=13.1 more-itertools~=8.14 -PyJWT~=2.0 bcrypt~=3.1 python3-saml~=1.4 requests~=2.26 diff --git a/src/pybind/mgr/dashboard/exceptions.py b/src/pybind/mgr/dashboard/exceptions.py index 96cbc523356..d396a38d2c3 100644 --- a/src/pybind/mgr/dashboard/exceptions.py +++ b/src/pybind/mgr/dashboard/exceptions.py @@ -121,3 +121,15 @@ class GrafanaError(Exception): class PasswordPolicyException(Exception): pass + + +class ExpiredSignatureError(Exception): + pass + + +class InvalidTokenError(Exception): + pass + + +class InvalidAlgorithmError(Exception): + pass diff --git a/src/pybind/mgr/dashboard/requirements-lint.txt b/src/pybind/mgr/dashboard/requirements-lint.txt index d82fa1ace1d..5fe9957c32a 100644 --- a/src/pybind/mgr/dashboard/requirements-lint.txt +++ b/src/pybind/mgr/dashboard/requirements-lint.txt @@ -9,3 +9,4 @@ autopep8==1.5.7 pyfakefs==4.5.0 isort==5.5.3 jsonschema==4.16.0 +PyJWT~=2.0 diff --git a/src/pybind/mgr/dashboard/requirements-test.txt b/src/pybind/mgr/dashboard/requirements-test.txt index 4e925e8616f..78fd1d5b742 100644 --- a/src/pybind/mgr/dashboard/requirements-test.txt +++ b/src/pybind/mgr/dashboard/requirements-test.txt @@ -2,3 +2,4 @@ pytest-cov pytest-instafail pyfakefs==4.5.0 jsonschema==4.16.0 +PyJWT~=2.0 diff --git a/src/pybind/mgr/dashboard/requirements.txt b/src/pybind/mgr/dashboard/requirements.txt index 8003d62a552..292971819c9 100644 --- a/src/pybind/mgr/dashboard/requirements.txt +++ b/src/pybind/mgr/dashboard/requirements.txt @@ -1,7 +1,6 @@ bcrypt CherryPy more-itertools -PyJWT pyopenssl requests Routes diff --git a/src/pybind/mgr/dashboard/services/auth.py b/src/pybind/mgr/dashboard/services/auth.py index f13963abffd..3c600231252 100644 --- a/src/pybind/mgr/dashboard/services/auth.py +++ b/src/pybind/mgr/dashboard/services/auth.py @@ -1,17 +1,19 @@ # -*- coding: utf-8 -*- +import base64 +import hashlib +import hmac import json import logging import os import threading import time import uuid -from base64 import b64encode import cherrypy -import jwt from .. import mgr +from ..exceptions import ExpiredSignatureError, InvalidAlgorithmError, InvalidTokenError from .access_control import LocalAuthenticator, UserDoesNotExist cherrypy.config.update({ @@ -33,7 +35,7 @@ class JwtManager(object): @staticmethod def _gen_secret(): secret = os.urandom(16) - return b64encode(secret).decode('utf-8') + return base64.b64encode(secret).decode('utf-8') @classmethod def init(cls): @@ -45,6 +47,54 @@ class JwtManager(object): mgr.set_store('jwt_secret', secret) cls._secret = secret + @classmethod + def array_to_base64_string(cls, message): + jsonstr = json.dumps(message, sort_keys=True).replace(" ", "") + string_bytes = base64.urlsafe_b64encode(bytes(jsonstr, 'UTF-8')) + return string_bytes.decode('UTF-8').replace("=", "") + + @classmethod + def encode(cls, message, secret): + header = {"alg": cls.JWT_ALGORITHM, "typ": "JWT"} + base64_header = cls.array_to_base64_string(header) + base64_message = cls.array_to_base64_string(message) + base64_secret = base64.urlsafe_b64encode(hmac.new( + bytes(secret, 'UTF-8'), + msg=bytes(base64_header + "." + base64_message, 'UTF-8'), + digestmod=hashlib.sha256 + ).digest()).decode('UTF-8').replace("=", "") + return base64_header + "." + base64_message + "." + base64_secret + + @classmethod + def decode(cls, message, secret): + split_message = message.split(".") + base64_header = split_message[0] + base64_message = split_message[1] + base64_secret = split_message[2] + + decoded_header = json.loads(base64.urlsafe_b64decode(base64_header)) + + if decoded_header['alg'] != cls.JWT_ALGORITHM: + raise InvalidAlgorithmError() + + incoming_secret = base64.urlsafe_b64encode(hmac.new( + bytes(secret, 'UTF-8'), + msg=bytes(base64_header + "." + base64_message, 'UTF-8'), + digestmod=hashlib.sha256 + ).digest()).decode('UTF-8').replace("=", "") + + if base64_secret != incoming_secret: + raise InvalidTokenError() + + # We add ==== as padding to ignore the requirement to have correct padding in + # the urlsafe_b64decode method. + decoded_message = json.loads(base64.urlsafe_b64decode(base64_message + "====")) + now = int(time.time()) + if decoded_message['exp'] < now: + raise ExpiredSignatureError() + + return decoded_message + @classmethod def gen_token(cls, username): if not cls._secret: @@ -59,13 +109,13 @@ class JwtManager(object): 'iat': now, 'username': username } - return jwt.encode(payload, cls._secret, algorithm=cls.JWT_ALGORITHM) # type: ignore + return cls.encode(payload, cls._secret) # type: ignore @classmethod def decode_token(cls, token): if not cls._secret: cls.init() - return jwt.decode(token, cls._secret, algorithms=cls.JWT_ALGORITHM) # type: ignore + return cls.decode(token, cls._secret) # type: ignore @classmethod def get_token_from_header(cls): @@ -99,8 +149,8 @@ class JwtManager(object): @classmethod def get_user(cls, token): try: - dtoken = JwtManager.decode_token(token) - if not JwtManager.is_blocklisted(dtoken['jti']): + dtoken = cls.decode_token(token) + if not cls.is_blocklisted(dtoken['jti']): user = AuthManager.get_user(dtoken['username']) if user.last_update <= dtoken['iat']: return user @@ -110,10 +160,12 @@ class JwtManager(object): ) else: cls.logger.debug('Token is block-listed') # type: ignore - except jwt.ExpiredSignatureError: + except ExpiredSignatureError: cls.logger.debug("Token has expired") # type: ignore - except jwt.InvalidTokenError: + except InvalidTokenError: cls.logger.debug("Failed to decode token") # type: ignore + except InvalidAlgorithmError: + cls.logger.debug("Only the HS256 algorithm is supported.") # type: ignore except UserDoesNotExist: cls.logger.debug( # type: ignore "Invalid token: user %s does not exist", dtoken['username'] -- 2.39.2