diff --git a/ironic_inspector/conf/default.py b/ironic_inspector/conf/default.py index 2c54426ab..4b25fe422 100644 --- a/ironic_inspector/conf/default.py +++ b/ironic_inspector/conf/default.py @@ -35,10 +35,17 @@ _OPTS = [ 'hostname, FQDN, or IP address.')), cfg.StrOpt('auth_strategy', default='keystone', - choices=('keystone', 'noauth'), + choices=[('noauth', _('no authentication')), + ('keystone', _('use the Identity service for ' + 'authentication')), + ('http_basic', _('HTTP basic authentication'))], help=_('Authentication method used on the ironic-inspector ' - 'API. Either "noauth" or "keystone" are currently valid ' + 'API. "noauth", "keystone" or "http_basic" are valid ' 'options. "noauth" will disable all authentication.')), + cfg.StrOpt('http_basic_auth_user_file', + default='/etc/ironic-inspector/htpasswd', + help=_('Path to Apache format user authentication file used ' + 'when auth_strategy=http_basic')), cfg.IntOpt('timeout', default=3600, # We're using timedelta which can overflow if somebody sets this diff --git a/ironic_inspector/main.py b/ironic_inspector/main.py index 8862e4eaa..e5beb0e65 100644 --- a/ironic_inspector/main.py +++ b/ironic_inspector/main.py @@ -38,6 +38,8 @@ CONF = ironic_inspector.conf.CONF _app = flask.Flask(__name__) +_wsgi_app = _app.wsgi_app + LOG = utils.getProcessingLogger(__name__) MINIMUM_API_VERSION = (1, 0) @@ -51,8 +53,13 @@ def _init_middleware(): :returns: None """ - if CONF.auth_strategy != 'noauth': + + # ensure original root app is restored before wrapping it + _app.wsgi_app = _wsgi_app + if CONF.auth_strategy == 'keystone': utils.add_auth_middleware(_app) + elif CONF.auth_strategy == 'http_basic': + utils.add_basic_auth_middleware(_app) else: LOG.warning('Starting unauthenticated, please check' ' configuration') diff --git a/ironic_inspector/test/unit/test_main.py b/ironic_inspector/test/unit/test_main.py index 23dde5912..d1377f303 100644 --- a/ironic_inspector/test/unit/test_main.py +++ b/ironic_inspector/test/unit/test_main.py @@ -13,6 +13,8 @@ import datetime import json +import os +import tempfile import unittest from unittest import mock @@ -44,11 +46,16 @@ def _get_error(res): class BaseAPITest(test_base.BaseTest): + + def init_app(self): + CONF.set_override('auth_strategy', 'noauth') + main._app.testing = True + self.app = main.get_app().test_client() + self.headers = {} + def setUp(self): super(BaseAPITest, self).setUp() - main._app.config['TESTING'] = True - self.app = main._app.test_client() - CONF.set_override('auth_strategy', 'noauth') + self.init_app() self.uuid = uuidutils.generate_uuid() self.rpc_get_client_mock = self.useFixture( fixtures.MockPatchObject(rpc, 'get_client', autospec=True)).mock @@ -57,10 +64,11 @@ class BaseAPITest(test_base.BaseTest): class TestApiIntrospect(BaseAPITest): - def test_introspect_no_authentication(self): - CONF.set_override('auth_strategy', 'noauth') - res = self.app.post('/v1/introspection/%s' % self.uuid) + def test_introspect(self): + + res = self.app.post('/v1/introspection/%s' % self.uuid, + headers=self.headers) self.assertEqual(202, res.status_code) self.client_mock.call.assert_called_once_with({}, 'do_introspection', @@ -70,7 +78,8 @@ class TestApiIntrospect(BaseAPITest): def test_intospect_failed(self): self.client_mock.call.side_effect = utils.Error("boom") - res = self.app.post('/v1/introspection/%s' % self.uuid) + res = self.app.post('/v1/introspection/%s' % self.uuid, + headers=self.headers) self.assertEqual(400, res.status_code) self.assertEqual( @@ -82,7 +91,8 @@ class TestApiIntrospect(BaseAPITest): token=None) def test_introspect_no_manage_boot(self): - res = self.app.post('/v1/introspection/%s?manage_boot=0' % self.uuid) + res = self.app.post('/v1/introspection/%s?manage_boot=0' % self.uuid, + headers=self.headers) self.assertEqual(202, res.status_code) self.client_mock.call.assert_called_once_with({}, 'do_introspection', node_id=self.uuid, @@ -91,7 +101,8 @@ class TestApiIntrospect(BaseAPITest): def test_introspect_can_manage_boot_false(self): CONF.set_override('can_manage_boot', False) - res = self.app.post('/v1/introspection/%s?manage_boot=0' % self.uuid) + res = self.app.post('/v1/introspection/%s?manage_boot=0' % self.uuid, + headers=self.headers) self.assertEqual(202, res.status_code) self.client_mock.call.assert_called_once_with({}, 'do_introspection', node_id=self.uuid, @@ -100,12 +111,14 @@ class TestApiIntrospect(BaseAPITest): def test_introspect_can_manage_boot_false_failed(self): CONF.set_override('can_manage_boot', False) - res = self.app.post('/v1/introspection/%s' % self.uuid) + res = self.app.post('/v1/introspection/%s' % self.uuid, + headers=self.headers) self.assertEqual(400, res.status_code) self.assertFalse(self.client_mock.call.called) def test_introspect_wrong_manage_boot(self): - res = self.app.post('/v1/introspection/%s?manage_boot=foo' % self.uuid) + res = self.app.post('/v1/introspection/%s?manage_boot=foo' % self.uuid, + headers=self.headers) self.assertEqual(400, res.status_code) self.assertFalse(self.client_mock.call.called) self.assertEqual( @@ -122,6 +135,30 @@ class TestApiIntrospect(BaseAPITest): self.assertFalse(self.client_mock.call.called) +class TestBasicAuthApiIntrospect(TestApiIntrospect): + + def init_app(self): + CONF.set_override('auth_strategy', 'http_basic') + 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) + CONF.set_override('http_basic_auth_user_file', f.name) + main._app.config['TESTING'] = True + self.app = main.get_app().test_client() + + # base64 encode myName:myPassword + self.headers = {'Authorization': 'Basic bXlOYW1lOm15UGFzc3dvcmQ='} + + def test_introspect_failed_authentication(self): + # base64 encode myName:yourPassword + self.headers = {'Authorization': 'Basic bXlOYW1lOnlvdXJQYXNzd29yZA=='} + + res = self.app.post('/v1/introspection/%s' % self.uuid, + headers=self.headers) + self.assertEqual(401, res.status_code) + + class TestApiContinue(BaseAPITest): def test_continue(self): # should be ignored diff --git a/ironic_inspector/test/unit/test_utils.py b/ironic_inspector/test/unit/test_utils.py index 935432ae2..12eb165d0 100644 --- a/ironic_inspector/test/unit/test_utils.py +++ b/ironic_inspector/test/unit/test_utils.py @@ -76,6 +76,11 @@ class TestCheckAuth(base.BaseTest): request = mock.Mock(headers={'X-Identity-Status': 'Invalid'}) utils.check_auth(request) + def test_basic(self): + self.cfg.config(auth_strategy='http_basic') + request = mock.Mock(headers={'X-Identity-Status': 'Invalid'}) + utils.check_auth(request) + def test_public_api(self): request = mock.Mock(headers={'X-Identity-Status': 'Invalid'}) request.context = context.RequestContext(is_public_api=True) diff --git a/ironic_inspector/test/unit/test_wsgi_service.py b/ironic_inspector/test/unit/test_wsgi_service.py index cb95d24fc..8eb948fe1 100644 --- a/ironic_inspector/test/unit/test_wsgi_service.py +++ b/ironic_inspector/test/unit/test_wsgi_service.py @@ -38,6 +38,8 @@ class TestWSGIServiceInitMiddleware(BaseWSGITest): super(TestWSGIServiceInitMiddleware, self).setUp() self.mock_add_auth_middleware = self.useFixture( fixtures.MockPatchObject(utils, 'add_auth_middleware')).mock + self.mock_add_basic_auth_middleware = self.useFixture( + fixtures.MockPatchObject(utils, 'add_basic_auth_middleware')).mock self.mock_add_cors_middleware = self.useFixture( fixtures.MockPatchObject(utils, 'add_cors_middleware')).mock self.mock_log = self.useFixture(fixtures.MockPatchObject( @@ -51,6 +53,14 @@ class TestWSGIServiceInitMiddleware(BaseWSGITest): self.mock_add_auth_middleware.assert_called_once_with(self.app) self.mock_add_cors_middleware.assert_called_once_with(self.app) + def test_init_middleware_basic(self): + CONF.set_override('auth_strategy', 'http_basic') + wsgi_service.WSGIService() + + self.mock_add_auth_middleware.assert_not_called() + self.mock_add_basic_auth_middleware.assert_called_once_with(self.app) + self.mock_add_cors_middleware.assert_called_once_with(self.app) + def test_init_middleware_noauth(self): CONF.set_override('auth_strategy', 'noauth') wsgi_service.WSGIService() diff --git a/ironic_inspector/utils.py b/ironic_inspector/utils.py index 7f87e4070..8b8fb4baf 100644 --- a/ironic_inspector/utils.py +++ b/ironic_inspector/utils.py @@ -15,6 +15,7 @@ import datetime import logging as pylog import futurist +from ironic_lib import auth_basic from keystonemiddleware import auth_token from openstack.baremetal.v1 import node from oslo_config import cfg @@ -187,6 +188,15 @@ def add_auth_middleware(app): app.wsgi_app = auth_token.AuthProtocol(app.wsgi_app, auth_conf) +def add_basic_auth_middleware(app): + """Add HTTP Basic authentication middleware to Flask application. + + :param app: application. + """ + app.wsgi_app = auth_basic.BasicAuthMiddleware( + app.wsgi_app, CONF.http_basic_auth_user_file) + + def add_cors_middleware(app): """Create a CORS wrapper @@ -206,7 +216,7 @@ def check_auth(request, rule=None, target=None): :param target: dict-like structure to check rule against :raises: utils.Error if access is denied """ - if CONF.auth_strategy == 'noauth': + if CONF.auth_strategy != 'keystone': return if not request.context.is_public_api: if request.headers.get('X-Identity-Status', '').lower() == 'invalid': diff --git a/lower-constraints.txt b/lower-constraints.txt index a70d3e91c..804da82c3 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -34,7 +34,7 @@ idna==2.9 ifaddr==0.1.6 imagesize==1.2.0 importlib-metadata==1.6.0 -ironic-lib==2.17.0 +ironic-lib==4.3.0 iso8601==0.1.12 itsdangerous==1.1.0 Jinja2==2.11.2 diff --git a/releasenotes/notes/http-basic-auth-fbe1da9669f5388c.yaml b/releasenotes/notes/http-basic-auth-fbe1da9669f5388c.yaml new file mode 100644 index 000000000..c0633a5c4 --- /dev/null +++ b/releasenotes/notes/http-basic-auth-fbe1da9669f5388c.yaml @@ -0,0 +1,26 @@ +--- +features: + - | + Enable Basic HTTP authentication middleware. + + When the config option ``auth_strategy`` is set to ``http_basic`` then + non-public API calls require a valid HTTP Basic authentication header to be + set. The config option ``http_basic_auth_user_file`` defaults to + ``/etc/ironic-inspector/htpasswd`` and points to a file which supports the + Apache htpasswd syntax[1]. This file is read for every request, so no + service restart is required when changes are made. + + The only password digest supported is bcrypt, and the ``bcrypt`` + python library is used for password checks since it supports ``$2y$`` + prefixed bcrypt passwords as generated by the Apache htpasswd utility. + + To try basic authentication, the following can be done: + + * Set ``/etc/ironic-inspector/inspector.conf`` ``DEFAULT`` ``auth_strategy`` + to ``http_basic`` + * Populate the htpasswd file with entries, for example: + ``htpasswd -nbB myName myPassword >> /etc/ironic-inspector/htpasswd`` + * Make basic authenticated HTTP requests, for example: + ``curl --user myName:myPassword http://localhost:6385/v1/introspection`` + + [1] https://httpd.apache.org/docs/current/misc/password_encryptions.html diff --git a/requirements.txt b/requirements.txt index 92d559c20..644383988 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ construct>=2.9.39 # MIT eventlet!=0.18.3,!=0.20.1,>=0.18.2 # MIT Flask>=1.0 # BSD futurist>=1.2.0 # Apache-2.0 -ironic-lib>=2.17.0 # Apache-2.0 +ironic-lib>=4.3.0 # Apache-2.0 jsonpath-rw<2.0,>=1.2.0 # Apache-2.0 jsonschema>=3.2.0 # MIT keystoneauth1>=3.18.0 # Apache-2.0