Merge branch 'main' into allow_all

This commit is contained in:
Martynov Maxim
2021-07-05 14:43:02 +03:00
committed by GitHub
90 changed files with 7274 additions and 1335 deletions

View File

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