mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 10:04:07 +00:00
Merge branch 'main' into allow_all
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Authenticating services with JupyterHub.
|
||||
|
||||
Cookies are sent to the Hub for verification. The Hub replies with a JSON
|
||||
model describing the authenticated user.
|
||||
Tokens are sent to the Hub for verification.
|
||||
The Hub replies with a JSON model describing the authenticated user.
|
||||
|
||||
``HubAuth`` can be used in any application, even outside tornado.
|
||||
|
||||
@@ -10,6 +10,7 @@ authenticate with the Hub.
|
||||
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -20,7 +21,6 @@ import time
|
||||
import uuid
|
||||
import warnings
|
||||
from unittest import mock
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
@@ -33,13 +33,50 @@ from traitlets import Dict
|
||||
from traitlets import Instance
|
||||
from traitlets import Integer
|
||||
from traitlets import observe
|
||||
from traitlets import Set
|
||||
from traitlets import Unicode
|
||||
from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
def check_scopes(required_scopes, scopes):
|
||||
"""Check that required_scope(s) are in scopes
|
||||
|
||||
Returns the subset of scopes matching required_scopes,
|
||||
which is truthy if any scopes match any required scopes.
|
||||
|
||||
Correctly resolves scope filters *except* for groups -> user,
|
||||
e.g. require: access:server!user=x, have: access:server!group=y
|
||||
will not grant access to user x even if user x is in group y.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
required_scopes: set
|
||||
The set of scopes required.
|
||||
scopes: set
|
||||
The set (or list) of scopes to check against required_scopes
|
||||
|
||||
Returns
|
||||
-------
|
||||
relevant_scopes: set
|
||||
The set of scopes in required_scopes that are present in scopes,
|
||||
which is truthy if any required scopes are present,
|
||||
and falsy otherwise.
|
||||
"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = {required_scopes}
|
||||
|
||||
intersection = _intersect_expanded_scopes(required_scopes, scopes)
|
||||
# re-intersect with required_scopes in case the intersection
|
||||
# applies stricter filters than required_scopes declares
|
||||
# e.g. required_scopes = {'read:users'} and intersection has only {'read:users!user=x'}
|
||||
return set(required_scopes) & intersection
|
||||
|
||||
|
||||
class _ExpiringDict(dict):
|
||||
"""Dict-like cache for Hub API requests
|
||||
|
||||
@@ -113,9 +150,15 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
This can be used by any application.
|
||||
|
||||
Use this base class only for direct, token-authenticated applications
|
||||
(web APIs).
|
||||
For applications that support direct visits from browsers,
|
||||
use HubOAuth to enable OAuth redirect-based authentication.
|
||||
|
||||
|
||||
If using tornado, use via :class:`HubAuthenticated` mixin.
|
||||
If using manually, use the ``.user_for_cookie(cookie_value)`` method
|
||||
to identify the user corresponding to a given cookie value.
|
||||
If using manually, use the ``.user_for_token(token_value)`` method
|
||||
to identify the user owning a given token.
|
||||
|
||||
The following config must be set:
|
||||
|
||||
@@ -129,15 +172,12 @@ class HubAuth(SingletonConfigurable):
|
||||
- cookie_cache_max_age: the number of seconds responses
|
||||
from the Hub should be cached.
|
||||
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
||||
- cookie_name: the name of the cookie I should be using,
|
||||
if different from the default (unlikely).
|
||||
|
||||
"""
|
||||
|
||||
hub_host = Unicode(
|
||||
'',
|
||||
help="""The public host of JupyterHub
|
||||
|
||||
|
||||
Only used if JupyterHub is spreading servers across subdomains.
|
||||
""",
|
||||
).tag(config=True)
|
||||
@@ -239,10 +279,6 @@ class HubAuth(SingletonConfigurable):
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
cookie_name = Unicode(
|
||||
'jupyterhub-services', help="""The name of the cookie I should be looking for"""
|
||||
).tag(config=True)
|
||||
|
||||
cookie_options = Dict(
|
||||
help="""Additional options to pass when setting cookies.
|
||||
|
||||
@@ -286,12 +322,30 @@ class HubAuth(SingletonConfigurable):
|
||||
def _default_cache(self):
|
||||
return _ExpiringDict(self.cache_max_age)
|
||||
|
||||
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
||||
oauth_scopes = Set(
|
||||
Unicode(),
|
||||
help="""OAuth scopes to use for allowing access.
|
||||
|
||||
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@default('oauth_scopes')
|
||||
def _default_scopes(self):
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||
if env_scopes:
|
||||
return set(json.loads(env_scopes))
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
if service_name:
|
||||
return {f'access:services!service={service_name}'}
|
||||
return set()
|
||||
|
||||
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||
"""Identify a user with the Hub
|
||||
|
||||
Args:
|
||||
url (str): The API URL to check the Hub for authorization
|
||||
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
||||
(e.g. http://127.0.0.1:8081/hub/api/user)
|
||||
cache_key (str): The key for checking the cache
|
||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||
|
||||
@@ -309,7 +363,12 @@ class HubAuth(SingletonConfigurable):
|
||||
except KeyError:
|
||||
app_log.debug("HubAuth cache miss: %s", cache_key)
|
||||
|
||||
data = self._api_request('GET', url, allow_404=True)
|
||||
data = self._api_request(
|
||||
'GET',
|
||||
url,
|
||||
headers={"Authorization": "token " + api_token},
|
||||
allow_403=True,
|
||||
)
|
||||
if data is None:
|
||||
app_log.warning("No Hub user identified for request")
|
||||
else:
|
||||
@@ -321,7 +380,7 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
def _api_request(self, method, url, **kwargs):
|
||||
"""Make an API request"""
|
||||
allow_404 = kwargs.pop('allow_404', False)
|
||||
allow_403 = kwargs.pop('allow_403', False)
|
||||
headers = kwargs.setdefault('headers', {})
|
||||
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
||||
if "cert" not in kwargs and self.certfile and self.keyfile:
|
||||
@@ -345,7 +404,7 @@ class HubAuth(SingletonConfigurable):
|
||||
raise HTTPError(500, msg)
|
||||
|
||||
data = None
|
||||
if r.status_code == 404 and allow_404:
|
||||
if r.status_code == 403 and allow_403:
|
||||
pass
|
||||
elif r.status_code == 403:
|
||||
app_log.error(
|
||||
@@ -389,26 +448,9 @@ class HubAuth(SingletonConfigurable):
|
||||
return data
|
||||
|
||||
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
||||
"""Ask the Hub to identify the user for a given cookie.
|
||||
|
||||
Args:
|
||||
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
|
||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||
|
||||
Returns:
|
||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
||||
|
||||
The 'name' field contains the user's name.
|
||||
"""
|
||||
return self._check_hub_authorization(
|
||||
url=url_path_join(
|
||||
self.api_url,
|
||||
"authorizations/cookie",
|
||||
self.cookie_name,
|
||||
quote(encrypted_cookie, safe=''),
|
||||
),
|
||||
cache_key='cookie:{}:{}'.format(session_id, encrypted_cookie),
|
||||
use_cache=use_cache,
|
||||
"""Deprecated and removed. Use HubOAuth to authenticate browsers."""
|
||||
raise RuntimeError(
|
||||
"Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
|
||||
)
|
||||
|
||||
def user_for_token(self, token, use_cache=True, session_id=''):
|
||||
@@ -425,14 +467,19 @@ class HubAuth(SingletonConfigurable):
|
||||
"""
|
||||
return self._check_hub_authorization(
|
||||
url=url_path_join(
|
||||
self.api_url, "authorizations/token", quote(token, safe='')
|
||||
self.api_url,
|
||||
"user",
|
||||
),
|
||||
api_token=token,
|
||||
cache_key='token:{}:{}'.format(
|
||||
session_id,
|
||||
hashlib.sha256(token.encode("utf8", "replace")).hexdigest(),
|
||||
),
|
||||
cache_key='token:{}:{}'.format(session_id, token),
|
||||
use_cache=use_cache,
|
||||
)
|
||||
|
||||
auth_header_name = 'Authorization'
|
||||
auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE)
|
||||
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||
|
||||
def get_token(self, handler):
|
||||
"""Get the user token from a request
|
||||
@@ -453,10 +500,8 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
def _get_user_cookie(self, handler):
|
||||
"""Get the user model from a cookie"""
|
||||
encrypted_cookie = handler.get_cookie(self.cookie_name)
|
||||
session_id = self.get_session_id(handler)
|
||||
if encrypted_cookie:
|
||||
return self.user_for_cookie(encrypted_cookie, session_id=session_id)
|
||||
# overridden in HubOAuth to store the access token after oauth
|
||||
return None
|
||||
|
||||
def get_session_id(self, handler):
|
||||
"""Get the jupyterhub session id
|
||||
@@ -505,10 +550,17 @@ class HubAuth(SingletonConfigurable):
|
||||
app_log.debug("No user identified")
|
||||
return user_model
|
||||
|
||||
def check_scopes(self, required_scopes, user):
|
||||
"""Check whether the user has required scope(s)"""
|
||||
return check_scopes(required_scopes, set(user["scopes"]))
|
||||
|
||||
|
||||
class HubOAuth(HubAuth):
|
||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||
|
||||
Use this class if you want users to be able to visit your service with a browser.
|
||||
They will be authenticated via OAuth with the Hub.
|
||||
|
||||
.. versionadded: 0.8
|
||||
"""
|
||||
|
||||
@@ -557,7 +609,7 @@ class HubOAuth(HubAuth):
|
||||
|
||||
oauth_client_id = Unicode(
|
||||
help="""The OAuth client ID for this application.
|
||||
|
||||
|
||||
Use JUPYTERHUB_CLIENT_ID by default.
|
||||
"""
|
||||
).tag(config=True)
|
||||
@@ -574,7 +626,7 @@ class HubOAuth(HubAuth):
|
||||
|
||||
oauth_redirect_uri = Unicode(
|
||||
help="""OAuth redirect URI
|
||||
|
||||
|
||||
Should generally be /base_url/oauth_callback
|
||||
"""
|
||||
).tag(config=True)
|
||||
@@ -772,12 +824,26 @@ class UserNotAllowed(Exception):
|
||||
)
|
||||
|
||||
|
||||
class HubAuthenticated(object):
|
||||
class HubAuthenticated:
|
||||
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
||||
|
||||
A handler that mixes this in must have the following attributes/properties:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
Default comes from .hub_auth.oauth_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||
Default values include:
|
||||
- 'access:services', 'access:services!service={service_name}' for services
|
||||
- 'access:servers', 'access:servers!user={user}',
|
||||
'access:servers!server={user}/{server_name}'
|
||||
for single-user servers
|
||||
|
||||
If hub_scopes is not used (e.g. JupyterHub 1.x),
|
||||
these additional properties can be used:
|
||||
|
||||
- .allow_admin: If True, allow any admin user.
|
||||
Default: False.
|
||||
- .hub_users: A set of usernames to allow.
|
||||
If left unspecified or None, username will not be checked.
|
||||
- .hub_groups: A set of group names to allow.
|
||||
@@ -804,13 +870,19 @@ class HubAuthenticated(object):
|
||||
hub_groups = None # set of allowed groups
|
||||
allow_admin = False # allow any admin user access
|
||||
|
||||
@property
|
||||
def hub_scopes(self):
|
||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
||||
return self.hub_auth.oauth_scopes or None
|
||||
|
||||
@property
|
||||
def allow_all(self):
|
||||
"""Property indicating that all successfully identified user
|
||||
or service should be allowed.
|
||||
"""
|
||||
return (
|
||||
self.hub_services is None
|
||||
self.hub_scopes is None
|
||||
and self.hub_services is None
|
||||
and self.hub_users is None
|
||||
and self.hub_groups is None
|
||||
and not self.allow_admin
|
||||
@@ -852,22 +924,43 @@ class HubAuthenticated(object):
|
||||
|
||||
Returns the input if the user should be allowed, None otherwise.
|
||||
|
||||
Override if you want to check anything other than the username's presence in hub_users list.
|
||||
Override for custom logic in authenticating users.
|
||||
|
||||
Args:
|
||||
model (dict): the user or service model returned from :class:`HubAuth`
|
||||
user_model (dict): the user or service model returned from :class:`HubAuth`
|
||||
Returns:
|
||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||
"""
|
||||
|
||||
name = model['name']
|
||||
kind = model.setdefault('kind', 'user')
|
||||
|
||||
if self.allow_all:
|
||||
app_log.debug(
|
||||
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
||||
)
|
||||
return model
|
||||
|
||||
if self.hub_scopes:
|
||||
scopes = self.hub_auth.check_scopes(self.hub_scopes, model)
|
||||
if scopes:
|
||||
app_log.debug(
|
||||
f"Allowing Hub {kind} {name} based on oauth scopes {scopes}"
|
||||
)
|
||||
return model
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Not allowing Hub {kind} {name}: missing required scopes"
|
||||
)
|
||||
app_log.debug(
|
||||
f"Hub {kind} {name} needs scope(s) {self.hub_scopes}, has scope(s) {model['scopes']}"
|
||||
)
|
||||
# if hub_scopes are used, *only* hub_scopes are used
|
||||
# note: this means successful authentication, but insufficient permission
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
return model
|
||||
|
Reference in New Issue
Block a user