use OAuth in single-user server

This commit is contained in:
Min RK
2017-03-30 15:23:48 +02:00
parent 453d1daf8b
commit 7e55220c3f
7 changed files with 285 additions and 33 deletions

View File

@@ -45,7 +45,6 @@ class TokenAPIHandler(APIHandler):
self.write(json.dumps({'token': api_token}))
class CookieAPIHandler(APIHandler):
@token_authenticated
def get(self, cookie_name, cookie_value=None):

View File

@@ -1167,7 +1167,7 @@ class JupyterHub(Application):
self.oauth_provider = make_provider(
self.session_factory,
url_prefix=url_path_join(self.hub.server.base_url, 'api/oauth2'),
login_url=self.authenticator.login_url(self.hub.server.base_url)
login_url=self.authenticator.login_url(self.hub.server.base_url),
)
client_store = self.oauth_provider.client_authenticator.client_store

View File

@@ -87,6 +87,10 @@ class BaseHandler(RequestHandler):
def authenticator(self):
return self.settings.get('authenticator', None)
@property
def oauth_provider(self):
return self.settings['oauth_provider']
def finish(self, *args, **kwargs):
"""Roll back any uncommitted transactions from the handler."""
self.db.rollback()

View File

@@ -7,20 +7,24 @@ 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 json
import os
import re
import socket
import time
from urllib.parse import quote
from urllib.parse import quote, urlencode
import warnings
import requests
from tornado.gen import coroutine
from tornado.log import app_log
from tornado.web import HTTPError
from tornado.httputil import url_concat
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.web import HTTPError, RequestHandler
from traitlets.config import Configurable
from traitlets import Unicode, Integer, Instance, default, observe
from traitlets import Unicode, Integer, Instance, default, observe, validate
from ..utils import url_path_join
@@ -103,21 +107,109 @@ class HubAuth(Configurable):
"""
# where is the hub
api_url = Unicode(os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api',
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 http://hub-ip:hub-port/hub/api
"""
).tag(config=True)
host = Unicode('',
help="""The public host of this server.
Only used if working with subdomains.
"""
).tag(config=True)
base_url = Unicode(os.getenv('JUPYTERHUB_SERVICE_PREFIX') or '/',
help="""The base URL prefix of this
added on to host
e.g. /services/service-name/ or /user/name/
Default: get from JUPYTERHUB_SERVICE_PREFIX
"""
)
@validate('base_url')
def _add_slash(self, proposal):
"""Ensure base_url starts and ends with /"""
value = proposal['value']
if not value.startswith('/'):
value = '/' + value
if not value.endswith('/'):
value = value + '/'
return value
login_url = Unicode('/hub/login',
help="""The login URL of the Hub
Typically /hub/login
If using OAuth, will the OAuth redirect URL
including client_id, redirect_uri params.
"""
).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)
api_token = Unicode(os.environ.get('JUPYTERHUB_API_TOKEN', ''),
@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', ''),
help="""API key for accessing Hub API.
Generate with `jupyterhub token [username]` or add to JupyterHub.services config.
@@ -169,12 +261,23 @@ class HubAuth(Configurable):
if cached is not None:
return cached
data = self._api_request('GET', url, allow_404=True)
if data is None:
app_log.warning("No Hub user identified for request")
else:
app_log.debug("Received request from Hub user %s", data)
if use_cache:
# cache result
self.cache[cache_key] = data
return data
def _api_request(self, method, url, **kwargs):
"""Make an API request"""
allow_404 = kwargs.pop('allow_404', False)
headers = kwargs.setdefault('headers', {})
headers.setdefault('Authorization', 'token %s' % self.api_token)
try:
r = requests.get(url,
headers = {
'Authorization' : 'token %s' % self.api_token,
},
)
r = requests.request(method, url, **kwargs)
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()
@@ -184,26 +287,49 @@ class HubAuth(Configurable):
raise HTTPError(500, msg)
data = None
if r.status_code == 404:
app_log.warning("No Hub user identified for request")
if r.status_code == 404 and allow_404:
pass
elif r.status_code == 403:
app_log.error("I don't have permission to check authorization with JupyterHub, my auth token may have expired: [%i] %s", r.status_code, r.reason)
app_log.error(r.text)
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)
app_log.error(r.text)
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)
app_log.warning(r.text)
raise HTTPError(500, "Failed to check authorization")
else:
data = r.json()
app_log.debug("Received request from Hub user %s", data)
if use_cache:
# cache result
self.cache[cache_key] = 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):
"""Ask the Hub to identify the user for a given cookie.
@@ -264,6 +390,24 @@ class HubAuth(Configurable):
user_token = m.group(1)
return user_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.oauth_cookie_name, kwargs)
handler.set_secure_cookie(
self.oauth_cookie_name,
cookie_value,
**kwargs
)
def get_user(self, handler):
"""Get the Hub user for a given tornado handler.
@@ -295,9 +439,14 @@ class HubAuth(Configurable):
# no token, check cookie
if user_model is None:
encrypted_cookie = handler.get_cookie(self.cookie_name)
if encrypted_cookie:
user_model = self.user_for_cookie(encrypted_cookie)
if self.using_oauth:
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
handler._cached_hub_user = user_model
@@ -360,6 +509,7 @@ class HubAuthenticated(object):
def get_login_url(self):
"""Return the Hub's login URL"""
app_log.debug("Redirecting to login url: %s" % self.hub_auth.login_url)
return self.hub_auth.login_url
def check_hub_user(self, model):
@@ -418,3 +568,53 @@ class HubAuthenticated(object):
self._hub_auth_user_cache = self.check_hub_user(user_model)
return self._hub_auth_user_cache
class JupyterHubOAuthCallbackHandler(HubAuthenticated, RequestHandler):
"""OAuth Callback handler"""
@coroutine
def get(self):
code = self.get_argument("code", False)
if not code:
raise HTTPError(400, "oauth callback made without a token")
# TODO: make async (in a Thread?)
token_reply = self.hub_auth.oauth_token_for_code(code)
# TODO: Configure the curl_httpclient for tornado
# 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)

