diff --git a/remoteappmanager/jupyterhub/auth/__init__.py b/remoteappmanager/jupyterhub/auth/__init__.py new file mode 100644 index 000000000..8df2b6918 --- /dev/null +++ b/remoteappmanager/jupyterhub/auth/__init__.py @@ -0,0 +1,2 @@ +from .world_authenticator import WorldAuthenticator # noqa +from .github_whitelist_authenticator import GitHubWhitelistAuthenticator # noqa diff --git a/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py b/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py new file mode 100644 index 000000000..0219410b0 --- /dev/null +++ b/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py @@ -0,0 +1,53 @@ +import os +from oauthenticator import GitHubOAuthenticator +from traitlets.config import LoggingConfigurable +from traitlets import Unicode, Float, Set + + +class FileWhitelistMixin(LoggingConfigurable): + """ + """ + + #: The path of the whitelist file. + whitelist_file = Unicode() + + #: When the file was last modified, so that we can reload appropriately. + _whitelist_file_last_modified = Float() + + #: Cached whitelist to return every time the file hasn't changed. + _whitelist = Set() + + @property + def whitelist(self): + try: + cur_mtime = os.path.getmtime(self.whitelist_file) + if cur_mtime <= self._whitelist_file_last_modified: + # File older than last change. + # keep using the current cached whitelist + return self._whitelist + + self.log.info("Whitelist file more recent than the old one. " + "Updating whitelist.") + + with open(self.whitelist_file, "r") as f: + whitelisted_users = set(x.strip() for x in f.readlines()) + except FileNotFoundError: + # empty set means everybody is allowed + return {} + except Exception: + # For other exceptions, assume the file is broken, log it + # and return what we have. + self.log.exception("Unable to access whitelist.") + return self._whitelist + + self._whitelist = whitelisted_users + self._whitelist_file_last_modified = cur_mtime + + return self._whitelist + + +class GitHubWhitelistAuthenticator(FileWhitelistMixin, GitHubOAuthenticator): + """A github authenticator that also verifies that the + user belongs to a specified whitelisted user as provided + by an external file (so that we don't have to restart + the service to change the whitelisted users""" diff --git a/remoteappmanager/jupyterhub/auth/tests/__init__.py b/remoteappmanager/jupyterhub/auth/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/remoteappmanager/jupyterhub/auth/tests/test_github_whitelist_authenticator.py b/remoteappmanager/jupyterhub/auth/tests/test_github_whitelist_authenticator.py new file mode 100644 index 000000000..cebe870a6 --- /dev/null +++ b/remoteappmanager/jupyterhub/auth/tests/test_github_whitelist_authenticator.py @@ -0,0 +1,89 @@ +import os + +import time +from tornado.testing import AsyncTestCase, gen_test + +from remoteappmanager.tests.temp_mixin import TempMixin +from remoteappmanager.tests.utils import mock_coro_factory + +from unittest.mock import Mock, patch + +from remoteappmanager.jupyterhub.auth import GitHubWhitelistAuthenticator + + +class TestGithubWhiteListAuthenticator(TempMixin, AsyncTestCase): + def setUp(self): + self.auth = GitHubWhitelistAuthenticator() + self.auth.authenticate = mock_coro_factory(return_value="foo") + super().setUp() + + @gen_test + def test_basic_auth(self): + auth = self.auth + + response = yield auth.authenticate(Mock(), {"username": "foo"}) + self.assertEqual(response, "foo") + + @gen_test + def test_basic_auth_with_whitelist_file(self): + whitelist_path = os.path.join(self.tempdir, "whitelist.txt") + with open(whitelist_path, "w") as f: + f.write("foo\n") + f.write("bar\n") + + auth = self.auth + auth.whitelist_file = whitelist_path + + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + self.assertEqual(response, "foo") + + # Check again to touch the code that does not trigger another load + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + self.assertEqual(response, "foo") + + # wait one second, so that we see a change in mtime. + time.sleep(1) + + # Change the file and see if we get a different behavior + with open(whitelist_path, "w") as f: + f.write("bar\n") + + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + self.assertEqual(response, None) + + @gen_test + def test_basic_auth_without_whitelist_file(self): + auth = self.auth + auth.whitelist_file = "/does/not/exist.txt" + + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + + # Should be equivalent to no whitelist, so everybody allowed + self.assertEqual(response, "foo") + + @gen_test + def test_exception_during_read(self): + whitelist_path = os.path.join(self.tempdir, "whitelist.txt") + with open(whitelist_path, "w") as f: + f.write("bar\n") + + auth = self.auth + auth.whitelist_file = whitelist_path + + # Do the first triggering, so that we load the file content. + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + + self.assertEqual(response, None) + + # Then try again with an exception occurring + with patch("os.path.getmtime") as p: + p.side_effect = Exception("BOOM!") + + response = yield auth.get_authenticated_user(Mock(), + {"username": "foo"}) + self.assertEqual(response, None) diff --git a/remoteappmanager/jupyterhub/auth/tests/test_world_authenticator.py b/remoteappmanager/jupyterhub/auth/tests/test_world_authenticator.py new file mode 100644 index 000000000..5f4d04ef5 --- /dev/null +++ b/remoteappmanager/jupyterhub/auth/tests/test_world_authenticator.py @@ -0,0 +1,12 @@ +from tornado.testing import AsyncTestCase, gen_test +from unittest.mock import Mock + +from remoteappmanager.jupyterhub.auth import WorldAuthenticator + + +class TestWorldAuthenticator(AsyncTestCase): + @gen_test + def test_basic_auth(self): + auth = WorldAuthenticator() + response = yield auth.authenticate(Mock(), {"username": "foo"}) + self.assertEqual(response, "foo") diff --git a/remoteappmanager/jupyterhub/auth.py b/remoteappmanager/jupyterhub/auth/world_authenticator.py similarity index 89% rename from remoteappmanager/jupyterhub/auth.py rename to remoteappmanager/jupyterhub/auth/world_authenticator.py index 3f218ee26..127f5b8dc 100644 --- a/remoteappmanager/jupyterhub/auth.py +++ b/remoteappmanager/jupyterhub/auth/world_authenticator.py @@ -7,7 +7,7 @@ class WorldAuthenticator(Authenticator): - ''' This authenticator authenticates everyone ''' + """ This authenticator authenticates everyone """ @gen.coroutine def authenticate(self, handler, data): diff --git a/requirements.txt b/requirements.txt index f2ca9f4aa..8562b70fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ jupyter_client==4.3.0 click==6.6 tabulate==0.7.5 git+git://github.com/simphony/tornado-webapi.git#egg=tornadowebapi +oauthenticator==0.5.1 diff --git a/setup.py b/setup.py index cd85e761f..f2bd77690 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "jupyter_client>=4.3.0", "click>=6.6", "tabulate>=0.7.5", + "oauthenticator>=0.5", ] # Unfortunately RTD cannot install jupyterhub because jupyterhub needs bower,