diff --git a/.gitignore b/.gitignore index a0d6cccf6..1edf02c74 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,8 @@ venv.bak/ src/server.py +# dia temporary files +*.dia~ + +# environment variable files +setenvs* diff --git a/docs/source/conf.py b/docs/source/conf.py index e6af9b5ae..861c746a2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -28,7 +28,7 @@ author = 'CSC Developers' # The full version, including alpha/beta/rc tags -version = release = '0.3.1' +version = release = '0.3.2' # -- General configuration --------------------------------------------------- diff --git a/docs/source/instructions.rst b/docs/source/instructions.rst index 3c98670e0..bc64005a1 100644 --- a/docs/source/instructions.rst +++ b/docs/source/instructions.rst @@ -55,11 +55,10 @@ For the Pouta production environment for testing unsecurely without trust:: Setting up TLS termination proxy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The backend itself is not meant to be run as standalone in a production -environment. Therefore in a running config a TLS termination proxy should be -used to make the service secure. Setting up TLS termination is outside the -scope of this documentation, but a few useful links are provided along with -the necessary configs regarding this service. [#]_ [#]_ +The backend can be run in secure mode, i.e. with HTTPS enabled, but for +scaling up a TLS termination proxy is recommended. Setting up TLS termination +is outside the scope of this documentation, but a few useful links are +provided along with the necessary configs regarding this service. [#]_ [#]_ Scaling up the service ---------------------- diff --git a/docs/source/usage.rst b/docs/source/usage.rst index a36efb874..82a41bfcb 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -68,6 +68,9 @@ The following command line arguments are available for server startup. trusted_dashboards in the specified address. --set-origin-address TEXT Set the address that the program will be redirected to from WebSSO + --secure Enable secure running, i.e. enable HTTPS. + --ssl-cert-file TEXT Specify the certificate to use with SSL. + --ssl-cert-key TEXT Specify the certificate key to use with SSL. --help Show this message and exit. @@ -81,5 +84,11 @@ The following command line arguments are available for server startup. authentication endpoint, i.e. if the program has been listed on the respective Openstack keystone trusted_dashboard list. [#]_ +--secure Enable HTTPS on the server, to enable secure + requests if there's no TLS termination proxy. +--ssl-cert-file TEXT Specify SSL certificate file. Required when + running in secure mode. +--ssl-cert-key TEXT Specify SSL certificate key. Required when + running in secure mode. .. [#] https://docs.openstack.org/keystone/pike/advanced-topics/federation/websso.html diff --git a/performance_tests/locustfile.py b/performance_tests/locustfile.py new file mode 100644 index 000000000..30e648dd5 --- /dev/null +++ b/performance_tests/locustfile.py @@ -0,0 +1,89 @@ +"""Locust file for testing the backend API performace.""" + +from locust import HttpLocust, TaskSet + + +def login(l_instance): + """Handle locust user login.""" + l_instance.client.post( + "/login/websso", + {"token": "the_actual_token_doesn't_matter"} + ) + + +def logout(l_instance): + """Handle locust user logout.""" + l_instance.client.get( + "/login/kill" + ) + + +def api_containers(l_instance): + """Get container listing.""" + l_instance.client.get( + "/api/buckets" + ) + + +def api_objects(l_instance): + """Get object listing.""" + l_instance.client.get( + "/api/objects?bucket=test-container-0" + ) + + +def api_active(l_instance): + """Get active project.""" + l_instance.client.get( + "/api/active" + ) + + +def api_projects(l_instance): + """Get available projects.""" + l_instance.client.get( + "/api/projects" + ) + + +def api_username(l_instance): + """Get the username.""" + l_instance.client.get( + "/api/username" + ) + + +def api_project_meta(l_instance): + """Get the project metadata.""" + l_instance.client.get( + "/api/get-project-meta" + ) + + +class UserBehaviour(TaskSet): + """Locust task class for swift-browser-ui user case.""" + + tasks = { + api_containers: 1, + api_objects: 5, + api_active: 1, + api_projects: 1, + api_username: 1, + api_project_meta: 2, + } + + def on_start(self): + """Handle website login.""" + login(self) + + def on_stop(self): + """Handle website logout.""" + logout(self) + + +class APIUser(HttpLocust): + """Locust API user class.""" + + task_set = UserBehaviour + min_wait = 100 + max_wait = 1000 diff --git a/swift_browser_ui/__init__.py b/swift_browser_ui/__init__.py index 5690bce12..a1d3d29a4 100644 --- a/swift_browser_ui/__init__.py +++ b/swift_browser_ui/__init__.py @@ -7,6 +7,6 @@ __name__ = 'swift_browser_ui' -__version__ = '0.3.1' +__version__ = '0.3.2' __author__ = 'CSC Developers' __license__ = 'MIT License' diff --git a/swift_browser_ui/server.py b/swift_browser_ui/server.py index b0d2f7ca9..2ddb9a218 100644 --- a/swift_browser_ui/server.py +++ b/swift_browser_ui/server.py @@ -1,13 +1,13 @@ """swift_browser_ui server related convenience functions.""" # Generic imports -# import ssl import logging import time import sys import asyncio import hashlib import os +import ssl import uvloop import cryptography.fernet @@ -124,23 +124,41 @@ async def servinit(): return app -# def run_server_secure(app): -# """ -# Run the server securely with a given ssl context. - -# While this function is incomplete, the project is safe to run in -# production only via a TLS termination proxy with e.g. NGINX. -# """ - # Setup ssl context - # sslcontext = ssl.create_default_context() - # sslcontext.set_ciphers( - # 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE' + - # '-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-' + - # 'AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-' + - # 'SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-' + - # 'RSA-AES128-SHA256' - # ) - # aiohttp.web.run_app(app, ssl_context=sslcontext) +def run_server_secure(app, cert_file, cert_key): + """ + Run the server securely with a given ssl context. + + While this function is incomplete, the project is safe to run in + production only via a TLS termination proxy with e.g. NGINX. + """ + # The chiphers are from the Mozilla project wiki, as a recommendation for + # the most secure and up-to-date build. + # https://wiki.mozilla.org/Security/Server_Side_TLS + logger = logging.getLogger("swift-browser-ui") + logger.debug("Running server securely.") + logger.debug("Setting up SSL context for the server.") + sslcontext = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + cipher_str = ( + "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE" + + "-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA" + + "-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM" + + "-SHA256:DHE-RSA-AES256-GCM-SHA384" + ) + logger.debug( + "Setting following ciphers for SSL context: \n%s", + cipher_str + ) + sslcontext.set_ciphers(cipher_str) + sslcontext.options |= ssl.OP_NO_TLSv1 + sslcontext.options |= ssl.OP_NO_TLSv1_1 + logger.debug("Loading cert chain.") + sslcontext.load_cert_chain(cert_file, cert_key) + aiohttp.web.run_app( + app, + access_log=aiohttp.web.logging.getLogger('aiohttp.access'), + port=setd['port'], + ssl_context=sslcontext, + ) def run_server_insecure(app): diff --git a/swift_browser_ui/shell.py b/swift_browser_ui/shell.py index 5159c3288..c06fdecc3 100644 --- a/swift_browser_ui/shell.py +++ b/swift_browser_ui/shell.py @@ -10,7 +10,7 @@ from .__init__ import __version__ from .settings import setd, set_key, FORMAT -from .server import servinit, run_server_insecure +from .server import servinit, run_server_insecure, run_server_secure from ._convenience import setup_logging as conv_setup_logging @@ -92,6 +92,18 @@ def cli(verbose, debug, logfile): @click.option( '--set-session-devmode', is_flag=True, default=False, hidden=True, ) +@click.option( + '--secure', is_flag=True, default=False, + help="Enable secure running, i.e. enable HTTPS." +) +@click.option( + '--ssl-cert-file', default=None, type=str, + help="Specify the certificate to use with SSL." +) +@click.option( + '--ssl-cert-key', default=None, type=str, + help="Specify the certificate key to use with SSL." +) def start( port, auth_endpoint_url, @@ -99,6 +111,9 @@ def start( dry_run, set_origin_address, set_session_devmode, + secure, + ssl_cert_file, + ssl_cert_key, ): """Start the browser backend and server.""" logging.debug( @@ -129,8 +144,10 @@ def start( logging.debug( "Running settings directory:%s", str(setd) ) - if not dry_run: + if not dry_run and not secure: run_server_insecure(servinit()) + if not dry_run and secure: + run_server_secure(servinit(), ssl_cert_file, ssl_cert_key) def main(): diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py new file mode 100644 index 000000000..84bad576b --- /dev/null +++ b/tests/test_middlewares.py @@ -0,0 +1,96 @@ +"""Module for testing ``swift_browser_ui.middlewares``.""" + + +from aiohttp.web import HTTPUnauthorized, HTTPForbidden, HTTPNotFound +from aiohttp.web import Response, HTTPClientError, FileResponse +import asynctest + +from swift_browser_ui.middlewares import error_middleware +from swift_browser_ui.front import index + + +async def return_401_handler(with_exception): + """Return an HTTP401 error.""" + if with_exception: + raise HTTPUnauthorized() + return Response( + status=401 + ) + + +async def return_403_handler(with_exception): + """Return an HTTP403 error.""" + if with_exception: + raise HTTPForbidden() + return Response( + status=403 + ) + + +async def return_404_handler(with_exception): + """Return or raise an HTTP404 error.""" + if with_exception: + raise HTTPNotFound() + return Response( + status=404 + ) + + +async def return_400_handler(with_exception): + """Return or raise an HTTP400 error.""" + if with_exception: + raise HTTPClientError() + return Response( + status=400 + ) + + +class MiddlewareTestClass(asynctest.TestCase): + """Testing the error middleware.""" + + async def test_401_return(self): + """Test 401 middleware when the 401 status is returned.""" + resp = await error_middleware(None, return_401_handler) + self.assertEqual(resp.status, 401) + self.assertIsInstance(resp, FileResponse) + + async def test_401_exception(self): + """Test 401 middleware when the 401 status is risen.""" + resp = await error_middleware(True, return_401_handler) + self.assertEqual(resp.status, 401) + self.assertIsInstance(resp, FileResponse) + + async def test_403_return(self): + """Test 403 middleware when the 403 status is returned.""" + resp = await error_middleware(None, return_403_handler) + self.assertEqual(resp.status, 403) + self.assertIsInstance(resp, FileResponse) + + async def test_403_exception(self): + """Test 403 middleware when the 403 status is risen.""" + resp = await error_middleware(True, return_403_handler) + self.assertEqual(resp.status, 403) + self.assertIsInstance(resp, FileResponse) + + async def test_404_return(self): + """Test 404 middleware when the 404 status is returned.""" + resp = await error_middleware(None, return_404_handler) + self.assertEqual(resp.status, 404) + self.assertIsInstance(resp, FileResponse) + + async def test_404_exception(self): + """Test 404 middlewrae when the 404 status is risen.""" + resp = await error_middleware(True, return_404_handler) + self.assertEqual(resp.status, 404) + self.assertIsInstance(resp, FileResponse) + + async def test_error_middleware_no_error(self): + """Test the general error middleware with correct status.""" + resp = await error_middleware(None, index) + self.assertEqual(resp.status, 200) + self.assertIsInstance(resp, FileResponse) + + async def test_error_middleware_non_handled_raise(self): + """Test the general error middleware with other status code.""" + with self.assertRaises(HTTPClientError): + await error_middleware(True, return_400_handler) diff --git a/tests/test_server.py b/tests/test_server.py index 1de067754..c1e153f3f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,13 +6,19 @@ import os +import unittest +import ssl from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop +import aiohttp import asynctest -from swift_browser_ui.server import servinit +from swift_browser_ui.server import servinit, run_server_insecure +from swift_browser_ui.server import kill_sess_on_shutdown, run_server_secure from swift_browser_ui.settings import setd +from .creation import get_request_with_mock_openstack + # Set static folder in settings so it can be tested setd['static_directory'] = os.getcwd() + '/swift_browser_ui_frontend' @@ -29,6 +35,50 @@ async def test_servinit(self): self.assertTrue(app is not None) +class TestServerShutdownHandler(asynctest.TestCase): + """Test case for the server graceful shutdown handler.""" + + async def test_kill_sess_on_shutdown(self): + """Test kill_sess_on_shutdown function.""" + session, req = get_request_with_mock_openstack() + + await kill_sess_on_shutdown(req.app) + + self.assertNotIn(session, req.app["Creds"].keys()) + + +class TestRunServerFunctions(unittest.TestCase): + """Test class for server run functions.""" + + @staticmethod + def mock_ssl_context_creation(purpose=None): + """Return a MagicMock instance of an ssl context.""" + return unittest.mock.MagicMock(ssl.create_default_context)() + + def test_run_server_secure(self): + """Test run_server_secure function.""" + run_app_mock = unittest.mock.MagicMock(aiohttp.web.run_app) + patch_run_app = unittest.mock.patch( + "swift_browser_ui.server.aiohttp.web.run_app", run_app_mock + ) + patch_ssl_defcontext = unittest.mock.patch( + "swift_browser_ui.server.ssl.create_default_context", + self.mock_ssl_context_creation + ) + with patch_run_app, patch_ssl_defcontext: + run_server_secure(None, None, None) + run_app_mock.assert_called_once() + + def test_run_server_insecure(self): + """Test run_server_insecure function.""" + run_app_mock = unittest.mock.MagicMock(aiohttp.web.run_app) + with unittest.mock.patch( + "swift_browser_ui.server.aiohttp.web.run_app", run_app_mock + ): + run_server_insecure(None) + run_app_mock.assert_called_once() + + # After testing the server initialization, we can use the correctly starting # server for testing other modules. class AppTestCase(AioHTTPTestCase):