Permalink
Browse files

Add initial code

  • Loading branch information...
1 parent 07c9084 commit 7c6101a05827ed9855eeaf9ad81cf5c8e64e944a @ibab ibab committed Aug 17, 2015
View
@@ -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"
+ }
+}
View
@@ -0,0 +1,5 @@
+
+from .spawner import *
+from .authenticator import *
+from .homehandler import *
+
View
@@ -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
View
@@ -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

Please sign in to comment.