mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 21:43:01 +00:00
Merge pull request #5037 from yuvipanda/dummy-path
Add SharedPasswordAuthenticator
This commit is contained in:
@@ -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:
|
||||||
|
```
|
||||||
|
@@ -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
|
||||||
|
@@ -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']
|
||||||
|
|
||||||
|
0
jupyterhub/authenticators/__init__.py
Normal file
0
jupyterhub/authenticators/__init__.py
Normal file
149
jupyterhub/authenticators/shared.py
Normal file
149
jupyterhub/authenticators/shared.py
Normal 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
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
162
jupyterhub/tests/test_shared_password.py
Normal file
162
jupyterhub/tests/test_shared_password.py
Normal 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
|
@@ -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"]
|
||||||
|
Reference in New Issue
Block a user