From da7987ca926e9bd82ff2989a920ea9740da24f95 Mon Sep 17 00:00:00 2001 From: ramishra Date: Mon, 26 Jul 2021 11:28:18 +0530 Subject: [PATCH] Add new basic auth middleware This adds a middleware that can be used as an alternative to noauth in standalone environments. This middleware uses a password file which supports the Apache htpasswd syntax. 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. Adapted from I874783b8ece0eedf27a94dfed5163d0c82f8b9de. Change-Id: I3d80d86afd063af9fa2f411885dbd0dc65a7bbc7 --- oslo_middleware/basic_auth.py | 203 ++++++++++++++++++ oslo_middleware/tests/test_auth_basic.py | 174 +++++++++++++++ ...asic-auth-middleware-5f812399e325425f.yaml | 12 ++ requirements.txt | 1 + 4 files changed, 390 insertions(+) create mode 100644 oslo_middleware/basic_auth.py create mode 100644 oslo_middleware/tests/test_auth_basic.py create mode 100644 releasenotes/notes/basic-auth-middleware-5f812399e325425f.yaml diff --git a/oslo_middleware/basic_auth.py b/oslo_middleware/basic_auth.py new file mode 100644 index 0000000..2c9c901 --- /dev/null +++ b/oslo_middleware/basic_auth.py @@ -0,0 +1,203 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +import binascii +import logging + +import bcrypt +import webob + +from oslo_config import cfg +from oslo_middleware import base + +LOG = logging.getLogger(__name__) + +OPTS = [ + cfg.StrOpt('http_basic_auth_user_file', + default='/etc/htpasswd', + help="HTTP basic auth password file.") +] + +cfg.CONF.register_opts(OPTS, group='oslo_middleware') + + +class ConfigInvalid(Exception): + def __init__(self, error_msg): + super().__init__( + 'Invalid configuration file. %(error_msg)s') + + +class BasicAuthMiddleware(base.ConfigurableMiddleware): + """Middleware which performs HTTP basic authentication on requests""" + + def __init__(self, application, conf=None): + super().__init__(application, conf) + self.auth_file = cfg.CONF.oslo_middleware.http_basic_auth_user_file + validate_auth_file(self.auth_file) + + def format_exception(self, e): + result = {'error': {'message': str(e), 'code': 401}} + headers = [('Content-Type', 'application/json')] + return webob.Response(content_type='application/json', + status_code=401, + json_body=result, + headerlist=headers) + + @webob.dec.wsgify + def __call__(self, req): + try: + token = parse_header(req.environ) + username, password = parse_token(token) + req.environ.update(authenticate( + self.auth_file, username, password)) + return self.application + except Exception as e: + response = self.format_exception(e) + return self.process_response(response) + + +def authenticate(auth_file, username, password): + """Finds username and password match in Apache style user auth file + + The user auth file format is expected to comply with Apache + documentation[1] however the bcrypt password digest is the *only* + digest format supported. + + [1] https://httpd.apache.org/docs/current/misc/password_encryptions.html + + :param: auth_file: Path to user auth file + :param: username: Username to authenticate + :param: password: Password encoded as bytes + :returns: A dictionary of WSGI environment values to append to the request + :raises: HTTPUnauthorized, if no file entries match username/password + """ + + line_prefix = username + ':' + try: + with open(auth_file, 'r') as f: + for line in f: + entry = line.strip() + if entry and entry.startswith(line_prefix): + return auth_entry(entry, password) + except OSError as exc: + LOG.error('Problem reading auth file: %s', exc) + raise webob.exc.HTTPBadRequest( + detail='Problem reading auth file') + # reached end of file with no matches + LOG.info('User %s not found', username) + raise webob.exc.HTTPUnauthorized() + + +def auth_entry(entry, password): + """Compare a password with a single user auth file entry + + :param: entry: Line from auth user file to use for authentication + :param: password: Password encoded as bytes + :returns: A dictionary of WSGI environment values to append to the request + :raises: HTTPUnauthorized, if the entry doesn't match supplied password or + if the entry is crypted with a method other than bcrypt + """ + + username, crypted = parse_entry(entry) + if not bcrypt.checkpw(password, crypted): + LOG.info('Password for %s does not match', username) + raise webob.exc.HTTPUnauthorized() + return { + 'HTTP_X_USER': username, + 'HTTP_X_USER_NAME': username + } + + +def validate_auth_file(auth_file): + """Read the auth user file and validate its correctness + + :param: auth_file: Path to user auth file + :raises: ConfigInvalid on validation error + """ + + try: + with open(auth_file, 'r') as f: + for line in f: + entry = line.strip() + if entry and ':' in entry: + parse_entry(entry) + except OSError: + raise ConfigInvalid(error_msg='Problem reading auth user file') + + +def parse_entry(entry): + """Extrace the username and crypted password from a user auth file entry + + :param: entry: Line from auth user file to use for authentication + :returns: a tuple of username and crypted password + :raises: ConfigInvalid if the password is not in the supported bcrypt + format + """ + + username, crypted_str = entry.split(':', maxsplit=1) + crypted = crypted_str.encode('utf-8') + if crypted[:4] not in (b'$2y$', b'$2a$', b'$2b$'): + error_msg = ('Only bcrypt digested passwords are supported for ' + '%(username)s') % {'username': username} + raise webob.exc.HTTPBadRequest(detail=error_msg) + return username, crypted + + +def parse_token(token): + """Parse the token portion of the Authentication header value + + :param: token: Token value from basic authorization header + :returns: tuple of username, password + :raises: BadRequest, if username and password could not be parsed for any + reason + """ + + try: + if isinstance(token, str): + token = token.encode('utf-8') + auth_pair = base64.b64decode(token, validate=True) + (username, password) = auth_pair.split(b':', maxsplit=1) + return (username.decode('utf-8'), password) + except (TypeError, binascii.Error, ValueError) as exc: + LOG.info('Could not decode authorization token: %s', exc) + raise webob.exc.HTTPBadRequest(detail=( + 'Could not decode authorization token')) + + +def parse_header(env): + """Parse WSGI environment for Authorization header of type Basic + + :param: env: WSGI environment to get header from + :returns: Token portion of the header value + :raises: HTTPUnauthorized, if header is missing or if the type is not Basic + """ + + try: + auth_header = env.pop('HTTP_AUTHORIZATION') + except KeyError: + LOG.info('No authorization token received') + raise webob.exc.HTTPUnauthorized() + try: + auth_type, token = auth_header.strip().split(maxsplit=1) + except (ValueError, AttributeError) as exc: + LOG.info('Could not parse Authorization header: %s', exc) + raise webob.exc.HTTPBadRequest(detail=( + 'Could not parse Authorization header')) + if auth_type.lower() != 'basic': + error_msg = ('Unsupported authorization type "%s"') % auth_type + LOG.info(error_msg) + raise webob.exc.HTTPBadRequest(detail=error_msg) + return token diff --git a/oslo_middleware/tests/test_auth_basic.py b/oslo_middleware/tests/test_auth_basic.py new file mode 100644 index 0000000..116b490 --- /dev/null +++ b/oslo_middleware/tests/test_auth_basic.py @@ -0,0 +1,174 @@ +# Copyright 2012 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import base64 +import os +import tempfile + +from oslo_config import cfg +import webob + +from oslo_middleware import basic_auth as auth +from oslotest import base as test_base + + +class TestAuthBasic(test_base.BaseTestCase): + def setUp(self): + super().setUp() + + @webob.dec.wsgify + def fake_app(req): + return webob.Response() + self.fake_app = fake_app + self.request = webob.Request.blank('/') + + def write_auth_file(self, data=None): + if not data: + data = '\n' + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write(data) + self.addCleanup(os.remove, f.name) + return f.name + + def test_middleware_authenticate(self): + auth_file = self.write_auth_file( + 'myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + cfg.CONF.set_override('http_basic_auth_user_file', + auth_file, group='oslo_middleware') + self.middleware = auth.BasicAuthMiddleware(self.fake_app) + self.request.environ[ + 'HTTP_AUTHORIZATION'] = 'Basic bXlOYW1lOm15UGFzc3dvcmQ=' + response = self.request.get_response(self.middleware) + self.assertEqual('200 OK', response.status) + + def test_middleware_unauthenticated(self): + auth_file = self.write_auth_file( + 'myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + cfg.CONF.set_override('http_basic_auth_user_file', + auth_file, group='oslo_middleware') + + self.middleware = auth.BasicAuthMiddleware(self.fake_app) + response = self.request.get_response(self.middleware) + self.assertEqual('401 Unauthorized', response.status) + + def test_authenticate(self): + auth_file = self.write_auth_file( + 'foo:bar\nmyName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + # test basic auth + self.assertEqual( + {'HTTP_X_USER': 'myName', 'HTTP_X_USER_NAME': 'myName'}, + auth.authenticate( + auth_file, 'myName', b'myPassword') + ) + # test failed auth + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.authenticate, + auth_file, 'foo', b'bar') + self.assertEqual('Only bcrypt digested ' + 'passwords are supported for foo', str(e)) + # test problem reading user data file + auth_file = auth_file + '.missing' + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.authenticate, + auth_file, 'myName', + b'myPassword') + self.assertEqual( + 'Problem reading auth file', str(e)) + + def test_auth_entry(self): + entry_pass = ('myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm') + entry_fail = 'foo:bar' + # success + self.assertEqual( + {'HTTP_X_USER': 'myName', 'HTTP_X_USER_NAME': 'myName'}, + auth.auth_entry(entry_pass, b'myPassword') + ) + # failed, unknown digest format + ex = self.assertRaises(webob.exc.HTTPBadRequest, + auth.auth_entry, entry_fail, b'bar') + self.assertEqual('Only bcrypt digested ' + 'passwords are supported for foo', str(ex)) + # failed, incorrect password + self.assertRaises(webob.exc.HTTPUnauthorized, + auth.auth_entry, entry_pass, b'bar') + + def test_validate_auth_file(self): + auth_file = self.write_auth_file( + 'myName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + # success, valid config + auth.validate_auth_file(auth_file) + # failed, missing auth file + auth_file = auth_file + '.missing' + self.assertRaises(auth.ConfigInvalid, + auth.validate_auth_file, auth_file) + # failed, invalid entry + auth_file = self.write_auth_file( + 'foo:bar\nmyName:$2y$05$lE3eGtyj41jZwrzS87KTqe6.' + 'JETVCWBkc32C63UP2aYrGoYOEpbJm\n\n\n') + self.assertRaises(webob.exc.HTTPBadRequest, + auth.validate_auth_file, auth_file) + + def test_parse_token(self): + # success with bytes + token = base64.b64encode(b'myName:myPassword') + self.assertEqual( + ('myName', b'myPassword'), + auth.parse_token(token) + ) + # success with string + token = str(token, encoding='utf-8') + self.assertEqual( + ('myName', b'myPassword'), + auth.parse_token(token) + ) + # failed, invalid base64 + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.parse_token, token[:-1]) + self.assertEqual('Could not decode authorization token', str(e)) + # failed, no colon in token + token = str(base64.b64encode(b'myNamemyPassword'), encoding='utf-8') + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.parse_token, token[:-1]) + self.assertEqual('Could not decode authorization token', str(e)) + + def test_parse_header(self): + auth_value = 'Basic bXlOYW1lOm15UGFzc3dvcmQ=' + # success + self.assertEqual( + 'bXlOYW1lOm15UGFzc3dvcmQ=', + auth.parse_header({ + 'HTTP_AUTHORIZATION': auth_value + }) + ) + # failed, missing Authorization header + e = self.assertRaises(webob.exc.HTTPUnauthorized, + auth.parse_header, + {}) + # failed missing token + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.parse_header, + {'HTTP_AUTHORIZATION': 'Basic'}) + self.assertEqual('Could not parse Authorization header', str(e)) + # failed, type other than Basic + digest_value = 'Digest username="myName" nonce="foobar"' + e = self.assertRaises(webob.exc.HTTPBadRequest, + auth.parse_header, + {'HTTP_AUTHORIZATION': digest_value}) + self.assertEqual('Unsupported authorization type "Digest"', str(e)) diff --git a/releasenotes/notes/basic-auth-middleware-5f812399e325425f.yaml b/releasenotes/notes/basic-auth-middleware-5f812399e325425f.yaml new file mode 100644 index 0000000..4394e46 --- /dev/null +++ b/releasenotes/notes/basic-auth-middleware-5f812399e325425f.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Adds a basic http auth middleware as an alternative to noauth in + standalone environments. This middleware uses a password file which + supports the Apache `htpasswd`_ syntax. 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. + + .. _htpasswd: https://httpd.apache.org/docs/current/misc/password_encryptions.html diff --git a/requirements.txt b/requirements.txt index 147659e..baa1d01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ stevedore>=1.20.0 # Apache-2.0 WebOb>=1.8.0 # MIT debtcollector>=1.2.0 # Apache-2.0 statsd>=3.2.1 # MIT +bcrypt>=3.1.3 # Apache-2.0