From 350d84ed41d52d7db8d1fdaee9a267e82a4e6073 Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Tue, 16 Jun 2020 11:30:02 +1200 Subject: [PATCH] Enable HTTP Basic authentication for JSON-RPC Change-Id: I90c4d5ef925c1dbb120948e3c0fe5982c9d997a0 Story: 2007656 Task: 39827 --- doc/source/install/standalone.rst | 23 ++++- ironic/common/json_rpc/__init__.py | 4 +- ironic/common/json_rpc/client.py | 17 +++- ironic/common/json_rpc/server.py | 10 ++- ironic/conf/json_rpc.py | 18 +++- ironic/tests/unit/common/test_json_rpc.py | 100 +++++++++++++++++++++- 6 files changed, 161 insertions(+), 11 deletions(-) diff --git a/doc/source/install/standalone.rst b/doc/source/install/standalone.rst index 21eb7302cb..71479c47e1 100644 --- a/doc/source/install/standalone.rst +++ b/doc/source/install/standalone.rst @@ -12,7 +12,7 @@ You should make the following changes to ``/etc/ironic/ironic.conf``: ... auth_strategy=noauth - Another options is ``http_basic`` where the credentials are stored in an + Another option is ``http_basic`` where the credentials are stored in an `Apache htpasswd format`_ file:: [DEFAULT] @@ -52,6 +52,27 @@ You should make the following changes to ``/etc/ironic/ironic.conf``: [DEFAULT] rpc_transport = json-rpc + JSON RPC also has its own authentication strategy. If it is not specified then + the stategy defaults to ``[DEFAULT]`` ``auth_strategy``. The following will + set JSON RPC to ``noauth``:: + + [json_rpc] + auth_strategy=noauth + + For ``http_basic`` the conductor server needs a credentials file to validate + requests:: + + [json_rpc] + auth_strategy=http_basic + http_basic_auth_user_file=/etc/ironic/htpasswd-json-rpc + + The API server also needs client-side credentials to be specified:: + + [json_rpc] + auth_strategy=http_basic + http_basic_username=myName + http_basic_password=myPassword + If you don't use Image service, it's possible to provide images to Bare Metal service via a URL. diff --git a/ironic/common/json_rpc/__init__.py b/ironic/common/json_rpc/__init__.py index 280b93f623..ad58e3bc6b 100644 --- a/ironic/common/json_rpc/__init__.py +++ b/ironic/common/json_rpc/__init__.py @@ -16,5 +16,5 @@ from oslo_config import cfg CONF = cfg.CONF -def require_authentication(): - return (CONF.json_rpc.auth_strategy or CONF.auth_strategy) == 'keystone' +def auth_strategy(): + return CONF.json_rpc.auth_strategy or CONF.auth_strategy diff --git a/ironic/common/json_rpc/client.py b/ironic/common/json_rpc/client.py index e83ed1d8e9..8d172196fe 100644 --- a/ironic/common/json_rpc/client.py +++ b/ironic/common/json_rpc/client.py @@ -15,6 +15,8 @@ This client is compatible with any JSON RPC 2.0 implementation, including ours. """ +import base64 + from oslo_config import cfg from oslo_log import log from oslo_utils import importutils @@ -36,18 +38,27 @@ def _get_session(): global _SESSION if _SESSION is None: - if json_rpc.require_authentication(): + auth_strategy = json_rpc.auth_strategy() + if auth_strategy == 'keystone': auth = keystone.get_auth('json_rpc') else: auth = None session = keystone.get_session('json_rpc', auth=auth) - session.headers = { + headers = { 'Content-Type': 'application/json' } + if auth_strategy == 'http_basic': + token = '{}:{}'.format( + CONF.json_rpc.http_basic_username, + CONF.json_rpc.http_basic_password + ).encode('utf-8') + encoded = base64.b64encode(token).decode('utf-8') + headers['Authorization'] = 'Basic {}'.format(encoded) # Adds options like connect_retries - _SESSION = keystone.get_adapter('json_rpc', session=session) + _SESSION = keystone.get_adapter('json_rpc', session=session, + additional_headers=headers) return _SESSION diff --git a/ironic/common/json_rpc/server.py b/ironic/common/json_rpc/server.py index 39fb67be0b..0f9db5fc96 100644 --- a/ironic/common/json_rpc/server.py +++ b/ironic/common/json_rpc/server.py @@ -21,6 +21,7 @@ https://www.jsonrpc.org/specification. Main differences: import json +from ironic_lib import auth_basic from keystonemiddleware import auth_token from oslo_config import cfg from oslo_log import log @@ -90,9 +91,14 @@ class WSGIService(service.Service): self.manager = manager self.serializer = serializer self._method_map = _build_method_map(manager) - if json_rpc.require_authentication(): + auth_strategy = json_rpc.auth_strategy() + if auth_strategy == 'keystone': conf = dict(CONF.keystone_authtoken) app = auth_token.AuthProtocol(self._application, conf) + elif auth_strategy == 'http_basic': + app = auth_basic.BasicAuthMiddleware( + self._application, + cfg.CONF.json_rpc.http_basic_auth_user_file) else: app = self._application self.server = wsgi.Server(CONF, 'ironic-json-rpc', app, @@ -109,7 +115,7 @@ class WSGIService(service.Service): return webob.Response(status_code=405, json_body=body)( environment, start_response) - if json_rpc.require_authentication(): + if json_rpc.auth_strategy() == 'keystone': roles = (request.headers.get('X-Roles') or '').split(',') if 'admin' not in roles: LOG.debug('Roles %s do not contain "admin", rejecting ' diff --git a/ironic/conf/json_rpc.py b/ironic/conf/json_rpc.py index e86e4b4862..d88844e090 100644 --- a/ironic/conf/json_rpc.py +++ b/ironic/conf/json_rpc.py @@ -19,9 +19,14 @@ opts = [ cfg.StrOpt('auth_strategy', choices=[('noauth', _('no authentication')), ('keystone', _('use the Identity service for ' - 'authentication'))], + 'authentication')), + ('http_basic', _('HTTP basic authentication'))], help=_('Authentication strategy used by JSON RPC. Defaults to ' 'the global auth_strategy setting.')), + cfg.StrOpt('http_basic_auth_user_file', + default='/etc/ironic/htpasswd-json-rpc', + help=_('Path to Apache format user authentication file used ' + 'when auth_strategy=http_basic')), cfg.HostAddressOpt('host_ip', default='::', help=_('The IP address or hostname on which JSON RPC ' @@ -32,6 +37,17 @@ opts = [ cfg.BoolOpt('use_ssl', default=False, help=_('Whether to use TLS for JSON RPC')), + cfg.StrOpt('http_basic_username', + default='', + help=_("Name of the user to use for HTTP Basic authentication " + "client requests. Required when " + "auth_strategy=http_basic.")), + cfg.StrOpt('http_basic_password', + default='', + secret=True, + help=_("Password to use for HTTP Basic authentication " + "client requests. Required when " + "auth_strategy=http_basic.")), ] diff --git a/ironic/tests/unit/common/test_json_rpc.py b/ironic/tests/unit/common/test_json_rpc.py index 0f5020bddc..e76500215c 100644 --- a/ironic/tests/unit/common/test_json_rpc.py +++ b/ironic/tests/unit/common/test_json_rpc.py @@ -10,6 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. +import os +import tempfile from unittest import mock import fixtures @@ -109,7 +111,7 @@ class TestService(test_base.TestCase): else: return response.json_body else: - self.assertFalse(response.text) + return response.text def _check(self, body, result=None, error=None, request_id='abcd'): self.assertEqual('2.0', body.pop('jsonrpc')) @@ -119,6 +121,33 @@ class TestService(test_base.TestCase): else: self.assertEqual({'result': result}, body) + def _setup_http_basic(self): + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write('myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + self.addCleanup(os.remove, f.name) + self.config(http_basic_auth_user_file=f.name, group='json_rpc') + self.config(auth_strategy='http_basic', group='json_rpc') + # self.config(http_basic_username='myUser', group='json_rpc') + # self.config(http_basic_password='myPassword', group='json_rpc') + self.service = server.WSGIService(FakeManager(), self.serializer) + self.app = self.server_mock.call_args[0][2] + + def test_http_basic_not_authenticated(self): + self._setup_http_basic() + self._request('success', {'context': self.ctx, 'x': 42}, + request_id=None, expected_error=401) + + def test_http_basic(self): + self._setup_http_basic() + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic bXlOYW1lOm15UGFzc3dvcmQ=' + } + body = self._request('success', {'context': self.ctx, 'x': 42}, + headers=headers) + self._check(body, result=42) + def test_success(self): body = self._request('success', {'context': self.ctx, 'x': 42}) self._check(body, result=42) @@ -130,7 +159,7 @@ class TestService(test_base.TestCase): def test_notification(self): body = self._request('no_result', {'context': self.ctx}, request_id=None) - self.assertIsNone(body) + self.assertEqual('', body) def test_no_context(self): body = self._request('no_context') @@ -542,3 +571,70 @@ class TestClient(test_base.TestCase): 'redfish_password': '***'}) resp_text = mock_log.call_args_list[1][0][2] self.assertEqual(body.replace('passw0rd', '***'), resp_text) + + +@mock.patch('ironic.common.json_rpc.client.keystone', autospec=True) +class TestSession(test_base.TestCase): + + def setUp(self): + super(TestSession, self).setUp() + client._SESSION = None + + def test_noauth(self, mock_keystone): + self.config(auth_strategy='noauth', group='json_rpc') + session = client._get_session() + + mock_keystone.get_auth.assert_not_called() + mock_keystone.get_session.assert_called_once_with( + 'json_rpc', auth=None) + + internal_session = mock_keystone.get_session.return_value + + mock_keystone.get_adapter.assert_called_once_with( + 'json_rpc', + session=internal_session, + additional_headers={ + 'Content-Type': 'application/json' + }) + self.assertEqual(mock_keystone.get_adapter.return_value, session) + + def test_keystone(self, mock_keystone): + self.config(auth_strategy='keystone', group='json_rpc') + session = client._get_session() + + mock_keystone.get_auth.assert_called_once_with('json_rpc') + auth = mock_keystone.get_auth.return_value + + mock_keystone.get_session.assert_called_once_with( + 'json_rpc', auth=auth) + + internal_session = mock_keystone.get_session.return_value + + mock_keystone.get_adapter.assert_called_once_with( + 'json_rpc', + session=internal_session, + additional_headers={ + 'Content-Type': 'application/json' + }) + self.assertEqual(mock_keystone.get_adapter.return_value, session) + + def test_http_basic(self, mock_keystone): + self.config(auth_strategy='http_basic', group='json_rpc') + self.config(http_basic_username='myName', group='json_rpc') + self.config(http_basic_password='myPassword', group='json_rpc') + session = client._get_session() + + mock_keystone.get_auth.assert_not_called() + mock_keystone.get_session.assert_called_once_with( + 'json_rpc', auth=None) + + internal_session = mock_keystone.get_session.return_value + + mock_keystone.get_adapter.assert_called_once_with( + 'json_rpc', + session=internal_session, + additional_headers={ + 'Authorization': 'Basic bXlOYW1lOm15UGFzc3dvcmQ=', + 'Content-Type': 'application/json' + }) + self.assertEqual(mock_keystone.get_adapter.return_value, session)