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:
parent
3a9e117aad
commit
196c019771
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
@ -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':
|
||||||
|
@ -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
|
||||||
|
26
releasenotes/notes/http-basic-auth-fbe1da9669f5388c.yaml
Normal file
26
releasenotes/notes/http-basic-auth-fbe1da9669f5388c.yaml
Normal 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
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user