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 # Authenticators
## Module: {mod}`jupyterhub.auth`
```{eval-rst} ```{eval-rst}
.. automodule:: jupyterhub.auth .. module:: jupyterhub.auth
``` ```
### {class}`Authenticator` ## {class}`Authenticator`
```{eval-rst} ```{eval-rst}
.. autoconfigurable:: Authenticator .. autoconfigurable:: Authenticator
:members: :members:
``` ```
### {class}`LocalAuthenticator` ## {class}`LocalAuthenticator`
```{eval-rst} ```{eval-rst}
.. autoconfigurable:: LocalAuthenticator .. autoconfigurable:: LocalAuthenticator
:members: :members:
``` ```
### {class}`PAMAuthenticator` ## {class}`PAMAuthenticator`
```{eval-rst} ```{eval-rst}
.. autoconfigurable:: PAMAuthenticator .. autoconfigurable:: PAMAuthenticator
``` ```
### {class}`DummyAuthenticator` ## {class}`DummyAuthenticator`
```{eval-rst} ```{eval-rst}
.. autoconfigurable:: DummyAuthenticator .. 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 ## The Dummy Authenticator
When testing, it may be helpful to use the When testing, it may be helpful to use the {class}`~.jupyterhub.auth.DummyAuthenticator`:
{class}`~.jupyterhub.auth.DummyAuthenticator`. This allows for any username and
password unless a global password has been set. Once set, any username will ```python
still be accepted but the correct password will need to be provided. 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 To use, specify
@@ -52,6 +57,35 @@ The DummyAuthenticator's default `allow_all` is True,
unlike most other Authenticators. 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
Additional authenticators can be found on GitHub Additional authenticators can be found on GitHub

View File

@@ -1504,12 +1504,19 @@ class DummyAuthenticator(Authenticator):
password = Unicode( password = Unicode(
config=True, config=True,
help=""" help="""
Set a global password for all users wanting to log in. .. deprecated:: 5.3
This allows users with any username to log in with the same static password. 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): def check_allow_config(self):
super().check_allow_config() super().check_allow_config()
self.log.warning( 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""" """Checks against a global password if it's been set. If not, allow any user/pass combo"""
if self.password: if self.password:
if data['password'] == self.password: if data['password'] == self.password:
return data['username'] return data["username"]
return None return None
return data['username'] 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. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from jupyterhub.auth import DummyAuthenticator 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" default = "jupyterhub.auth:PAMAuthenticator"
pam = "jupyterhub.auth:PAMAuthenticator" pam = "jupyterhub.auth:PAMAuthenticator"
dummy = "jupyterhub.auth:DummyAuthenticator" dummy = "jupyterhub.auth:DummyAuthenticator"
shared-password = "jupyterhub.authenticators.shared:SharedPasswordAuthenticator"
null = "jupyterhub.auth:NullAuthenticator" null = "jupyterhub.auth:NullAuthenticator"
[project.entry-points."jupyterhub.proxies"] [project.entry-points."jupyterhub.proxies"]