View File

@@ -34,9 +34,10 @@ from notebook.notebookapp import (
)
from notebook.auth.login import LoginHandler
from notebook.auth.logout import LogoutHandler
from notebook.base.handlers import IPythonHandler
from jupyterhub import __version__
from .services.auth import HubAuth, HubAuthenticated
from .services.auth import HubAuth, HubAuthenticated, JupyterHubOAuthCallbackHandler
from .utils import url_path_join
@@ -96,6 +97,25 @@ class JupyterHubLogoutHandler(LogoutHandler):
url_path_join(self.settings['hub_prefix'], 'logout'))
class OAuthCallbackHandler(IPythonHandler):
"""Mixin IPythonHandler to get the right error pages, etc."""
@property
def hub_auth(self):
return self.settings['hub_auth']
def get(self):
code = self.get_argument("code", False)
if not code:
raise HTTPError(400, "oauth callback made without a token")
# TODO: make async (in a Thread?)
token = self.hub_auth.oauth_token_for_code(code)
user_model = self.hub_auth.user_for_token(token)
self.log.info("Logged-in user %s", user_model)
self.hub_auth.set_cookie(self, user_model)
next_url = self.get_argument('next', '') or self.base_url
self.redirect(next_url)
# register new hub related command-line aliases
aliases = dict(notebook_aliases)
aliases.update({
@@ -311,10 +331,11 @@ class SingleUserNotebookApp(NotebookApp):
parent=self,
api_token=api_token,
api_url=self.hub_api_url,
base_url=self.base_url,
)
def init_webapp(self):
# load the hub related settings into the tornado settings dict
# load the hub-related settings into the tornado settings dict
self.init_hub_auth()
s = self.tornado_settings
s['user'] = self.user
@@ -322,9 +343,15 @@ class SingleUserNotebookApp(NotebookApp):
s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
s['hub_auth'] = self.hub_auth
self.hub_auth.login_url = self.hub_host + self.hub_prefix
s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
super(SingleUserNotebookApp, self).init_webapp()
# add OAuth callback
self.web_app.add_handlers(r".*$", [(
urlparse(self.hub_auth.oauth_redirect_uri).path,
OAuthCallbackHandler
)])
self.patch_templates()
def patch_templates(self):

View File

@@ -51,6 +51,8 @@ class Spawner(LoggingConfigurable):
hub = Any()
authenticator = Any()
api_token = Unicode()
oauth_client_id = Unicode()
oauth_client_secret = Unicode()
will_resume = Bool(False,
help="""Whether the Spawner will resume on next start
@@ -391,6 +393,7 @@ class Spawner(LoggingConfigurable):
Subclasses should call super, to ensure that state is properly cleared.
"""
self.api_token = ''
self.oauth_client_secret = ''
def get_env(self):
"""Return the environment dict to use for the Spawner.
@@ -425,6 +428,9 @@ class Spawner(LoggingConfigurable):
env['JUPYTERHUB_API_TOKEN'] = self.api_token
# deprecated (as of 0.7.2), for old versions of singleuser
env['JPY_API_TOKEN'] = self.api_token
# OAuth settings
env['JUPYTERHUB_CLIENT_ID'] = self.oauth_client_id
env['JUPYTERHUB_CLIENT_SECRET'] = self.oauth_client_secret
# Put in limit and guarantee info if they exist.
# Note that this is for use by the humans / notebook extensions in the

View File

@@ -4,12 +4,12 @@
from datetime import datetime, timedelta
from urllib.parse import quote, urlparse
from oauth2.error import ClientNotFoundError
from sqlalchemy import inspect
from tornado import gen
from tornado.log import app_log
from sqlalchemy import inspect
from .utils import url_path_join, default_server_name
from .utils import url_path_join, default_server_name, new_token
from . import orm
from traitlets import HasTraits, Any, Dict, observe, default
@@ -213,7 +213,6 @@ class User(HasTraits):
url of the server will be /user/:name/:server_name
"""
db = self.db
if self.allow_named_servers:
if options is not None and 'server_name' in options:
server_name = options['server_name']
@@ -242,7 +241,24 @@ class User(HasTraits):
spawner.user_options = options or {}
# we are starting a new server, make sure it doesn't restore state
spawner.clear_state()
# create API and OAuth tokens
spawner.api_token = api_token
spawner.oauth_client_id = client_id = 'user-%s-%s' % (self.escaped_name, server_name)
client_store = self.settings['oauth_provider'].client_authenticator.client_store
try:
oauth_client = client_store.fetch_by_client_id(client_id)
except ClientNotFoundError:
oauth_client = None
# create a new OAuth client + secret on every launch,
# except for resuming containers.
if oauth_client is None or not spawner.will_resume:
spawner.oauth_client_secret = client_secret = new_token()
print(server.base_url)
client_store.add_client(client_id, client_secret,
url_path_join(server.base_url, 'oauth_callback'),
)
db.commit()
# trigger pre-spawn hook on authenticator
authenticator = self.authenticator