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/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 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/htpasswd`` * 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 Change-Id: I7b89155d8bbd2f48e186c12adea9d6932cd0bfe2 Story: 2007656 Task: 39825 Depends-On: https://review.opendev.org/729070
This commit is contained in:
parent
3daffe07a8
commit
c6112b01c3
@ -15,6 +15,7 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
from ironic_lib import auth_basic
|
||||||
import keystonemiddleware.audit as audit_middleware
|
import keystonemiddleware.audit as audit_middleware
|
||||||
from keystonemiddleware import auth_token
|
from keystonemiddleware import auth_token
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
@ -98,11 +99,18 @@ def setup_app(pecan_config=None, extra_hooks=None):
|
|||||||
reason=e
|
reason=e
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auth_middleware = None
|
||||||
if CONF.auth_strategy == "keystone":
|
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_public_routes.AuthPublicRoutes(
|
||||||
app,
|
app,
|
||||||
auth=auth_token.AuthProtocol(
|
auth=auth_middleware,
|
||||||
app, {"oslo_config_config": cfg.CONF}),
|
|
||||||
public_api_routes=pecan_config.app.acl_public_routes)
|
public_api_routes=pecan_config.app.acl_public_routes)
|
||||||
|
|
||||||
if CONF.profiler.enabled:
|
if CONF.profiler.enabled:
|
||||||
|
@ -97,7 +97,7 @@ class ContextHook(hooks.PecanHook):
|
|||||||
ctx = context.RequestContext.from_environ(state.request.environ,
|
ctx = context.RequestContext.from_environ(state.request.environ,
|
||||||
is_public_api=is_public_api)
|
is_public_api=is_public_api)
|
||||||
# Do not pass any token with context for noauth mode
|
# 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
|
ctx.auth_token = None
|
||||||
|
|
||||||
creds = ctx.to_policy_values()
|
creds = ctx.to_policy_values()
|
||||||
|
@ -633,9 +633,9 @@ def authorize(rule, target, creds, *args, **kwargs):
|
|||||||
|
|
||||||
Checks authorization of a rule against the target and credentials, and
|
Checks authorization of a rule against the target and credentials, and
|
||||||
raises an exception if the rule is not defined.
|
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
|
return True
|
||||||
enforcer = get_enforcer()
|
enforcer = get_enforcer()
|
||||||
try:
|
try:
|
||||||
|
@ -58,10 +58,15 @@ api_opts = [
|
|||||||
default='keystone',
|
default='keystone',
|
||||||
choices=[('noauth', _('no authentication')),
|
choices=[('noauth', _('no authentication')),
|
||||||
('keystone', _('use the Identity service for '
|
('keystone', _('use the Identity service for '
|
||||||
'authentication'))],
|
'authentication')),
|
||||||
|
('http_basic', _('HTTP basic authentication'))],
|
||||||
help=_('Authentication strategy used by ironic-api. "noauth" should '
|
help=_('Authentication strategy used by ironic-api. "noauth" should '
|
||||||
'not be used in a production environment because all '
|
'not be used in a production environment because all '
|
||||||
'authentication will be disabled.')),
|
'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',
|
cfg.BoolOpt('debug_tracebacks_in_api',
|
||||||
default=False,
|
default=False,
|
||||||
help=_('Return server tracebacks in the API response for any '
|
help=_('Return server tracebacks in the API response for any '
|
||||||
|
@ -45,7 +45,7 @@ _IRONIC_MANAGES_BOOT = 'inspector_manage_boot'
|
|||||||
def _get_inspector_session(**kwargs):
|
def _get_inspector_session(**kwargs):
|
||||||
global _INSPECTOR_SESSION
|
global _INSPECTOR_SESSION
|
||||||
if not _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
|
# NOTE(dtantsur): using set_default instead of set_override because
|
||||||
# the native keystoneauth option must have priority.
|
# the native keystoneauth option must have priority.
|
||||||
CONF.set_default('auth_type', 'none', group='inspector')
|
CONF.set_default('auth_type', 'none', group='inspector')
|
||||||
|
@ -16,11 +16,15 @@ Tests to assert that various incorporated middleware works as expected.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from http import client as http_client
|
from http import client as http_client
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
import oslo_middleware.cors as cors_middleware
|
import oslo_middleware.cors as cors_middleware
|
||||||
|
|
||||||
from ironic.tests.unit.api import base
|
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):
|
class TestCORSMiddleware(base.BaseApiTest):
|
||||||
@ -112,3 +116,38 @@ class TestCORSMiddleware(base.BaseApiTest):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._response_string(http_client.OK), response.status)
|
self._response_string(http_client.OK), response.status)
|
||||||
self.assertNotIn('Access-Control-Allow-Origin', response.headers)
|
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'])
|
||||||
|
@ -35,7 +35,7 @@ greenlet==0.4.15
|
|||||||
hacking==3.0.0
|
hacking==3.0.0
|
||||||
ifaddr==0.1.6
|
ifaddr==0.1.6
|
||||||
importlib-metadata==1.6.0
|
importlib-metadata==1.6.0
|
||||||
ironic-lib==2.17.1
|
ironic-lib==4.3.0
|
||||||
iso8601==0.1.11
|
iso8601==0.1.11
|
||||||
Jinja2==2.10
|
Jinja2==2.10
|
||||||
jmespath==0.9.5
|
jmespath==0.9.5
|
||||||
|
32
releasenotes/notes/http-basic-auth-f8c0536eba989918.yaml
Normal file
32
releasenotes/notes/http-basic-auth-f8c0536eba989918.yaml
Normal file
@ -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
|
@ -11,7 +11,7 @@ python-cinderclient!=4.0.0,>=3.3.0 # Apache-2.0
|
|||||||
python-neutronclient>=6.7.0 # Apache-2.0
|
python-neutronclient>=6.7.0 # Apache-2.0
|
||||||
python-glanceclient>=2.8.0 # Apache-2.0
|
python-glanceclient>=2.8.0 # Apache-2.0
|
||||||
keystoneauth1>=3.18.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
|
python-swiftclient>=3.2.0 # Apache-2.0
|
||||||
pytz>=2013.6 # MIT
|
pytz>=2013.6 # MIT
|
||||||
stevedore>=1.20.0 # Apache-2.0
|
stevedore>=1.20.0 # Apache-2.0
|
||||||
|
Loading…
Reference in New Issue
Block a user