Enable HTTP Basic authentication for JSON-RPC

Change-Id: I90c4d5ef925c1dbb120948e3c0fe5982c9d997a0
Story: 2007656
Task: 39827
This commit is contained in:
Steve Baker 2020-06-16 11:30:02 +12:00
parent 62408b32ae
commit 350d84ed41
6 changed files with 161 additions and 11 deletions
doc/source/install
ironic

@ -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.

@ -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

@ -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

@ -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 '

@ -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.")),
]

@ -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)