Permalink
Please sign in to comment.
Showing
with
1,661 additions
and 0 deletions.
- +11 −0 bower.json
- +5 −0 everware/__init__.py
- +299 −0 everware/authenticator.py
- +37 −0 everware/homehandler.py
- +110 −0 everware/spawner.py
- +16 −0 package.json
- +217 −0 setup.py
- +23 −0 share/static/html/error.html
- +48 −0 share/static/html/home.html
- +96 −0 share/static/html/login.html
- +120 −0 share/static/html/page.html
- +28 −0 share/static/html/spawn_pending.html
- BIN share/static/images/jupyter.png
- BIN share/static/images/jupyterhub-80.png
- +197 −0 share/static/js/admin.js
- +19 −0 share/static/js/home.js
- +133 −0 share/static/js/jhapi.js
- +137 −0 share/static/js/utils.js
- +3 −0 share/static/less/admin.less
- +21 −0 share/static/less/error.less
- +77 −0 share/static/less/login.less
- +26 −0 share/static/less/page.less
- +27 −0 share/static/less/style.less
- +11 −0 share/static/less/variables.less
11
bower.json
| @@ -0,0 +1,11 @@ | |||
| +{ | |||
| + "name": "jupyterhub-deps", | |||
| + "version": "0.0.0", | |||
| + "dependencies": { | |||
| + "bootstrap": "components/bootstrap#~3.1", | |||
| + "font-awesome": "components/font-awesome#~4.1", | |||
| + "jquery": "components/jquery#~2.0", | |||
| + "moment": "~2.7", | |||
| + "requirejs": "~2.1" | |||
| + } | |||
| +} | |||
| @@ -0,0 +1,5 @@ | |||
| + | |||
| +from .spawner import * | |||
| +from .authenticator import * | |||
| +from .homehandler import * | |||
| + | |||
| @@ -0,0 +1,299 @@ | |||
| +""" | |||
| +Custom Authenticator to use GitHub OAuth with JupyterHub | |||
| + | |||
| +Most of the code c/o Kyle Kelley (@rgbkrk) | |||
| +""" | |||
| + | |||
| + | |||
| +import json | |||
| +import os | |||
| +import urllib | |||
| + | |||
| +from tornado.auth import OAuth2Mixin | |||
| +from tornado.escape import url_escape | |||
| +from tornado import gen, web | |||
| + | |||
| +from tornado.httputil import url_concat | |||
| +from tornado.httpclient import HTTPRequest, AsyncHTTPClient | |||
| + | |||
| +from jupyterhub.handlers import BaseHandler | |||
| +from jupyterhub.auth import Authenticator, LocalAuthenticator | |||
| +from jupyterhub.utils import url_path_join | |||
| + | |||
| +from traitlets import Unicode, Set | |||
| + | |||
| + | |||
| +class GitHubMixin(OAuth2Mixin): | |||
| + _OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" | |||
| + _OAUTH_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" | |||
| + | |||
| + | |||
| +class BitbucketMixin(OAuth2Mixin): | |||
| + _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" | |||
| + _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" | |||
| + | |||
| + | |||
| +class WelcomeHandler(BaseHandler): | |||
| + """Render the login page.""" | |||
| + | |||
| + def _render(self, login_error=None, username=None): | |||
| + return self.render_template('login.html', | |||
| + next=url_escape(self.get_argument('next', default='')), | |||
| + username=username, | |||
| + login_error=login_error, | |||
| + ) | |||
| + | |||
| + def get(self): | |||
| + next_url = self.get_argument('next', '') | |||
| + if not next_url.startswith('/'): | |||
| + # disallow non-absolute next URLs (e.g. full URLs) | |||
| + next_url = '' | |||
| + user = self.get_current_user() | |||
| + if user: | |||
| + if not next_url: | |||
| + if user.running: | |||
| + next_url = user.server.base_url | |||
| + else: | |||
| + next_url = self.hub.server.base_url | |||
| + # set new login cookie | |||
| + # because single-user cookie may have been cleared or incorrect | |||
| + #self.set_login_cookie(self.get_current_user()) | |||
| + self.redirect('/oauth_login', permanent=False) | |||
| + else: | |||
| + self.finish(self._render()) | |||
| + | |||
| +class OAuthLoginHandler(BaseHandler): | |||
| + | |||
| + def get(self): | |||
| + guess_uri = '{proto}://{host}{path}'.format( | |||
| + proto=self.request.protocol, | |||
| + host=self.request.host, | |||
| + path=url_path_join( | |||
| + self.hub.server.base_url, | |||
| + 'oauth_callback' | |||
| + ) | |||
| + ) | |||
| + | |||
| + redirect_uri = self.authenticator.oauth_callback_url or guess_uri | |||
| + self.log.info('oauth redirect: %r', redirect_uri) | |||
| + | |||
| + self.authorize_redirect( | |||
| + redirect_uri=redirect_uri, | |||
| + client_id=self.authenticator.client_id, | |||
| + scope=[], | |||
| + response_type='code') | |||
| + | |||
| + | |||
| +class GitHubLoginHandler(OAuthLoginHandler, GitHubMixin): | |||
| + pass | |||
| + | |||
| + | |||
| +class BitbucketLoginHandler(OAuthLoginHandler, BitbucketMixin): | |||
| + pass | |||
| + | |||
| + | |||
| +class GitHubOAuthHandler(BaseHandler): | |||
| + @gen.coroutine | |||
| + def get(self): | |||
| + # TODO: Check if state argument needs to be checked | |||
| + username = yield self.authenticator.authenticate(self) | |||
| + if username: | |||
| + user = self.user_from_username(username) | |||
| + self.set_login_cookie(user) | |||
| + self.redirect(self.hub.server.base_url) | |||
| + else: | |||
| + # todo: custom error page? | |||
| + raise web.HTTPError(403) | |||
| + | |||
| + | |||
| +class BitbucketOAuthHandler(GitHubOAuthHandler): | |||
| + pass | |||
| + | |||
| + | |||
| +class GitHubOAuthenticator(Authenticator): | |||
| + | |||
| + login_service = "GitHub" | |||
| + oauth_callback_url = Unicode('', config=True) | |||
| + client_id = Unicode(os.environ.get('GITHUB_CLIENT_ID', ''), | |||
| + config=True) | |||
| + client_secret = Unicode(os.environ.get('GITHUB_CLIENT_SECRET', ''), | |||
| + config=True) | |||
| + | |||
| + def login_url(self, base_url): | |||
| + return url_path_join(base_url, 'login') | |||
| + | |||
| + def get_handlers(self, app): | |||
| + return [ | |||
| + (r'/login', WelcomeHandler), | |||
| + (r'/oauth_login', GitHubLoginHandler), | |||
| + (r'/oauth_callback', GitHubOAuthHandler), | |||
| + ] | |||
| + | |||
| + @gen.coroutine | |||
| + def authenticate(self, handler): | |||
| + code = handler.get_argument("code", False) | |||
| + if not code: | |||
| + raise web.HTTPError(400, "oauth callback made without a token") | |||
| + # TODO: Configure the curl_httpclient for tornado | |||
| + http_client = AsyncHTTPClient() | |||
| + | |||
| + # 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.client_id, | |||
| + client_secret=self.client_secret, | |||
| + code=code | |||
| + ) | |||
| + | |||
| + url = url_concat("https://github.com/login/oauth/access_token", | |||
| + params) | |||
| + | |||
| + req = HTTPRequest(url, | |||
| + method="POST", | |||
| + headers={"Accept": "application/json"}, | |||
| + body='' # Body is required for a POST... | |||
| + ) | |||
| + | |||
| + 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={"Accept": "application/json", | |||
| + "User-Agent": "JupyterHub", | |||
| + "Authorization": "token {}".format(access_token) | |||
| + } | |||
| + req = HTTPRequest("https://api.github.com/user", | |||
| + method="GET", | |||
| + headers=headers | |||
| + ) | |||
| + resp = yield http_client.fetch(req) | |||
| + resp_json = json.loads(resp.body.decode('utf8', 'replace')) | |||
| + | |||
| + username = resp_json["login"] | |||
| + if self.whitelist and username not in self.whitelist: | |||
| + username = None | |||
| + raise gen.Return(username) | |||
| + | |||
| + | |||
| +class BitbucketOAuthenticator(Authenticator): | |||
| + | |||
| + login_service = "Bitbucket" | |||
| + oauth_callback_url = Unicode(os.environ.get('OAUTH_CALLBACK_URL', ''), | |||
| + config=True) | |||
| + client_id = Unicode(os.environ.get('BITBUCKET_CLIENT_ID', ''), | |||
| + config=True) | |||
| + client_secret = Unicode(os.environ.get('BITBUCKET_CLIENT_SECRET', ''), | |||
| + config=True) | |||
| + team_whitelist = Set( | |||
| + config=True, | |||
| + help="Automatically whitelist members of selected teams", | |||
| + ) | |||
| + | |||
| + def login_url(self, base_url): | |||
| + return url_path_join(base_url, 'oauth_login') | |||
| + | |||
| + def get_handlers(self, app): | |||
| + return [ | |||
| + (r'/oauth_login', BitbucketLoginHandler), | |||
| + (r'/oauth_callback', BitbucketOAuthHandler), | |||
| + ] | |||
| + | |||
| + @gen.coroutine | |||
| + def authenticate(self, handler): | |||
| + code = handler.get_argument("code", False) | |||
| + if not code: | |||
| + raise web.HTTPError(400, "oauth callback made without a token") | |||
| + # TODO: Configure the curl_httpclient for tornado | |||
| + http_client = AsyncHTTPClient() | |||
| + | |||
| + params = dict( | |||
| + client_id=self.client_id, | |||
| + client_secret=self.client_secret, | |||
| + grant_type="authorization_code", | |||
| + code=code, | |||
| + redirect_uri=self.oauth_callback_url | |||
| + ) | |||
| + | |||
| + url = url_concat( | |||
| + "https://bitbucket.org/site/oauth2/access_token", params) | |||
| + self.log.info(url) | |||
| + | |||
| + bb_header = {"Content-Type": | |||
| + "application/x-www-form-urlencoded;charset=utf-8"} | |||
| + req = HTTPRequest(url, | |||
| + method="POST", | |||
| + auth_username=self.client_id, | |||
| + auth_password=self.client_secret, | |||
| + body=urllib.parse.urlencode(params).encode('utf-8'), | |||
| + headers=bb_header | |||
| + ) | |||
| + | |||
| + 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 = {"Accept": "application/json", | |||
| + "User-Agent": "JupyterHub", | |||
| + "Authorization": "Bearer {}".format(access_token) | |||
| + } | |||
| + req = HTTPRequest("https://api.bitbucket.org/2.0/user", | |||
| + method="GET", | |||
| + headers=headers | |||
| + ) | |||
| + resp = yield http_client.fetch(req) | |||
| + resp_json = json.loads(resp.body.decode('utf8', 'replace')) | |||
| + | |||
| + username = resp_json["username"] | |||
| + whitelisted = yield self.check_whitelist(username, headers) | |||
| + if not whitelisted: | |||
| + username = None | |||
| + return username | |||
| + | |||
| + def check_whitelist(self, username, headers): | |||
| + if self.team_whitelist: | |||
| + return self._check_group_whitelist(username, headers) | |||
| + else: | |||
| + return self._check_user_whitelist(username) | |||
| + | |||
| + @gen.coroutine | |||
| + def _check_user_whitelist(self, user): | |||
| + return (not self.whitelist) or (user in self.whitelist) | |||
| + | |||
| + @gen.coroutine | |||
| + def _check_group_whitelist(self, username, headers): | |||
| + http_client = AsyncHTTPClient() | |||
| + | |||
| + # We verify the team membership by calling teams endpoint. | |||
| + # Re-use the headers, change the request. | |||
| + next_page = url_concat("https://api.bitbucket.org/2.0/teams", | |||
| + {'role': 'member'}) | |||
| + user_teams = set() | |||
| + while next_page: | |||
| + req = HTTPRequest(next_page, method="GET", headers=headers) | |||
| + resp = yield http_client.fetch(req) | |||
| + resp_json = json.loads(resp.body.decode('utf8', 'replace')) | |||
| + next_page = resp_json.get('next', None) | |||
| + | |||
| + user_teams |= \ | |||
| + set([entry["username"] for entry in resp_json["values"]]) | |||
| + return len(self.team_whitelist & user_teams) > 0 | |||
| + | |||
| + | |||
| +class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator): | |||
| + | |||
| + """A version that mixes in local system user creation""" | |||
| + pass | |||
| + | |||
| + | |||
| +class LocalBitbucketOAuthenticator(LocalAuthenticator, | |||
| + BitbucketOAuthenticator): | |||
| + | |||
| + """A version that mixes in local system user creation""" | |||
| + pass | |||
| @@ -0,0 +1,37 @@ | |||
| + | |||
| +from tornado import gen | |||
| +from jupyterhub.handlers.pages import web, BaseHandler | |||
| + | |||
| +class HomeHandler(BaseHandler): | |||
| + """Render the user's home page.""" | |||
| + | |||
| + @web.authenticated | |||
| + def get(self): | |||
| + html = self.render_template('home.html', | |||
| + user=self.get_current_user(), | |||
| + ) | |||
| + self.finish(html) | |||
| + | |||
| + @gen.coroutine | |||
| + def post(self): | |||
| + data = {} | |||
| + for arg in self.request.arguments: | |||
| + data[arg] = self.get_argument(arg) | |||
| + | |||
| + repo_url = data['repourl'] | |||
| + user = self.get_current_user() | |||
| + user.last_repo_url = repo_url | |||
| + | |||
| + already_running = False | |||
| + if user.spawner: | |||
| + status = yield user.spawner.poll() | |||
| + already_running = (status == None) | |||
| + if not already_running: | |||
| + yield self.spawn_single_user(user) | |||
| + | |||
| + user.last_repo_url = repo_url | |||
| + self.db.commit() | |||
| + | |||
| + self.redirect(self.hub.server.base_url) | |||
| + | |||
| + | |||
Oops, something went wrong.
0 comments on commit
7c6101a