make HubOAuth a subclass

instead of implementing both shared-cookie and OAuth on HubAuth
This commit is contained in:
Min RK
2017-03-30 15:57:58 +02:00
parent 3e482d08d7
commit 74728e5f42
2 changed files with 171 additions and 175 deletions

View File

@@ -106,31 +106,28 @@ class HubAuth(Configurable):
""" """
# where is the hub hub_host = Unicode('',
api_url = Unicode(os.getenv('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api', help="""The public host of JupyterHub
help="""The base API URL of the Hub.
Only used if JupyterHub is spreading servers across subdomains.
Typically http://hub-ip:hub-port/hub/api
""" """
).tag(config=True) ).tag(config=True)
host = Unicode('', host = Unicode('',
help="""The public host of this server. help="""The public host of this service/server
Only used if working with subdomains. Only used if JupyterHub is spreading servers across subdomains.
""" """
).tag(config=True) ).tag(config=True)
base_url = Unicode(os.getenv('JUPYTERHUB_SERVICE_PREFIX') or '/', base_url = Unicode(os.getenv('JUPYTERHUB_SERVICE_PREFIX') or '/',
help="""The base URL prefix of this help="""The base URL prefix of this application
added on to host
e.g. /services/service-name/ or /user/name/ e.g. /services/service-name/ or /user/name/
Default: get from JUPYTERHUB_SERVICE_PREFIX Default: get from JUPYTERHUB_SERVICE_PREFIX
""" """
) ).tag(config=True)
@validate('base_url') @validate('base_url')
def _add_slash(self, proposal): def _add_slash(self, proposal):
"""Ensure base_url starts and ends with /""" """Ensure base_url starts and ends with /"""
@@ -141,84 +138,35 @@ class HubAuth(Configurable):
value = value + '/' value = value + '/'
return value return value
login_url = Unicode('/hub/login', # where is the hub
help="""The login URL of the Hub api_url = Unicode(os.getenv('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api',
help="""The base API URL of the Hub.
Typically /hub/login Typically http://hub-ip:hub-port/hub/api
If using OAuth, will the OAuth redirect URL
including client_id, redirect_uri params.
""" """
).tag(config=True) ).tag(config=True)
@default('login_url')
def _login_url(self):
if self.using_oauth:
return url_concat(self.oauth_authorization_url, {
'client_id': self.oauth_client_id,
'redirect_uri': self.oauth_redirect_uri,
'response_type': 'code',
})
else:
return '/hub/login'
@property
def using_oauth(self):
"""Am I using OAuth?"""
return bool(self.oauth_client_id)
oauth_client_id = Unicode(os.getenv('JUPYTERHUB_CLIENT_ID', ''),
help="""The OAuth client ID for this application.
Use JUPYTERHUB_CLIENT_ID by default.
"""
).tag(config=True)
oauth_client_secret = Unicode(os.getenv('JUPYTERHUB_CLIENT_SECRET', ''),
help="""The OAuth client secret for this application.
Use JUPYTERHUB_CLIENT_SECRET by default.
"""
).tag(config=True)
@property
def oauth_cookie_name(self):
"""Use OAuth client_id for cookie name
because we don't want to use the same cookie name
across OAuth clients.
"""
return self.oauth_client_id
oauth_redirect_uri = Unicode(
help="""OAuth redirect URI
Should generally be /base_url/oauth_callback
"""
).tag(config=True)
@default('oauth_redirect_uri')
def _default_redirect(self):
return self.host + url_path_join(self.base_url, 'oauth_callback')
oauth_authorization_url = Unicode('/hub/api/oauth2/authorize',
help="The URL to redirect to when starting the OAuth process",
).tag(config=True)
oauth_token_url = Unicode(
help="""The URL for requesting an OAuth token from JupyterHub"""
).tag(config=True)
@default('oauth_token_url')
def _token_url(self):
return url_path_join(self.api_url, 'oauth2/token')
api_token = Unicode(os.getenv('JUPYTERHUB_API_TOKEN', ''), api_token = Unicode(os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API. help="""API key for accessing Hub API.
Generate with `jupyterhub token [username]` or add to JupyterHub.services config. Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
""" """
).tag(config=True) ).tag(config=True)
login_url = Unicode('/hub/login',
help="""The login URL to use
Typically /hub/login
"""
).tag(config=True)
@default('login_url')
def _default_login_url(self):
return self.hub_host + '/hub/login'
cookie_name = Unicode('jupyterhub-services', cookie_name = Unicode('jupyterhub-services',
help="""The name of the cookie I should be looking for""" help="""The name of the cookie I should be looking for"""
).tag(config=True) ).tag(config=True)
cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age") cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age")
@observe('cookie_cache_max_age') @observe('cookie_cache_max_age')
def _deprecated_cookie_cache(self, change): def _deprecated_cookie_cache(self, change):
@@ -306,31 +254,6 @@ class HubAuth(Configurable):
return data return data
def oauth_token_for_code(self, code):
"""Get OAuth token for code
Finish OAuth handshake. Should be called in OAuth Callback handler.
Args:
code: oauth code for finishing OAuth login
"""
# GitHub specifies a POST request yet requires URL parameters
params = dict(
client_id=self.oauth_client_id,
client_secret=self.oauth_client_secret,
grant_type='authorization_code',
code=code,
redirect_uri=self.oauth_redirect_uri,
)
token_reply = self._api_request('POST', self.oauth_token_url,
data=urlencode(params).encode('utf8'),
headers={
'Content-Type': 'application/x-www-form-urlencoded'
})
return token_reply['access_token']
def user_for_cookie(self, encrypted_cookie, use_cache=True): def user_for_cookie(self, encrypted_cookie, use_cache=True):
"""Ask the Hub to identify the user for a given cookie. """Ask the Hub to identify the user for a given cookie.
@@ -390,23 +313,11 @@ class HubAuth(Configurable):
user_token = m.group(1) user_token = m.group(1)
return user_token return user_token
def set_cookie(self, handler, user_model): def _get_user_cookie(self, handler):
"""Set a cookie recording OAuth result""" """Get the user model from a cookie"""
kwargs = { encrypted_cookie = handler.get_cookie(self.cookie_name)
'path': self.base_url, if encrypted_cookie:
} return self.user_for_cookie(encrypted_cookie)
if handler.request.protocol == 'https':
kwargs['secure'] = True
# if self.subdomain_host:
# kwargs['domain'] = self.domain
cookie_value = json.dumps({'name': user_model['name']})
app_log.debug("Setting oauth cookie for %s: %s, %s",
handler.request.remote_ip, self.oauth_cookie_name, kwargs)
handler.set_secure_cookie(
self.oauth_cookie_name,
cookie_value,
**kwargs
)
def get_user(self, handler): def get_user(self, handler):
"""Get the Hub user for a given tornado handler. """Get the Hub user for a given tornado handler.
@@ -439,14 +350,7 @@ class HubAuth(Configurable):
# no token, check cookie # no token, check cookie
if user_model is None: if user_model is None:
if self.using_oauth: user_model = self._get_user_cookie(handler)
user_model_json = handler.get_secure_cookie(self.oauth_cookie_name)
if user_model_json:
user_model = json.loads(user_model_json.decode('utf8', 'replace'))
else:
encrypted_cookie = handler.get_cookie(self.cookie_name)
if encrypted_cookie:
user_model = self.user_for_cookie(encrypted_cookie)
# cache result # cache result
handler._cached_hub_user = user_model handler._cached_hub_user = user_model
@@ -455,6 +359,123 @@ class HubAuth(Configurable):
return user_model return user_model
class HubOAuth(HubAuth):
"""HubAuth using OAuth for login instead of cookies set by the Hub.
.. versionadded: 0.8
"""
# Overrides of HubAuth API
@default('login_url')
def _login_url(self):
return url_concat(self.oauth_authorization_url, {
'client_id': self.oauth_client_id,
'redirect_uri': self.oauth_redirect_uri,
'response_type': 'code',
})
@property
def cookie_name(self):
"""Use OAuth client_id for cookie name
because we don't want to use the same cookie name
across OAuth clients.
"""
return self.oauth_client_id
def _get_user_cookie(self, handler):
user_model_json = handler.get_secure_cookie(self.cookie_name)
if user_model_json:
return json.loads(user_model_json.decode('utf8', 'replace'))
# HubOAuth API
oauth_client_id = Unicode(os.getenv('JUPYTERHUB_CLIENT_ID', ''),
help="""The OAuth client ID for this application.
Use JUPYTERHUB_CLIENT_ID by default.
"""
).tag(config=True)
oauth_client_secret = Unicode(os.getenv('JUPYTERHUB_CLIENT_SECRET', ''),
help="""The OAuth client secret for this application.
Use JUPYTERHUB_CLIENT_SECRET by default.
"""
).tag(config=True)
oauth_redirect_uri = Unicode(
help="""OAuth redirect URI
Should generally be /base_url/oauth_callback
"""
).tag(config=True)
@default('oauth_redirect_uri')
def _default_redirect(self):
return self.host + url_path_join(self.base_url, 'oauth_callback')
oauth_authorization_url = Unicode('/hub/api/oauth2/authorize',
help="The URL to redirect to when starting the OAuth process",
).tag(config=True)
oauth_token_url = Unicode(
help="""The URL for requesting an OAuth token from JupyterHub"""
).tag(config=True)
@default('oauth_token_url')
def _token_url(self):
return url_path_join(self.api_url, 'oauth2/token')
def token_for_code(self, code):
"""Get token for OAuth temporary code
This is the last step of OAuth login.
Should be called in OAuth Callback handler.
Args:
code (str): oauth code for finishing OAuth login
Returns:
token (str): JupyterHub API Token
"""
# GitHub specifies a POST request yet requires URL parameters
params = dict(
client_id=self.oauth_client_id,
client_secret=self.oauth_client_secret,
grant_type='authorization_code',
code=code,
redirect_uri=self.oauth_redirect_uri,
)
token_reply = self._api_request('POST', self.oauth_token_url,
data=urlencode(params).encode('utf8'),
headers={
'Content-Type': 'application/x-www-form-urlencoded'
})
return token_reply['access_token']
def set_cookie(self, handler, user_model):
"""Set a cookie recording OAuth result"""
kwargs = {
'path': self.base_url,
}
if handler.request.protocol == 'https':
kwargs['secure'] = True
# if self.subdomain_host:
# kwargs['domain'] = self.domain
cookie_value = json.dumps({'name': user_model['name']})
app_log.debug("Setting oauth cookie for %s: %s, %s",
handler.request.remote_ip, self.cookie_name, kwargs)
handler.set_secure_cookie(
self.cookie_name,
cookie_value,
**kwargs
)
def clear_cookie(self, handler):
"""Clear the OAuth cookie"""
handler.clear_cookie(self.cookie_name, path=self.base_url)
class HubAuthenticated(object): class HubAuthenticated(object):
"""Mixin for tornado handlers that are authenticated with JupyterHub """Mixin for tornado handlers that are authenticated with JupyterHub
@@ -497,10 +518,11 @@ class HubAuthenticated(object):
# which will be configured with defaults # which will be configured with defaults
# based on JupyterHub environment variables for services. # based on JupyterHub environment variables for services.
_hub_auth = None _hub_auth = None
hub_auth_class = HubAuth
@property @property
def hub_auth(self): def hub_auth(self):
if self._hub_auth is None: if self._hub_auth is None:
self._hub_auth = HubAuth() self._hub_auth = self.hub_auth_class()
return self._hub_auth return self._hub_auth
@hub_auth.setter @hub_auth.setter
@@ -569,7 +591,12 @@ class HubAuthenticated(object):
return self._hub_auth_user_cache return self._hub_auth_user_cache
class JupyterHubOAuthCallbackHandler(HubAuthenticated, RequestHandler): class HubOAuthenticated(HubAuthenticated):
"""Simple subclass of HubAuthenticated using OAuth instead of old shared cookies"""
hub_auth_class = HubOAuth
class JupyterHubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
"""OAuth Callback handler""" """OAuth Callback handler"""
@coroutine @coroutine
@@ -578,43 +605,11 @@ class JupyterHubOAuthCallbackHandler(HubAuthenticated, RequestHandler):
if not code: if not code:
raise HTTPError(400, "oauth callback made without a token") raise HTTPError(400, "oauth callback made without a token")
# TODO: make async (in a Thread?) # TODO: make async (in a Thread?)
token_reply = self.hub_auth.oauth_token_for_code(code) token_reply = self.hub_auth.token_for_code(code)
user_model = self.hub_auth.user_for_token(token)
# TODO: Configure the curl_httpclient for tornado self.log.info("Logged-in user %s", user_model)
# Exchange the OAuth code for a GitHub Access Token
#
# See: https://developer.github.com/v3/oauth/
# GitHub specifies a POST request yet requires URL parameters
params = dict(
client_id=self.hub_auth.oauth_client_id,
client_secret=self.hub_auth.oauth_client_secret,
code=code
)
url = self.hub_auth.oauth_token_url
req = HTTPRequest(url,
method="POST",
headers={"Accept": "application/json"},
body=''
)
resp = yield http_client.fetch(req)
resp_json = json.loads(resp.body.decode('utf8', 'replace'))
access_token = resp_json['access_token']
# Determine who the logged in user is
headers = {
"User-Agent": "JupyterHub OAuth Client",
"Authorization": "token {}".format(access_token)
}
req = HTTPRequest(self.hub_auth.oauth_token_url,
method="GET",
headers=headers,
)
resp = yield http_client.fetch(req)
user_model = json.loads(resp.body.decode('utf8', 'replace'))
self.hub_auth.set_cookie(self, user_model) self.hub_auth.set_cookie(self, user_model)
next_url = self.get_argument('next', '') or self.base_url
self.redirect(next_url)

View File

@@ -37,14 +37,14 @@ from notebook.auth.logout import LogoutHandler
from notebook.base.handlers import IPythonHandler from notebook.base.handlers import IPythonHandler
from jupyterhub import __version__ from jupyterhub import __version__
from .services.auth import HubAuth, HubAuthenticated, JupyterHubOAuthCallbackHandler from .services.auth import HubOAuth, HubOAuthenticated, JupyterHubOAuthCallbackHandler
from .utils import url_path_join from .utils import url_path_join
# Authenticate requests with the Hub # Authenticate requests with the Hub
class HubAuthenticatedHandler(HubAuthenticated): class HubAuthenticatedHandler(HubOAuthenticated):
"""Class we are going to patch-in for authentication with the Hub""" """Class we are going to patch-in for authentication with the Hub"""
@property @property
def hub_auth(self): def hub_auth(self):
@@ -95,12 +95,13 @@ class JupyterHubLoginHandler(LoginHandler):
class JupyterHubLogoutHandler(LogoutHandler): class JupyterHubLogoutHandler(LogoutHandler):
def get(self): def get(self):
self.settings['hub_auth'].clear_cookie(self)
self.redirect( self.redirect(
self.settings['hub_host'] + self.settings['hub_host'] +
url_path_join(self.settings['hub_prefix'], 'logout')) url_path_join(self.settings['hub_prefix'], 'logout'))
class OAuthCallbackHandler(IPythonHandler): class OAuthCallbackHandler(JupyterHubOAuthCallbackHandler, IPythonHandler):
"""Mixin IPythonHandler to get the right error pages, etc.""" """Mixin IPythonHandler to get the right error pages, etc."""
@property @property
def hub_auth(self): def hub_auth(self):
@@ -111,7 +112,7 @@ class OAuthCallbackHandler(IPythonHandler):
if not code: if not code:
raise HTTPError(400, "oauth callback made without a token") raise HTTPError(400, "oauth callback made without a token")
# TODO: make async (in a Thread?) # TODO: make async (in a Thread?)
token = self.hub_auth.oauth_token_for_code(code) token = self.hub_auth.token_for_code(code)
user_model = self.hub_auth.user_for_token(token) user_model = self.hub_auth.user_for_token(token)
self.log.info("Logged-in user %s", user_model) self.log.info("Logged-in user %s", user_model)
self.hub_auth.set_cookie(self, user_model) self.hub_auth.set_cookie(self, user_model)
@@ -179,7 +180,7 @@ class SingleUserNotebookApp(NotebookApp):
examples = "" examples = ""
subcommands = {} subcommands = {}
version = __version__ version = __version__
classes = NotebookApp.classes + [HubAuth] classes = NotebookApp.classes + [HubOAuth]
user = CUnicode().tag(config=True) user = CUnicode().tag(config=True)
group = CUnicode().tag(config=True) group = CUnicode().tag(config=True)
@@ -330,7 +331,7 @@ class SingleUserNotebookApp(NotebookApp):
if not api_token: if not api_token:
self.exit("JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?") self.exit("JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?")
self.hub_auth = HubAuth( self.hub_auth = HubOAuth(
parent=self, parent=self,
api_token=api_token, api_token=api_token,
api_url=self.hub_api_url, api_url=self.hub_api_url,