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.')),
|
||||
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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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':
|
||||
|
@ -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
|
||||
|
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
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user