mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 06:52:59 +00:00
use OAuth in single-user server
This commit is contained in:
@@ -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):
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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):
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user