Browse Source

Merge "Enable HTTP Basic authentication for JSON-RPC"

tags/15.1.0
Zuul 2 weeks ago
committed by Gerrit Code Review
parent
commit
8078a1405e
6 changed files with 161 additions and 11 deletions
  1. +22
    -1
      doc/source/install/standalone.rst
  2. +2
    -2
      ironic/common/json_rpc/__init__.py
  3. +14
    -3
      ironic/common/json_rpc/client.py
  4. +8
    -2
      ironic/common/json_rpc/server.py
  5. +17
    -1
      ironic/conf/json_rpc.py
  6. +98
    -2
      ironic/tests/unit/common/test_json_rpc.py

+ 22
- 1
doc/source/install/standalone.rst View File

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



+ 2
- 2
ironic/common/json_rpc/__init__.py View File

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

+ 14
- 3
ironic/common/json_rpc/client.py View File

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



+ 8
- 2
ironic/common/json_rpc/server.py View File

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


+ 17
- 1
ironic/conf/json_rpc.py View File

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




+ 98
- 2
ironic/tests/unit/common/test_json_rpc.py View File

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

Loading…
Cancel
Save