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
|
||||
|
||||
## 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:
|
||||
```
|
||||
|
@@ -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
|
||||
|
@@ -1504,12 +1504,19 @@ class DummyAuthenticator(Authenticator):
|
||||
password = Unicode(
|
||||
config=True,
|
||||
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):
|
||||
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']
|
||||
|
||||
|
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.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
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"
|
||||
pam = "jupyterhub.auth:PAMAuthenticator"
|
||||
dummy = "jupyterhub.auth:DummyAuthenticator"
|
||||
shared-password = "jupyterhub.authenticators.shared:SharedPasswordAuthenticator"
|
||||
null = "jupyterhub.auth:NullAuthenticator"
|
||||
|
||||
[project.entry-points."jupyterhub.proxies"]
|
||||
|
Reference in New Issue
Block a user