diff --git a/ironic/api/app.py b/ironic/api/app.py index c3b18aa819..8780037b18 100644 --- a/ironic/api/app.py +++ b/ironic/api/app.py @@ -15,6 +15,7 @@ # License for the specific language governing permissions and limitations # under the License. +from ironic_lib import auth_basic import keystonemiddleware.audit as audit_middleware from keystonemiddleware import auth_token from oslo_config import cfg @@ -98,11 +99,18 @@ def setup_app(pecan_config=None, extra_hooks=None): reason=e ) + auth_middleware = None if CONF.auth_strategy == "keystone": + auth_middleware = auth_token.AuthProtocol( + app, {"oslo_config_config": cfg.CONF}) + elif CONF.auth_strategy == "http_basic": + auth_middleware = auth_basic.BasicAuthMiddleware( + app, cfg.CONF.http_basic_auth_user_file) + + if auth_middleware: app = auth_public_routes.AuthPublicRoutes( app, - auth=auth_token.AuthProtocol( - app, {"oslo_config_config": cfg.CONF}), + auth=auth_middleware, public_api_routes=pecan_config.app.acl_public_routes) if CONF.profiler.enabled: diff --git a/ironic/api/hooks.py b/ironic/api/hooks.py index 2e7cc9e7ad..758e56e299 100644 --- a/ironic/api/hooks.py +++ b/ironic/api/hooks.py @@ -97,7 +97,7 @@ class ContextHook(hooks.PecanHook): ctx = context.RequestContext.from_environ(state.request.environ, is_public_api=is_public_api) # Do not pass any token with context for noauth mode - if cfg.CONF.auth_strategy == 'noauth': + if cfg.CONF.auth_strategy != 'keystone': ctx.auth_token = None creds = ctx.to_policy_values() diff --git a/ironic/common/policy.py b/ironic/common/policy.py index cc87727413..2407977311 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -633,9 +633,9 @@ def authorize(rule, target, creds, *args, **kwargs): Checks authorization of a rule against the target and credentials, and raises an exception if the rule is not defined. - Always returns true if CONF.auth_strategy == noauth. + Always returns true if CONF.auth_strategy is not keystone. """ - if CONF.auth_strategy == 'noauth': + if CONF.auth_strategy != 'keystone': return True enforcer = get_enforcer() try: diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 3de2f5947e..2936d232a5 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -58,10 +58,15 @@ api_opts = [ default='keystone', choices=[('noauth', _('no authentication')), ('keystone', _('use the Identity service for ' - 'authentication'))], + 'authentication')), + ('http_basic', _('HTTP basic authentication'))], help=_('Authentication strategy used by ironic-api. "noauth" should ' 'not be used in a production environment because all ' 'authentication will be disabled.')), + cfg.StrOpt('http_basic_auth_user_file', + default='/etc/ironic/htpasswd', + help=_('Path to Apache format user authentication file used ' + 'when auth_strategy=http_basic')), cfg.BoolOpt('debug_tracebacks_in_api', default=False, help=_('Return server tracebacks in the API response for any ' diff --git a/ironic/drivers/modules/inspector.py b/ironic/drivers/modules/inspector.py index be0a5e72bc..4af309e439 100644 --- a/ironic/drivers/modules/inspector.py +++ b/ironic/drivers/modules/inspector.py @@ -45,7 +45,7 @@ _IRONIC_MANAGES_BOOT = 'inspector_manage_boot' def _get_inspector_session(**kwargs): global _INSPECTOR_SESSION if not _INSPECTOR_SESSION: - if CONF.auth_strategy == 'noauth': + if CONF.auth_strategy != 'keystone': # NOTE(dtantsur): using set_default instead of set_override because # the native keystoneauth option must have priority. CONF.set_default('auth_type', 'none', group='inspector') diff --git a/ironic/tests/unit/api/test_middleware.py b/ironic/tests/unit/api/test_middleware.py index dfc7ed991a..80f768fd13 100644 --- a/ironic/tests/unit/api/test_middleware.py +++ b/ironic/tests/unit/api/test_middleware.py @@ -16,11 +16,15 @@ Tests to assert that various incorporated middleware works as expected. """ from http import client as http_client +import os +import tempfile from oslo_config import cfg import oslo_middleware.cors as cors_middleware from ironic.tests.unit.api import base +from ironic.tests.unit.api import utils +from ironic.tests.unit.db import utils as db_utils class TestCORSMiddleware(base.BaseApiTest): @@ -112,3 +116,38 @@ class TestCORSMiddleware(base.BaseApiTest): self.assertEqual( self._response_string(http_client.OK), response.status) self.assertNotIn('Access-Control-Allow-Origin', response.headers) + + +class TestBasicAuthMiddleware(base.BaseApiTest): + + def _make_app(self): + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write('myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + cfg.CONF.set_override('http_basic_auth_user_file', f.name) + self.addCleanup(os.remove, cfg.CONF.http_basic_auth_user_file) + + cfg.CONF.set_override('auth_strategy', 'http_basic') + return super(TestBasicAuthMiddleware, self)._make_app() + + def setUp(self): + super(TestBasicAuthMiddleware, self).setUp() + self.environ = {'fake.cache': utils.FakeMemcache()} + self.fake_db_node = db_utils.get_test_node(chassis_id=None) + + def test_not_authenticated(self): + response = self.get_json('/chassis', expect_errors=True) + self.assertEqual(http_client.UNAUTHORIZED, response.status_int) + self.assertEqual( + 'Basic realm="Baremetal API"', + response.headers['WWW-Authenticate'] + ) + + def test_authenticated(self): + auth_header = {'Authorization': 'Basic bXlOYW1lOm15UGFzc3dvcmQ='} + response = self.get_json('/chassis', headers=auth_header) + self.assertEqual({'chassis': []}, response) + + def test_public_unauthenticated(self): + response = self.get_json('/') + self.assertEqual('v1', response['id']) diff --git a/lower-constraints.txt b/lower-constraints.txt index de3c260f74..acee123652 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -35,7 +35,7 @@ greenlet==0.4.15 hacking==3.0.0 ifaddr==0.1.6 importlib-metadata==1.6.0 -ironic-lib==2.17.1 +ironic-lib==4.3.0 iso8601==0.1.11 Jinja2==2.10 jmespath==0.9.5 diff --git a/releasenotes/notes/http-basic-auth-f8c0536eba989918.yaml b/releasenotes/notes/http-basic-auth-f8c0536eba989918.yaml new file mode 100644 index 0000000000..1f0c8df18b --- /dev/null +++ b/releasenotes/notes/http-basic-auth-f8c0536eba989918.yaml @@ -0,0 +1,32 @@ +--- +features: + - | + Enable Basic HTTP authentication middleware. + + Having noauth as the only option for standalone ironic causes constraints + on how the API is exposed on the network. Having some kind of + authentication layer behind a TLS deployment eases these constraints. + + 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/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. + + Like the ``noauth`` auth strategy, the ``http_basic`` auth strategy is + intended for standalone deployments of ironic, and integration with other + OpenStack services cannot depend on a service catalog. + + 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 HTTP basic authentication, the following can be done: + * Set ``/etc/ironic/ironic.conf`` ``DEFAULT`` ``auth_strategy`` to + * ``http_basic`` Populate the htpasswd file with entries, for example: + ``htpasswd -nbB myName myPassword >> /etc/ironic/htpassw + * Make basic authenticated HTTP requests, for example: + ``curl --user myName:myPassword http://localhost:6385/v1/drivers`` + + [1] https://httpd.apache.org/docs/current/misc/password_encryptions.html diff --git a/requirements.txt b/requirements.txt index 602487484c..9a59e67777 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ python-cinderclient!=4.0.0,>=3.3.0 # Apache-2.0 python-neutronclient>=6.7.0 # Apache-2.0 python-glanceclient>=2.8.0 # Apache-2.0 keystoneauth1>=3.18.0 # Apache-2.0 -ironic-lib>=2.17.1 # Apache-2.0 +ironic-lib>=4.3.0 # Apache-2.0 python-swiftclient>=3.2.0 # Apache-2.0 pytz>=2013.6 # MIT stevedore>=1.20.0 # Apache-2.0