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

Change-Id: If50dfbfc18445ad9fe27e17cb0ee1b317ff25a0b
Depends-On: https://review.opendev.org/729070
Story: 2007656
Task: 39826
This commit is contained in:
Steve Baker 2020-06-04 10:25:40 +12:00
parent 3a9e117aad
commit 196c019771
9 changed files with 119 additions and 17 deletions

View File

@ -35,10 +35,17 @@ _OPTS = [
'hostname, FQDN, or IP address.')), 'hostname, FQDN, or IP address.')),
cfg.StrOpt('auth_strategy', cfg.StrOpt('auth_strategy',
default='keystone', 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 ' 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.')), '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', cfg.IntOpt('timeout',
default=3600, default=3600,
# We're using timedelta which can overflow if somebody sets this # We're using timedelta which can overflow if somebody sets this

View File

@ -38,6 +38,8 @@ CONF = ironic_inspector.conf.CONF
_app = flask.Flask(__name__) _app = flask.Flask(__name__)
_wsgi_app = _app.wsgi_app
LOG = utils.getProcessingLogger(__name__) LOG = utils.getProcessingLogger(__name__)
MINIMUM_API_VERSION = (1, 0) MINIMUM_API_VERSION = (1, 0)
@ -51,8 +53,13 @@ def _init_middleware():
:returns: None :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) utils.add_auth_middleware(_app)
elif CONF.auth_strategy == 'http_basic':
utils.add_basic_auth_middleware(_app)
else: else:
LOG.warning('Starting unauthenticated, please check' LOG.warning('Starting unauthenticated, please check'
' configuration') ' configuration')

View File

