diff --git a/login/config/globus.py b/login/config/globus.py new file mode 100644 index 0000000..4aa65c1 --- /dev/null +++ b/login/config/globus.py @@ -0,0 +1,314 @@ +""" +Custom Authenticator to use Globus OAuth2 with JupyterHub +""" +import os, sys, subprocess, re +import pickle +import base64 + +from tornado import gen, web +from tornado.auth import OAuth2Mixin +from tornado.web import HTTPError + +from traitlets import List, Unicode, Bool, Instance, Any +from jupyterhub.handlers import LogoutHandler +from jupyterhub.auth import LocalAuthenticator +from jupyterhub.utils import url_path_join + +from .oauth2 import OAuthLoginHandler, OAuthenticator + + +try: + import globus_sdk +except: + raise ImportError('globus_sdk is not installed, please see ' + '"globus-requirements.txt" for using Globus oauth.') + + +class GlobusMixin(OAuth2Mixin): + _OAUTH_AUTHORIZE_URL = 'https://auth.globus.org/v2/oauth2/authorize' + + +class GlobusLoginHandler(OAuthLoginHandler, GlobusMixin): + pass + + +class GlobusLogoutHandler(LogoutHandler): + """ + Handle custom logout URLs and token revocation. If a custom logout url + is specified, the 'logout' button will log the user out of that identity + provider in addition to clearing the session with Jupyterhub, otherwise + only the Jupyterhub session is cleared. + """ + @gen.coroutine + def get(self): + user = self.get_current_user() + if user: + if self.authenticator.revoke_tokens_on_logout: + self.clear_tokens(user) + self.clear_login_cookie() + if self.authenticator.logout_redirect_url: + self.redirect(self.authenticator.logout_redirect_url) + else: + super().get() + + @gen.coroutine + def clear_tokens(self, user): + if not self.authenticator.revoke_tokens_on_logout: + return + + state = yield user.get_auth_state() + if state: + self.authenticator.revoke_service_tokens(state.get('tokens')) + self.log.info('Logout: Revoked tokens for user "{}" services: {}' + .format(user.name, ','.join(state['tokens'].keys()))) + state['tokens'] = '' + user.save_auth_state(state) + + +class GlobusOAuthenticator(OAuthenticator): + """The Globus OAuthenticator handles both authorization and passing + transfer tokens to the spawner. """ + + + login_service = 'Globus' + login_handler = GlobusLoginHandler + logout_handler = GlobusLogoutHandler + + identity_provider = Unicode(help="""Restrict which institution a user + can use to login (GlobusID, University of Hogwarts, etc.). This should + be set in the app at developers.globus.org, but this acts as an additional + check to prevent unnecessary account creation.""").tag(config=True) + + def _identity_provider_default(self): + return os.getenv('IDENTITY_PROVIDER', 'globusid.org') + + exclude_tokens = List( + help="""Exclude tokens from being passed into user environments + when they start notebooks, Terminals, etc.""" + ).tag(config=True) + + def _exclude_tokens_default(self): + return ['auth.globus.org'] + + def _scope_default(self): + return [ + 'openid', + 'profile', + 'email' + ] + + allow_refresh_tokens = Bool( + help="""Allow users to have Refresh Tokens. If Refresh Tokens are not + allowed, users must use regular Access Tokens which will expire after + a set time. Set to False for increased security, True for increased + convenience.""" + ).tag(config=True) + + def _allow_refresh_tokens_default(self): + return True + + globus_local_endpoint = Unicode(help="""If Jupyterhub is also a Globus + endpoint, its endpoint id can be specified here.""").tag(config=True) + + def _globus_local_endpoint_default(self): + return os.getenv('GLOBUS_LOCAL_ENDPOINT', '') + + logout_redirect_url = \ + Unicode(help="""URL for logging out.""").tag(config=True) + + def _logout_redirect_url_default(self): + return os.getenv('LOGOUT_REDIRECT_URL', '') + + revoke_tokens_on_logout = Bool( + help="""Revoke tokens so they cannot be used again. Single-user servers + MUST be restarted after logout in order to get a fresh working set of + tokens.""" + ).tag(config=True) + + def _revoke_tokens_on_logout_default(self): + return True + + @gen.coroutine + def pre_spawn_start(self, user, spawner): + """Add tokens to the spawner whenever the spawner starts a notebook. + This will allow users to create a transfer client: + globus-sdk-python.readthedocs.io/en/stable/tutorial/#tutorial-step4 + """ + spawner.environment['GLOBUS_LOCAL_ENDPOINT'] = \ + self.globus_local_endpoint + state = yield user.get_auth_state() + if state: + globus_data = base64.b64encode( + pickle.dumps(state) + ) + spawner.environment['GLOBUS_DATA'] = globus_data.decode('utf-8') + + + globus_authorizer = Any( + help="""Define which authorizer to use in order to generate client. + For ex: RefreshTokenAuth for when using VC3 client specific.""" + ).tag(config=True) + + def _globus_authorizer_default(self): + return globus_sdk.RefreshTokenAuthorizer("TEST", self.globus_portal_client()) + + def globus_portal_client(self): + return globus_sdk.ConfidentialAppAuthClient( + self.client_id, + self.client_secret) + + + def getUsername(self, sub): + cmd = 'vc3-client user-list | grep ' + sub + proc = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True) + out, err = proc.communicate() + + if (out == ""): + print("Invalid user", file=sys.stderr) + else: + out = out.decode('utf-8') + username = re.search('\sname=([^\s]+)', out).group(1) + return username + + def getRefreshToken(self, username): + cmd = 'vc3-client user-list | grep ' + username + proc = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True) + out, err = proc.communicate() + + if (out == ""): + print("Invalid user", file=sys.stderr) + else: + out = out.decode('utf-8') + refresh_token = re.search('\surl=([^\s]+)', out).group(1) + return refresh_token + + def isUserInProject(self, user, project): + + cmd = 'vc3-client project-list | grep ' + project + proc = subprocess.Popen([cmd], stdout=subprocess.PIPE, shell=True) + out, err = proc.communicate() + + if (out == ""): + print("Invalid project", file=sys.stderr) + else: + out = out.decode('utf-8') + members = re.search('\smembers=([^\s]+)', out).group(1) + + if user in members: + return True + + return False + + + @gen.coroutine + def authenticate(self, handler, data=None): + """ + Authenticate with globus.org. Usernames (and therefore Jupyterhub + accounts) will correspond to a Globus User ID, so foouser@globusid.org + will have the 'foouser' account in Jupyterhub. + """ + + vc3_token = False + + if (vc3_token): + + internal_auth_client = self.globus_portal_client() + + REFRESH_TOKEN = self.getRefreshToken("jezhou8") # TODO : figure out how to find which user's token to use + refresh_authorizer = self.globus_authorizer(REFRESH_TOKEN, internal_auth_client) + + client = globus_sdk.AuthClient(authorizer=refresh_authorizer) + + info = client.oauth2_userinfo() + sub = info['sub'] + + username = self.getUsername(sub) + + if self.isUserInProject(username, "vc3"): # TODO : Change project to unix group + return username + else: + raise HTTPError( + 403, + 'This site is restricted to accounts that belong to VC3 project' + ) + + else: + + code = handler.get_argument("code") + redirect_uri = self.get_callback_url(self) + + client = self.globus_portal_client() + client.oauth2_start_flow( + redirect_uri, + requested_scopes=' '.join(self.scope), + refresh_tokens=True + ) + + self.log.info("request refresh token %s" % self.allow_refresh_tokens) + + # Doing the code for token for id_token exchange + tokens = client.oauth2_exchange_code_for_tokens(code) + id_token = tokens.decode_id_token(client) + # It's possible for identity provider domains to be namespaced + # https://docs.globus.org/api/auth/specification/#identity_provider_namespaces # noqa + username, domain = id_token.get('preferred_username').split('@', 1) + + print("\n tokens: {}".format(str(tokens)), file=sys.stdout) + + if self.identity_provider and domain != self.identity_provider: + raise HTTPError( + 403, + 'This site is restricted to {} accounts. Please link your {}' + ' account at {}.'.format( + self.identity_provider, + self.identity_provider, + 'globus.org/app/account' + ) + ) + return { + 'name': username, + 'auth_state': { + 'client_id': self.client_id, + 'tokens': { + tok: v for tok, v in tokens.by_resource_server.items() + if tok not in self.exclude_tokens + }, + } + } + + def revoke_service_tokens(self, services): + """Revoke live Globus access and refresh tokens. Revoking inert or + non-existent tokens does nothing. Services are defined by dicts + returned by tokens.by_resource_server, for example: + services = { 'transfer.api.globus.org': {'access_token': 'token'}, ... + ... + } + """ + client = self.globus_portal_client() + for service_data in services.values(): + client.oauth2_revoke_token(service_data['access_token']) + client.oauth2_revoke_token(service_data['refresh_token']) + + def get_callback_url(self, handler=None): + """ + Getting the configured callback url + """ + if self.oauth_callback_url is None: + raise HTTPError(500, + 'No callback url provided. ' + 'Please configure by adding ' + 'c.GlobusOAuthenticator.oauth_callback_url ' + 'to the config' + ) + return self.oauth_callback_url + + def logout_url(self, base_url): + return url_path_join(base_url, 'logout') + + def get_handlers(self, app): + return super().get_handlers(app) + [(r'/logout', self.logout_handler)] + + +class LocalGlobusOAuthenticator(LocalAuthenticator, GlobusOAuthenticator): + """A version that mixes in local system user creation""" + pass \ No newline at end of file diff --git a/login/config/jupyterhub.service b/login/config/jupyterhub.service index 6ebb0af..6079511 100644 --- a/login/config/jupyterhub.service +++ b/login/config/jupyterhub.service @@ -4,7 +4,7 @@ Description=Jupyter Notebook [Service] Type=simple PIDFile=/run/jupyterhub.pid -ExecStart=/bin/bash -c '/usr/bin/jupyterhub --ip "$(curl -s https://api.ipify.org)" --port 8080 -f "/etc/.jupyterhub/jupyterhub_config.py"' +ExecStart=/bin/bash -c '/usr/bin/jupyterhub --ip "$(curl -s https://api.ipify.org)" --port 8080 -f "/etc/.jupyterhub/jupyterhub_config.py" --ssl-cert /etc/.jupyterhub/jupyterhub.cert --ssl-key /etc/.jupyterhub/jupyterhub.key' User=root WorkingDirectory=/home/ Restart=always diff --git a/login/config/jupyterhub_config.py b/login/config/jupyterhub_config.py index 9f2a328..59e9673 100644 --- a/login/config/jupyterhub_config.py +++ b/login/config/jupyterhub_config.py @@ -1,4 +1,24 @@ -# Configuration for JupyterHub +from requests_oauthlib import OAuth2Session +from tornado import gen +from jupyterhub.auth import Authenticator +from globus_sdk import ConfidentialAppAuthClient +from jupyterhub.handlers import LogoutHandler +from traitlets import List, Unicode, Bool -c.JupyterHub.ssl_cert = '/etc/.jupyterhub/jupyterhub.crt' -c.JupyterHub.ssl_key = '/etc/.jupyterhub/jupyterhub.key' \ No newline at end of file +import json +import sys, subprocess +import re +import globus_sdk + +from oauthenticator.globus import LocalGlobusOAuthenticator +c.JupyterHub.authenticator_class = LocalGlobusOAuthenticator +c.LocalGlobusOAuthenticator.enable_auth_state = True +c.LocalGlobusOAuthenticator.oauth_callback_url = 'https://128.135.158.176:8080/hub/oauth_callback' +c.LocalGlobusOAuthenticator.client_id = "d1b57f33-a45c-46e1-9a0c-f5420940dacf" +c.LocalGlobusOAuthenticator.client_secret = "YfcI+zz7YamlUI7Rjgh/WnM9ygaa1RTUGJZbkpWw3JI=" + +c.LocalGlobusOAuthenticator.create_system_users = True +c.LocalGlobusOAuthenticator.add_user_cmd = ['adduser', '-m', '-c', '""'] + +c.LocalGlobusOAuthenticator.globus_authorizer = globus_sdk.RefreshTokenAuthorizer +c.JupyterHub.extra_log_file = '/var/log/jupyterhub.log' \ No newline at end of file diff --git a/login/config/login.html b/login/config/login.html new file mode 100644 index 0000000..3772531 --- /dev/null +++ b/login/config/login.html @@ -0,0 +1,101 @@ +{% extends "page.html" %} +{% if announcement_login %} + {% set announcement = announcement_login %} +{% endif %} + +{% block login_widget %} +{% endblock %} + +{% block main %} +{% block login %} +
+{% if custom_html %} +{{ custom_html | safe }} +{% elif login_service %} + +{% if login_service == 'VC3' %} + + + + +

Logging you in...

+
+ +
+ +
+{% else %} + +{% endif %} +{% endif %} +
+{% endblock login %} + +{% endblock %} + +{% block script %} +{{ super() }} + + +{% endblock %} \ No newline at end of file