From ed9f744b819a37fd892e542880d74844a3bd96fa Mon Sep 17 00:00:00 2001 From: yshepilov Date: Sat, 18 Feb 2023 16:45:45 +0100 Subject: [PATCH] #401 added possibility to use on-fly basic auth for API calls --- src/auth/auth_base.py | 3 ++ src/auth/auth_htpasswd.py | 16 +++++++++ src/auth/auth_ldap.py | 7 ++++ src/auth/tornado_auth.py | 17 ++++++++-- ...st_auth_basic.py => test_auth_htpasswd.py} | 16 +++++++++ src/tests/auth_ldap_test.py | 34 +++++++++++++++++++ src/tests/test_utils.py | 21 ++++++++++-- src/tests/web/server_test.py | 34 +++++++++++++++++-- src/web/web_auth_utils.py | 15 +++++++- 9 files changed, 154 insertions(+), 9 deletions(-) rename src/tests/auth/{test_auth_basic.py => test_auth_htpasswd.py} (93%) diff --git a/src/auth/auth_base.py b/src/auth/auth_base.py index ff4ca985..7f4b0dd8 100644 --- a/src/auth/auth_base.py +++ b/src/auth/auth_base.py @@ -20,6 +20,9 @@ def get_groups(self, user, known_groups=None): def validate_user(self, user, request_handler): return True + def perform_basic_auth(self, user, password): + return False + def logout(self, user, request_handler): return None diff --git a/src/auth/auth_htpasswd.py b/src/auth/auth_htpasswd.py index 82218302..ffaa26e5 100644 --- a/src/auth/auth_htpasswd.py +++ b/src/auth/auth_htpasswd.py @@ -45,6 +45,22 @@ def authenticate(self, request_handler): return username + def perform_basic_auth(self, user, password): + self._authenticate_internal(user, password) + return True + + def _authenticate_internal(self, username, password): + auth_error = auth_base.AuthRejectedError('Invalid credentials') + + if password is None: + LOGGER.warning('Password was not provided for user ' + username) + raise auth_error + + if not self.verifier.verify(username, password): + raise auth_error + + return username + class _HtpasswdVerifier: diff --git a/src/auth/auth_ldap.py b/src/auth/auth_ldap.py index a9af2f76..a0d42731 100644 --- a/src/auth/auth_ldap.py +++ b/src/auth/auth_ldap.py @@ -110,6 +110,13 @@ def authenticate(self, request_handler): username = request_handler.get_argument('username') password = request_handler.get_argument('password') + return self._authenticate_internal(username, password) + + def perform_basic_auth(self, user, password): + self._authenticate_internal(user, password) + return True + + def _authenticate_internal(self, username, password): LOGGER.info('Logging in user ' + username) if self.username_template: diff --git a/src/auth/tornado_auth.py b/src/auth/tornado_auth.py index 35f4847e..324d7999 100644 --- a/src/auth/tornado_auth.py +++ b/src/auth/tornado_auth.py @@ -1,4 +1,5 @@ import asyncio +import base64 import logging import tornado.concurrent @@ -33,9 +34,19 @@ def is_authenticated(self, request_handler): return active - @staticmethod - def _get_current_user(request_handler): - return tornado_utils.get_secure_cookie(request_handler, 'username') + def _get_current_user(self, request_handler): + cookie_username = tornado_utils.get_secure_cookie(request_handler, 'username') + if cookie_username: + return cookie_username + + authorization_header = request_handler.request.headers.get('Authorization') + if authorization_header and authorization_header.startswith('Basic '): + username_password = base64.b64decode(authorization_header[6:]).decode('utf-8') + (username, password) = username_password.split(':', 1) + if self.authenticator.perform_basic_auth(username, password): + return username + + return None def get_username(self, request_handler): if not self.is_enabled(): diff --git a/src/tests/auth/test_auth_basic.py b/src/tests/auth/test_auth_htpasswd.py similarity index 93% rename from src/tests/auth/test_auth_basic.py rename to src/tests/auth/test_auth_htpasswd.py index 44a30166..21cdda4c 100644 --- a/src/tests/auth/test_auth_basic.py +++ b/src/tests/auth/test_auth_htpasswd.py @@ -133,6 +133,22 @@ def test_authenticate_failure_when_plain_with_crypt_password(self): password = username_passwords[username] self._assert_rejected(username, password, authenticator) + def test_basic_auth_when_success(self): + authenticator = self._create_authenticator({'htpasswd_path': self.file_path}) + + auth_result = authenticator.perform_basic_auth('user_md5_1', '111') + self.assertEqual(True, auth_result) + + def test_basic_auth_when_failure(self): + authenticator = self._create_authenticator({'htpasswd_path': self.file_path}) + + self.assertRaisesRegex( + AuthRejectedError, + 'Invalid credentials', + authenticator.perform_basic_auth, + 'user_md5_1', + 'wrong') + def test_missing_htpasswd_path_config(self): self.assertRaisesRegex(Exception, 'is required attribute', HtpasswdAuthenticator, {}, None) diff --git a/src/tests/auth_ldap_test.py b/src/tests/auth_ldap_test.py index bb81275e..f49f779b 100644 --- a/src/tests/auth_ldap_test.py +++ b/src/tests/auth_ldap_test.py @@ -3,6 +3,7 @@ from ldap3 import Connection, SIMPLE, MOCK_SYNC, OFFLINE_AD_2012_R2, Server from ldap3.utils.dn import safe_dn +from auth.auth_base import AuthRejectedError from auth.auth_ldap import LdapAuthenticator from tests import test_utils from tests.test_utils import mock_request_handler @@ -59,6 +60,9 @@ def connect(username, password): def authenticate(self, username, password): return self.authenticator.authenticate(_mock_request_handler(username, password)) + def perform_basic_auth(self, username, password): + return self.authenticator.perform_basic_auth(username, password) + def get_groups(self, username): return self.authenticator.get_groups(username) @@ -308,5 +312,35 @@ def authenticate(self, username, password): self.assertEqual(user, username) +class TestAuthenticate(unittest.TestCase): + + def test_perform_basic_auth_success(self): + authenticated = self.auth_wrapper.perform_basic_auth('user1', '1234') + self.assertEqual(True, authenticated) + self.assertEqual(['group1'], self.auth_wrapper.get_groups('user1')) + + def test_perform_basic_auth_failure(self): + self.assertRaisesRegex( + AuthRejectedError, + 'Invalid credentials', + self.auth_wrapper.perform_basic_auth, + 'user1', + '555') + + def setUp(self): + test_utils.setup() + + self.auth_wrapper = self.create_wrapper() + + self.auth_wrapper.add_user('user1', '1234') + self.auth_wrapper.add_group('group1', ['user1']) + + def create_wrapper(self): + return _LdapAuthenticatorMockWrapper('cn=$username,cn=Users,dc=buggy,dc=net', 'dc=buggy,dc=net') + + def tearDown(self): + test_utils.cleanup() + + def _mock_request_handler(username, password): return mock_request_handler(arguments={'username': username, 'password': password}) diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 71e164a8..2860c977 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -10,7 +10,7 @@ import utils.file_utils as file_utils import utils.os_utils as os_utils -from auth.auth_base import Authenticator +from auth.auth_base import Authenticator, AuthRejectedError from execution.process_base import ProcessWrapper from model.script_config import ConfigModel, ParameterModel from model.server_conf import LoggingConfig @@ -593,5 +593,22 @@ async def __call__(self, *args, **kwargs): class MockAuthenticator(Authenticator): + + def __init__(self) -> None: + super().__init__() + self._users = {} + def authenticate(self, request_handler): - return request_handler.request.remote_ip + raise AuthRejectedError('Not implemented') + + def perform_basic_auth(self, user, password): + if user not in self._users: + raise AuthRejectedError('Invalid user ' + user) + + if self._users[user] != password: + raise AuthRejectedError('Invalid password for user ' + user) + + return True + + def add_user(self, username, password): + self._users[username] = password diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index a0ee604f..7d19a3e8 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -8,6 +8,7 @@ import requests from parameterized import parameterized +from requests.auth import HTTPBasicAuth from tornado.ioloop import IOLoop from tornado.web import create_signed_value @@ -230,16 +231,40 @@ def test_update_script_config(self): script_content = file_utils.read_file(script_path) self.assertEqual('abcdef', script_content) + def test_on_fly_auth(self): + self.start_server(12345, '127.0.0.1') + + def test_get_scripts_when_basic_auth(self): + self.start_server(12345, '127.0.0.1') + + test_utils.write_script_config({'name': 's1'}, 's1', self.runners_folder) + + response = self.request('GET', + 'http://127.0.0.1:12345/scripts', + session=requests.Session(), + auth=HTTPBasicAuth('normal_user', 'qwerty')) + self.assertCountEqual([ + {'name': 's1', 'group': None, 'parsing_failed': False}], + response['scripts']) + + def test_get_scripts_when_basic_auth_failure(self): + self.start_server(12345, '127.0.0.1') + + test_utils.write_script_config({'name': 's1'}, 's1', self.runners_folder) + + response = requests.get('http://127.0.0.1:12345/scripts', auth=HTTPBasicAuth('normal_user', 'wrong_pass')) + self.assertEquals(401, response.status_code) + @staticmethod def get_xsrf_token(session): response = session.get('http://127.0.0.1:12345/admin/scripts') return response.cookies.get('_xsrf') - def request(self, method, url, session=None): + def request(self, method, url, session=None, auth=None): if session is None: session = self._user_session - response = session.request(method, url) + response = session.request(method, url, auth=auth) self.assertEqual(200, response.status_code, 'Failed to execute request: ' + response.text) return response.json() @@ -261,8 +286,11 @@ def start_server(self, port, address, *, xsrf_protection=XSRF_PROTECTION_TOKEN): cookie_secret = b'cookie_secret' + authenticator = MockAuthenticator() + authenticator.add_user('normal_user', 'qwerty') + server.init(config, - MockAuthenticator(), + authenticator, authorizer, execution_service, MagicMock(), diff --git a/src/web/web_auth_utils.py b/src/web/web_auth_utils.py index 4e1efdd4..756b5802 100644 --- a/src/web/web_auth_utils.py +++ b/src/web/web_auth_utils.py @@ -7,6 +7,7 @@ import tornado.web import tornado.websocket +from auth.auth_base import AuthRejectedError, AuthFailureError from utils.tornado_utils import redirect_relative from web.web_utils import identify_user @@ -29,7 +30,18 @@ def wrapper(self, *args, **kwargs): if login_resource: return func(self, *args, **kwargs) - authenticated = auth.is_authenticated(self) + try: + authenticated = auth.is_authenticated(self) + except (AuthRejectedError, AuthFailureError) as e: + message = 'On-fly auth rejected' + LOGGER.warning(message + ': ' + str(e)) + code = 401 + if isinstance(self, tornado.websocket.WebSocketHandler): + self.close(code=code, reason=message) + return + else: + raise tornado.web.HTTPError(code, message) + access_allowed = authenticated and authorizer.is_allowed_in_app(identify_user(self)) if authenticated and (not access_allowed): @@ -39,6 +51,7 @@ def wrapper(self, *args, **kwargs): message = 'Access denied. Please contact system administrator' if isinstance(self, tornado.websocket.WebSocketHandler): self.close(code=code, reason=message) + return else: if isinstance(self, tornado.web.StaticFileHandler) and request_path.lower().endswith('.html'): login_url += "?" + urlencode(dict(next=request_path, redirectReason='prohibited'))