@ -13,6 +13,8 @@
import datetime import datetime
import json import json
import os
import tempfile
import unittest import unittest
from unittest import mock from unittest import mock
@ -44,11 +46,16 @@ def _get_error(res):
class BaseAPITest(test_base.BaseTest): 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): def setUp(self):
super(BaseAPITest, self).setUp() super(BaseAPITest, self).setUp()
main._app.config['TESTING'] = True self.init_app()
self.app = main._app.test_client()
CONF.set_override('auth_strategy', 'noauth')
self.uuid = uuidutils.generate_uuid() self.uuid = uuidutils.generate_uuid()
self.rpc_get_client_mock = self.useFixture( self.rpc_get_client_mock = self.useFixture(
fixtures.MockPatchObject(rpc, 'get_client', autospec=True)).mock fixtures.MockPatchObject(rpc, 'get_client', autospec=True)).mock
@ -57,10 +64,11 @@ class BaseAPITest(test_base.BaseTest):
class TestApiIntrospect(BaseAPITest): 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.assertEqual(202, res.status_code)
self.client_mock.call.assert_called_once_with({}, 'do_introspection', self.client_mock.call.assert_called_once_with({}, 'do_introspection',
@ -70,7 +78,8 @@ class TestApiIntrospect(BaseAPITest):
def test_intospect_failed(self): def test_intospect_failed(self):
self.client_mock.call.side_effect = utils.Error("boom") 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(400, res.status_code)
self.assertEqual( self.assertEqual(
@ -82,7 +91,8 @@ class TestApiIntrospect(BaseAPITest):
token=None) token=None)
def test_introspect_no_manage_boot(self): 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.assertEqual(202, res.status_code)
self.client_mock.call.assert_called_once_with({}, 'do_introspection', self.client_mock.call.assert_called_once_with({}, 'do_introspection',
node_id=self.uuid, node_id=self.uuid,
@ -91,7 +101,8 @@ class TestApiIntrospect(BaseAPITest):
def test_introspect_can_manage_boot_false(self): def test_introspect_can_manage_boot_false(self):
CONF.set_override('can_manage_boot', False) 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.assertEqual(202, res.status_code)
self.client_mock.call.assert_called_once_with({}, 'do_introspection', self.client_mock.call.assert_called_once_with({}, 'do_introspection',
node_id=self.uuid, node_id=self.uuid,
@ -100,12 +111,14 @@ class TestApiIntrospect(BaseAPITest):
def test_introspect_can_manage_boot_false_failed(self): def test_introspect_can_manage_boot_false_failed(self):
CONF.set_override('can_manage_boot', False) 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.assertEqual(400, res.status_code)
self.assertFalse(self.client_mock.call.called) self.assertFalse(self.client_mock.call.called)
def test_introspect_wrong_manage_boot(self): 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.assertEqual(400, res.status_code)
self.assertFalse(self.client_mock.call.called) self.assertFalse(self.client_mock.call.called)
self.assertEqual( self.assertEqual(
@ -122,6 +135,30 @@ class TestApiIntrospect(BaseAPITest):
self.assertFalse(self.client_mock.call.called) 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): class TestApiContinue(BaseAPITest):
def test_continue(self): def test_continue(self):
# should be ignored # should be ignored

View File

@ -76,6 +76,11 @@ class TestCheckAuth(base.BaseTest):
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'}) request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
utils.check_auth(request) 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): def test_public_api(self):
request = mock.Mock(headers={'X-Identity-Status': 'Invalid'}) request = mock.Mock(headers={'X-Identity-Status': 'Invalid'})
request.context = context.RequestContext(is_public_api=True) request.context = context.RequestContext(is_public_api=True)

View File

@ -38,6 +38,8 @@ class TestWSGIServiceInitMiddleware(BaseWSGITest):
super(TestWSGIServiceInitMiddleware, self).setUp() super(TestWSGIServiceInitMiddleware, self).setUp()
self.mock_add_auth_middleware = self.useFixture( self.mock_add_auth_middleware = self.useFixture(
fixtures.MockPatchObject(utils, 'add_auth_middleware')).mock 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( self.mock_add_cors_middleware = self.useFixture(
fixtures.MockPatchObject(utils, 'add_cors_middleware')).mock fixtures.MockPatchObject(utils, 'add_cors_middleware')).mock
self.mock_log = self.useFixture(fixtures.MockPatchObject( 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_auth_middleware.assert_called_once_with(self.app)
self.mock_add_cors_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): def test_init_middleware_noauth(self):
CONF.set_override('auth_strategy', 'noauth') CONF.set_override('auth_strategy', 'noauth')
wsgi_service.WSGIService() wsgi_service.WSGIService()

View File

@ -15,6 +15,7 @@ import datetime
import logging as pylog import logging as pylog
import futurist import futurist
from ironic_lib import auth_basic
from keystonemiddleware import auth_token from keystonemiddleware import auth_token
from openstack.baremetal.v1 import node from openstack.baremetal.v1 import node
from oslo_config import cfg 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) 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): def add_cors_middleware(app):
"""Create a CORS wrapper """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 :param target: dict-like structure to check rule against
:raises: utils.Error if access is denied :raises: utils.Error if access is denied
""" """
if CONF.auth_strategy == 'noauth': if CONF.auth_strategy != 'keystone':
return return
if not request.context.is_public_api: if not request.context.is_public_api:
if request.headers.get('X-Identity-Status', '').lower() == 'invalid': if request.headers.get('X-Identity-Status', '').lower() == 'invalid':

View File

@ -34,7 +34,7 @@ idna==2.9
ifaddr==0.1.6 ifaddr==0.1.6
imagesize==1.2.0 imagesize==1.2.0
importlib-metadata==1.6.0 importlib-metadata==1.6.0
ironic-lib==2.17.0 ironic-lib==4.3.0
iso8601==0.1.12 iso8601==0.1.12
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.2 Jinja2==2.11.2

View File

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

View File

@ -7,7 +7,7 @@ construct>=2.9.39 # MIT
eventlet!=0.18.3,!=0.20.1,>=0.18.2 # MIT eventlet!=0.18.3,!=0.20.1,>=0.18.2 # MIT
Flask>=1.0 # BSD Flask>=1.0 # BSD
futurist>=1.2.0 # Apache-2.0 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 jsonpath-rw<2.0,>=1.2.0 # Apache-2.0
jsonschema>=3.2.0 # MIT jsonschema>=3.2.0 # MIT
keystoneauth1>=3.18.0 # Apache-2.0 keystoneauth1>=3.18.0 # Apache-2.0