Files
jupyterhub/jupyterhub/services/auth.py

357 lines
12 KiB
Python

"""Authenticating services with JupyterHub
Cookies are sent to the Hub for verification, replying with a JSON model describing the authenticated user.
HubAuth can be used in any application, even outside tornado.
HubAuthenticated is a mixin class for tornado handlers that should authenticate with the Hub.
"""
import os
import socket
import time
from urllib.parse import quote
import requests
from tornado.log import app_log
from tornado.web import HTTPError
from traitlets.config import Configurable
from traitlets import Unicode, Integer, Instance, default
from ..utils import url_path_join
class _ExpiringDict(dict):
"""Dict-like cache for Hub API requests
Values will expire after max_age seconds.
A monotonic timer is used (time.monotonic).
A max_age of 0 means cache forever.
"""
max_age = 0
def __init__(self, max_age=0):
self.max_age = max_age
self.timestamps = {}
self.values = {}
def __setitem__(self, key, value):
"""Store key and record timestamp"""
self.timestamps[key] = time.monotonic()
self.values[key] = value
def _check_age(self, key):
"""Check timestamp for a key"""
if key not in self.values:
# not registered, nothing to do
return
now = time.monotonic()
timestamp = self.timestamps[key]
if self.max_age > 0 and timestamp + self.max_age < now:
self.values.pop(key)
self.timestamps.pop(key)
def __contains__(self, key):
"""dict check for `key in dict`"""
self._check_age(key)
return key in self.values
def __getitem__(self, key):
"""Check age before returning value"""
self._check_age(key)
return self.values[key]
def get(self, key, default=None):
"""dict-like get:"""
try:
return self[key]
except KeyError:
return default
class HubAuth(Configurable):
"""A class for authenticating with JupyterHub
This can be used by any application.
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.
The following config must be set:
- api_token (token for authenticating with JupyterHub API),
fetched from the JUPYTERHUB_API_TOKEN env by default.
The following config MAY be set:
- api_url: the base URL of the Hub's internal API,
fetched from JUPYTERHUB_API_URL by default.
- 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).
"""
# where is the hub
api_url = Unicode(os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api',
help="""The base API URL of the Hub.
Typically http://hub-ip:hub-port/hub/api
"""
).tag(config=True)
login_url = Unicode('/hub/login',
help="""The login URL of the Hub
Typically /hub/login
"""
).tag(config=True)
api_token = Unicode(os.environ.get('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API.
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
"""
).tag(config=True)
cookie_name = Unicode('jupyterhub-services',
help="""The name of the cookie I should be looking for"""
).tag(config=True)
cookie_cache_max_age = Integer(300,
help="""The maximum time (in seconds) to cache the Hub's response for cookie authentication.
A larger value reduces load on the Hub and occasional response lag.
A smaller value reduces propagation time of changes on the Hub (rare).
Default: 300 (five minutes)
"""
).tag(config=True)
cookie_cache = Instance(_ExpiringDict, allow_none=False)
@default('cookie_cache')
def _cookie_cache(self):
return _ExpiringDict(self.cookie_cache_max_age)
def _check_response_for_authorization(self, r):
"""Verify the response for authorizing a user
Raises an error if the response failed, otherwise returns the json
"""
if r.status_code == 404:
app_log.warning("No Hub user identified for request")
data = None
elif r.status_code == 403:
app_log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Permission failure checking authorization, I may need a new token")
elif r.status_code >= 500:
app_log.error("Upstream failure verifying auth token: [%i] %s", r.status_code, r.reason)
raise HTTPError(502, "Failed to check authorization (upstream problem)")
elif r.status_code >= 400:
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
raise HTTPError(500, "Failed to check authorization")
else:
app_log.debug("Received request from Hub user %s", data)
return r.json()
def user_for_cookie(self, encrypted_cookie, use_cache=True):
"""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.
"""
if use_cache:
cached = self.cookie_cache.get(encrypted_cookie)
if cached is not None:
return cached
try:
r = requests.get(
url_path_join(self.api_url,
"authorizations/cookie",
self.cookie_name,
quote(encrypted_cookie, safe=''),
),
headers = {
'Authorization' : 'token %s' % self.api_token,
},
)
except requests.ConnectionError:
msg = "Failed to connect to Hub API at %r." % self.api_url
msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
if '127.0.0.1' in self.api_url:
msg += " Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
" single-user servers if the servers are not on the same host as the Hub."
raise HTTPError(500, msg)
data = self._check_response_for_authorization(r)
self.cookie_cache[encrypted_cookie] = data
return data
def user_for_token(self, token, use_cache=True):
"""Ask the Hub to identify the user for a given token.
Args:
token (str): the token
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.
"""
if use_cache:
cached = self.cookie_cache.get(token)
if cached is not None:
return cached
try:
r = requests.get(
url_path_join(self.api_url,
"authorizations/token",
quote(token, safe=''),
),
headers = {
'Authorization' : 'token %s' % self.api_token,
},
)
except requests.ConnectionError:
msg = "Failed to connect to Hub API at %r." % self.api_url
msg += " Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
if '127.0.0.1' in self.api_url:
msg += " Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
" single-user servers if the servers are not on the same host as the Hub."
raise HTTPError(500, msg)
data = self._check_response_for_authorization(r)
self.cookie_cache[token] = data
return data
def get_user(self, handler):
"""Get the Hub user for a given tornado handler.
Checks cookie with the Hub to identify the current user.
Args:
handler (tornado.web.RequestHandler): the current request handler
Returns:
user_model (dict): The user model, if a user is identified, None if authentication fails.
The 'name' field contains the user's name.
"""
# only allow this to be called once per handler
# avoids issues if an error is raised,
# since this may be called again when trying to render the error page
if hasattr(handler, '_cached_hub_user'):
return handler._cached_hub_user
handler._cached_hub_user = None
encrypted_cookie = handler.get_cookie(self.cookie_name)
if encrypted_cookie:
user_model = self.user_for_cookie(encrypted_cookie)
handler._cached_hub_user = user_model
return user_model
else:
app_log.debug("No token cookie")
return None
class HubAuthenticated(object):
"""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_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.
If left unspecified or None, groups will not be checked.
Examples::
class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@web.authenticated
def get(self):
...
"""
hub_users = None # set of allowed users
hub_groups = None # set of allowed groups
# self.hub_auth must be a HubAuth instance.
# If nothing specified, use default config,
# which will be configured with defaults
# based on JupyterHub environment variables for services.
_hub_auth = None
@property
def hub_auth(self):
if self._hub_auth is None:
self._hub_auth = HubAuth()
return self._hub_auth
@hub_auth.setter
def hub_auth(self, auth):
self._hub_auth = auth
def check_hub_user(self, user_model):
"""Check whether Hub-authenticated user should be allowed.
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.
Args:
user_model (dict): the user model returned from :class:`HubAuth`
Returns:
user_model (dict): The user model if the user should be allowed, None otherwise.
"""
name = user_model['name']
if self.hub_users is None and self.hub_groups is None:
# no whitelist specified, allow any authenticated Hub user
app_log.debug("Allowing Hub user %s (all Hub users allowed)", name)
return user_model
if self.hub_users and name in self.hub_users:
# user in whitelist
app_log.debug("Allowing whitelisted Hub user %s", name)
return user_model
elif self.hub_groups and set(user_model['groups']).intersection(self.hub_groups):
allowed_groups = set(user_model['groups']).intersection(self.hub_groups)
app_log.debug("Allowing Hub user %s in group(s) %s", name, ','.join(sorted(allowed_groups)))
# group in whitelist
return user_model
else:
app_log.warning("Not allowing Hub user %s" % name)
return None
def get_current_user(self):
"""Tornado's authentication method
Returns:
user_model (dict): The user model, if a user is identified, None if authentication fails.
"""
if hasattr(self, '_hub_auth_user_cache'):
return self._hub_auth_user_cache
user_model = self.hub_auth.get_user(self)
if not user_model:
self._hub_auth_user_cache = None
return
self._hub_auth_user_cache = self.check_hub_user(user_model)
return self._hub_auth_user_cache