Skip to content

Commit

Permalink
#401 added possibility to use on-fly basic auth for API calls
Browse files Browse the repository at this point in the history
  • Loading branch information
bugy committed Feb 18, 2023
1 parent 413d96e commit ed9f744
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 9 deletions.
3 changes: 3 additions & 0 deletions src/auth/auth_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions src/auth/auth_htpasswd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions src/auth/auth_ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions src/auth/tornado_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import base64
import logging

import tornado.concurrent
Expand Down Expand Up @@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions src/tests/auth_ldap_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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})
21 changes: 19 additions & 2 deletions src/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
34 changes: 31 additions & 3 deletions src/tests/web/server_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand All @@ -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(),
Expand Down
15 changes: 14 additions & 1 deletion src/web/web_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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'))
Expand Down

0 comments on commit ed9f744

Please sign in to comment.