Merge pull request #5037 from yuvipanda/dummy-path

Add SharedPasswordAuthenticator
This commit is contained in:
Min RK
2025-04-07 12:25:10 +02:00
committed by GitHub
8 changed files with 378 additions and 15 deletions

View File

@@ -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:
```

View File

@@ -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

View File

@@ -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']

View File

View File

@@ -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

View File

@@ -2,6 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from jupyterhub.auth import DummyAuthenticator

View File

@@ -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

View File

@@ -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"]