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