diff --git a/docs/source/reference/api/auth.md b/docs/source/reference/api/auth.md index 7f4661b4..094d5953 100644 --- a/docs/source/reference/api/auth.md +++ b/docs/source/reference/api/auth.md @@ -1,33 +1,42 @@ # Authenticators -## Module: {mod}`jupyterhub.auth` - ```{eval-rst} -.. automodule:: jupyterhub.auth +.. module:: jupyterhub.auth ``` -### {class}`Authenticator` +## {class}`Authenticator` ```{eval-rst} .. autoconfigurable:: Authenticator :members: ``` -### {class}`LocalAuthenticator` +## {class}`LocalAuthenticator` ```{eval-rst} .. autoconfigurable:: LocalAuthenticator :members: ``` -### {class}`PAMAuthenticator` +## {class}`PAMAuthenticator` ```{eval-rst} .. autoconfigurable:: PAMAuthenticator ``` -### {class}`DummyAuthenticator` +## {class}`DummyAuthenticator` ```{eval-rst} .. autoconfigurable:: DummyAuthenticator ``` + +```{eval-rst} +.. module:: jupyterhub.authenticators.shared +``` + +## {class}`SharedPasswordAuthenticator` + +```{eval-rst} +.. autoconfigurable:: SharedPasswordAuthenticator + :no-inherited-members: +``` diff --git a/docs/source/reference/authenticators.md b/docs/source/reference/authenticators.md index a0060a26..d16189e3 100644 --- a/docs/source/reference/authenticators.md +++ b/docs/source/reference/authenticators.md @@ -36,10 +36,15 @@ A [generic implementation](https://github.com/jupyterhub/oauthenticator/blob/mas ## The Dummy Authenticator -When testing, it may be helpful to use the -{class}`~.jupyterhub.auth.DummyAuthenticator`. This allows for any username and -password unless a global password has been set. Once set, any username will -still be accepted but the correct password will need to be provided. +When testing, it may be helpful to use the {class}`~.jupyterhub.auth.DummyAuthenticator`: + +```python +c.JupyterHub.authenticator_class = "dummy" +# always a good idea to limit to localhost when testing with an insecure config +c.JupyterHub.ip = "127.0.0.1" +``` + +This allows for any username and password to login, and is _wildly_ insecure. To use, specify @@ -52,6 +57,35 @@ The DummyAuthenticator's default `allow_all` is True, unlike most other Authenticators. ::: +:::{deprecated} 5.3 +Setting a password on DummyAuthenticator is deprecated. +Use the new {class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator` +if you want to set a shared password for users. +::: + +## Shared Password Authenticator + +:::{versionadded} 5.3 +{class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator` is added and [DummyAuthenticator.password](#DummyAuthenticator.password) is deprecated. +::: + +For short-term deployments like workshops where there is no real user data to protect and you trust users to not abuse the system or each other, +{class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator` can be used. + +Set a [user password](#SharedPasswordAuthenticator.user_password) for users to login: + +```python +c.JupyterHub.authenticator_class = "shared-password" +c.SharedPasswordAuthenticator.user_password = "my-workshop-2042" +``` + +You can also grant admin users access by adding them to `admin_users` and setting a separate [admin password](#SharedPasswordAuthenticator.admin_password): + +```python +c.Authenticator.admin_users = {"danger", "eggs"} +c.SharedPasswordAuthenticator.admin_password = "extra-super-secret-secure-password" +``` + ## Additional Authenticators Additional authenticators can be found on GitHub diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index d5ba38dc..fab1cf43 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -1504,12 +1504,19 @@ class DummyAuthenticator(Authenticator): password = Unicode( config=True, help=""" - Set a global password for all users wanting to log in. - - This allows users with any username to log in with the same static password. + .. deprecated:: 5.3 + + Setting a password in DummyAuthenticator is deprecated. + Use `SharedPasswordAuthenticator` instead. """, ) + @observe("password") + def _password_changed(self, change): + msg = "DummyAuthenticator.password is deprecated in JupyterHub 5.3. Use SharedPasswordAuthenticator.user_password instead." + warnings.warn(msg, DeprecationWarning) + self.log.warning(msg) + def check_allow_config(self): super().check_allow_config() self.log.warning( @@ -1520,7 +1527,7 @@ class DummyAuthenticator(Authenticator): """Checks against a global password if it's been set. If not, allow any user/pass combo""" if self.password: if data['password'] == self.password: - return data['username'] + return data["username"] return None return data['username'] diff --git a/jupyterhub/authenticators/__init__.py b/jupyterhub/authenticators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jupyterhub/authenticators/shared.py b/jupyterhub/authenticators/shared.py new file mode 100644 index 00000000..820fadfd --- /dev/null +++ b/jupyterhub/authenticators/shared.py @@ -0,0 +1,149 @@ +from secrets import compare_digest + +from traitlets import Unicode, validate + +from ..auth import Authenticator + + +class SharedPasswordAuthenticator(Authenticator): + """ + Authenticator with static shared passwords. + + For use in short-term deployments with negligible security concerns. + + Enable with:: + + c.JupyterHub.authenticator_class = "shared-password" + + .. warning:: + This is an insecure Authenticator only appropriate for short-term + deployments with no requirement to protect users from each other. + + - The password is stored in plain text at rest in config + - Anyone with the password can login as **any user** + - All users are able to login as all other (non-admin) users with the same password + """ + + _USER_PASSWORD_MIN_LENGTH = 8 + _ADMIN_PASSWORD_MIN_LENGTH = 32 + + user_password = Unicode( + None, + allow_none=True, + config=True, + help=f""" + Set a global password for all *non admin* users wanting to log in. + + Must be {_USER_PASSWORD_MIN_LENGTH} characters or longer. + + If not set, regular users cannot login. + + If `allow_all` is True, anybody can register unlimited new users with any username by logging in with this password. + Users may be allowed by name by specifying `allowed_users`. + + Any user will also be able to login as **any other non-admin user** with this password. + + If `admin_users` is set, those users *must* use `admin_password` to log in. + """, + ) + + admin_password = Unicode( + None, + allow_none=True, + config=True, + help=f""" + Set a global password that grants *admin* privileges to users logging in with this password. + Only usernames declared in `admin_users` may login with this password. + + Must meet the following requirements: + + - Be {_ADMIN_PASSWORD_MIN_LENGTH} characters or longer + - Not be the same as `user_password` + + If not set, admin users cannot login. + """, + ) + + @validate("admin_password") + def _validate_admin_password(self, proposal): + new = proposal.value + trait_name = f"{self.__class__.__name__}.{proposal.trait.name}" + + if not new: + # no admin password; do nothing + return None + if len(new) < self._ADMIN_PASSWORD_MIN_LENGTH: + raise ValueError( + f"{trait_name} must be at least {self._ADMIN_PASSWORD_MIN_LENGTH} characters, not {len(new)}." + ) + if self.user_password == new: + # Checked here and in validating password, to ensure we don't miss issues due to ordering + raise ValueError( + f"{self.__class__.__name__}.user_password and {trait_name} cannot be the same" + ) + return new + + @validate("user_password") + def _validate_password(self, proposal): + new = proposal.value + trait_name = f"{self.__class__.__name__}.{proposal.trait.name}" + + if not new: + # no user password; do nothing + return None + if len(new) < self._USER_PASSWORD_MIN_LENGTH: + raise ValueError( + f"{trait_name} must be at least {self._USER_PASSWORD_MIN_LENGTH} characters long, got {len(new)} characters" + ) + if self.admin_password == new: + # Checked here and in validating password, to ensure we don't miss issues due to ordering + raise ValueError( + f"{trait_name} and {self.__class__.__name__}.admin_password cannot be the same" + ) + return new + + def check_allow_config(self): + """Validate and warn about any suspicious allow config""" + super().check_allow_config() + clsname = self.__class__.__name__ + if self.admin_password and not self.admin_users: + self.log.warning( + f"{clsname}.admin_password set, but {clsname}.admin_users is not." + " No admin users will be able to login." + f" Add usernames to {clsname}.admin_users to grant users admin permissions." + ) + if self.admin_users and not self.admin_password: + self.log.warning( + f"{clsname}.admin_users set, but {clsname}.admin_password is not." + " No admin users will be able to login." + f" Set {clsname}.admin_password to allow admins to login." + ) + if not self.user_password: + if not self.admin_password: + # log as an error, but don't raise, because disabling all login is valid + self.log.error( + f"Neither {clsname}.admin_password nor {clsname}.user_password is set." + " Nobody will be able to login!" + ) + else: + self.log.warning( + f"{clsname}.user_password not set." + " No non-admin users will be able to login." + ) + + async def authenticate(self, handler, data): + """Checks against shared password""" + if data["username"] in self.admin_users: + # Admin user + if self.admin_password and compare_digest( + data["password"], self.admin_password + ): + return {"name": data["username"], "admin": True} + else: + if self.user_password and compare_digest( + data["password"], self.user_password + ): + # Anyone logging in with the standard password is *never* admin + return {"name": data["username"], "admin": False} + + return None diff --git a/jupyterhub/tests/test_dummyauth.py b/jupyterhub/tests/test_dummyauth.py index 12886c7b..a88aab56 100644 --- a/jupyterhub/tests/test_dummyauth.py +++ b/jupyterhub/tests/test_dummyauth.py @@ -2,6 +2,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. + from jupyterhub.auth import DummyAuthenticator diff --git a/jupyterhub/tests/test_shared_password.py b/jupyterhub/tests/test_shared_password.py new file mode 100644 index 00000000..38e06b35 --- /dev/null +++ b/jupyterhub/tests/test_shared_password.py @@ -0,0 +1,162 @@ +import pytest +from traitlets.config import Config + +from jupyterhub.authenticators.shared import SharedPasswordAuthenticator + + +@pytest.fixture +def admin_password(): + return "a" * 32 + + +@pytest.fixture +def user_password(): + return "user_password" + + +@pytest.fixture +def authenticator(admin_password, user_password): + return SharedPasswordAuthenticator( + admin_password=admin_password, + user_password=user_password, + admin_users={"admin"}, + allow_all=True, + ) + + +async def test_password_validation(): + authenticator = SharedPasswordAuthenticator() + # Validate length + with pytest.raises( + ValueError, + match="admin_password must be at least 32 characters", + ): + authenticator.admin_password = "a" * 31 + + with pytest.raises( + ValueError, + match="user_password must be at least 8 characters", + ): + authenticator.user_password = "a" * 7 + + # Validate that the passwords aren't the same + authenticator.user_password = "a" * 32 + with pytest.raises( + ValueError, + match="SharedPasswordAuthenticator.user_password and SharedPasswordAuthenticator.admin_password cannot be the same", + ): + authenticator.admin_password = "a" * 32 + + # ok + authenticator.admin_password = "a" * 33 + + # check collision in the other order + with pytest.raises( + ValueError, + match="SharedPasswordAuthenticator.user_password and SharedPasswordAuthenticator.admin_password cannot be the same", + ): + authenticator.user_password = "a" * 33 + + +async def test_admin_password(authenticator, user_password, admin_password): + # Regular user, regular password + authorized = await authenticator.get_authenticated_user( + None, {'username': 'test_user', 'password': user_password} + ) + assert authorized['name'] == 'test_user' + assert not authorized['admin'] + + # Regular user, admin password + authorized = await authenticator.get_authenticated_user( + None, {'username': 'test_user', 'password': admin_password} + ) + assert not authorized + + # Admin user, admin password + authorized = await authenticator.get_authenticated_user( + None, {'username': 'admin', 'password': admin_password} + ) + assert authorized['name'] == 'admin' + assert authorized['admin'] + + # Admin user, regular password + authorized = await authenticator.get_authenticated_user( + None, {'username': 'admin', 'password': user_password} + ) + assert not authorized + + # Regular user, wrong password + authorized = await authenticator.get_authenticated_user( + None, {'username': 'test_user', 'password': 'blah'} + ) + assert not authorized + + # New username, allow_all is False + authenticator.allow_all = False + authorized = await authenticator.get_authenticated_user( + None, {'username': 'new_user', 'password': 'user_password'} + ) + assert not authorized + + +async def test_empty_passwords(): + authenticator = SharedPasswordAuthenticator( + allow_all=True, + admin_users={"admin"}, + user_password="", + admin_password="", + ) + authorized = await authenticator.get_authenticated_user( + None, {'username': 'admin', 'password': ''} + ) + assert not authorized + authorized = await authenticator.get_authenticated_user( + None, {'username': 'user', 'password': ''} + ) + assert not authorized + + +@pytest.mark.parametrize( + "auth_config, warns, not_warns", + [ + pytest.param({}, "nobody can login", "", id="default"), + pytest.param( + {"allow_all": True}, + "Nobody will be able to login", + "regular users", + id="no passwords", + ), + pytest.param( + {"admin_password": "a" * 32}, "admin_users is not", "", id="no admin_users" + ), + pytest.param( + {"admin_users": {"admin"}}, + "admin_password is not", + "", + id="no admin_password", + ), + pytest.param( + {"admin_users": {"admin"}, "admin_password": "a" * 32, "allow_all": True}, + "No non-admin users will be able to login", + "", + id="only_admin", + ), + ], +) +def test_check_allow_config(caplog, auth_config, warns, not_warns): + # check log warnings + config = Config() + for key, value in auth_config.items(): + setattr(config.SharedPasswordAuthenticator, key, value) + authenticator = SharedPasswordAuthenticator(config=config) + authenticator.check_allow_config() + if warns: + if isinstance(warns, str): + warns = [warns] + for snippet in warns: + assert snippet in caplog.text + if not_warns: + if isinstance(not_warns, str): + not_warns = [not_warns] + for snippet in not_warns: + assert snippet not in caplog.text diff --git a/pyproject.toml b/pyproject.toml index 64091de6..e4aa5e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ jupyterhub-singleuser = "jupyterhub.singleuser:main" default = "jupyterhub.auth:PAMAuthenticator" pam = "jupyterhub.auth:PAMAuthenticator" dummy = "jupyterhub.auth:DummyAuthenticator" +shared-password = "jupyterhub.authenticators.shared:SharedPasswordAuthenticator" null = "jupyterhub.auth:NullAuthenticator" [project.entry-points."jupyterhub.proxies"]