├── gsiauthenticator ├── __init__.py ├── auth.py └── myproxy_login.py ├── requirements.txt ├── .gitignore ├── version.py ├── README.md ├── jupyterhub_config.py ├── LICENSE └── setup.py /gsiauthenticator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jupyterhub 2 | pyopenssl 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | *.pyc 3 | *.egg-info 4 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | version_info = ( 5 | 0, 6 | 0, 7 | 3, 8 | ) 9 | __version__ = '.'.join(map(str, version_info[:3])) 10 | 11 | if len(version_info) > 3: 12 | __version__ = '%s-%s' % (__version__, version_info[3]) 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GSI Authenticator 2 | 3 | Enables GSI Authentication for Jupyterhub. Acquires an X509 certificate from a myproxy service. 4 | 5 | Use with [SSH Spawner](https://github.com/NERSC/SSHSpawner) to authenticate to remote host with GSISSH 6 | 7 | ## Installation 8 | 9 | Requires Python 3 10 | 11 | ``` 12 | python setup.py install 13 | ``` 14 | 15 | ## Configuration 16 | 17 | See [jupyterhub_config.py](jupyterhub_config.py) for a sample configuration 18 | -------------------------------------------------------------------------------- /jupyterhub_config.py: -------------------------------------------------------------------------------- 1 | # Sample Configuration 2 | c.JupyterHub.authenticator_class = 'gsiauthenticator.auth.GSIAuthenticator' 3 | c.GSIAuthenticator.server = 'nerscca2.nersc.gov' 4 | c.JupyterHub.cookie_max_age_days = 0.5 5 | 6 | # Port for MyProxy Server 7 | # c.GSIAuthenticator.port = 7512 8 | 9 | # Lifetime of Certificate in seconds. This should match the equivalent 10 | # value in `c.JupyterHub.cookie_max_age_days` eg. 0.5 days => 43200 seconds 11 | # c.GSIAuthenticator.proxy_lifetime = 43200 12 | 13 | # Prefix for writing out cert files. FIle will be 14 | # c.GSIAuthenticator.cert_path_prefix = '/tmp/x509_' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, The Regents of the University of California, 2 | through Lawrence Berkeley National Laboratory. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the University of California, Lawrence Berkeley National 15 | Laboratory, U.S. Dept. of Energy nor the names of its contributors may be 16 | used to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | # 4 | # Copyright (c) Juptyer Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | # 7 | # ---------------------------------------------------------------------------- 8 | # Minimal Python version sanity check (from IPython/Jupyterhub) 9 | # ---------------------------------------------------------------------------- 10 | from __future__ import print_function 11 | 12 | import os 13 | import sys 14 | 15 | from distutils.core import setup 16 | 17 | pjoin = os.path.join 18 | here = os.path.abspath(os.path.dirname(__file__)) 19 | 20 | # Get the current package version. 21 | version_ns = {} 22 | with open(pjoin(here, 'version.py')) as f: 23 | exec(f.read(), {}, version_ns) 24 | 25 | setup_args = dict( 26 | name='gsiauthenticator', 27 | packages=['gsiauthenticator'], 28 | version=version_ns['__version__'], 29 | description="""GSI Authenticator: A custom authenticator for Jupyterhub 30 | to login to a MyProxy server 31 | """, 32 | long_description="""GSI Authenticator: A custom authenticator for 33 | Jupyterhub to login to a MyProxy server and fetch an x509 34 | certificate. Use with SSH Spawner in GSI mode.""", 35 | author="Shreyas Cholia, Shane Canon, Rollin Thomas", 36 | author_email="scholia@lbl.gov, scanon@lbl.gov, rcthomas@lbl.gov", 37 | url="http://www.nersc.gov", 38 | license="BSD", 39 | platforms="Linux, Mac OS X", 40 | keywords=['Interactive', 'Interpreter', 'Shell', 'Web'], 41 | classifiers=[ 42 | 'Intended Audience :: Developers', 43 | 'Intended Audience :: System Administrators', 44 | 'Intended Audience :: Science/Research', 45 | 'License :: OSI Approved :: BSD License', 46 | 'Programming Language :: Python', 47 | 'Programming Language :: Python :: 3', 48 | ], 49 | ) 50 | 51 | # setuptools requirements 52 | if 'setuptools' in sys.modules: 53 | setup_args['install_requires'] = install_requires = [] 54 | with open('requirements.txt') as f: 55 | for line in f.readlines(): 56 | req = line.strip() 57 | if not req or req.startswith(('-e', '#')): 58 | continue 59 | install_requires.append(req) 60 | 61 | 62 | def main(): 63 | setup(**setup_args) 64 | 65 | if __name__ == '__main__': 66 | main() 67 | -------------------------------------------------------------------------------- /gsiauthenticator/auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from traitlets import Unicode, Integer 4 | from tornado import gen 5 | 6 | from jupyterhub.auth import Authenticator 7 | 8 | from gsiauthenticator.myproxy_login import myproxy_logon_py 9 | 10 | 11 | class GSIAuthenticator(Authenticator): 12 | """Authenticate local Linux/UNIX users with GSI""" 13 | encoding = Unicode('utf8', 14 | help="""The encoding to use for GSI""" 15 | ).tag(config=True) 16 | server = Unicode('localhost', 17 | help="""The MyProxy server to use for authentication.""" 18 | ).tag(config=True) 19 | 20 | port = Integer(7512, 21 | help="""The MyProxy port to use""" 22 | ).tag(config=True) 23 | 24 | cert_path_prefix = Unicode('/tmp/x509_', 25 | help="""The path prefix for the cert/key file""" 26 | ).tag(config=True) 27 | 28 | proxy_lifetime = Integer(43200, 29 | help="""Lifetime (seconds) of the X509 Proxy""" 30 | ).tag(config=True) 31 | 32 | @gen.coroutine 33 | def authenticate(self, handler, data): 34 | """Authenticate with GSI, and return the proxy certificate 35 | if login is successful. 36 | 37 | Return None otherwise. 38 | """ 39 | username = data['username'] 40 | try: 41 | resp = myproxy_logon_py(self.server, 42 | username, 43 | data['password'], 44 | lifetime=self.proxy_lifetime, 45 | port=self.port) 46 | # print(resp) 47 | if 'key' in resp: 48 | file = '%s%s' % (self.cert_path_prefix, username) 49 | with open(file, 'bw') as f: 50 | os.chmod(file, 0o600) 51 | f.write(resp['key']) 52 | f.write(resp['cert']) 53 | except: 54 | if handler is not None: 55 | self.log.warning("GSI Authentication failed (%s@%s):", 56 | username, handler.request.remote_ip) 57 | else: 58 | self.log.warning("GSI Authentication failed: ") 59 | return None 60 | else: 61 | return username 62 | -------------------------------------------------------------------------------- /gsiauthenticator/myproxy_login.py: -------------------------------------------------------------------------------- 1 | # $Id: myproxy_logon.py 672 2011-02-03 00:05:03Z shreyas $ 2 | # 3 | # myproxy client 4 | # 5 | # Tom Uram 6 | # 2005/08/04 7 | # 8 | 9 | import logging as _logging 10 | import os 11 | import socket 12 | import re 13 | from OpenSSL import crypto, SSL 14 | 15 | class GetException(Exception): pass 16 | class RetrieveProxyException(Exception): pass 17 | 18 | logger = _logging.getLogger(__name__) 19 | 20 | gsi_keys=dict() 21 | 22 | def debuglevel(level): 23 | return 0 24 | 25 | 26 | def create_cert_req(keyType = crypto.TYPE_RSA, 27 | bits = 1024, 28 | messageDigest = "md5"): 29 | """ 30 | Create certificate request. 31 | 32 | Returns: certificate request PEM text, private key PEM text 33 | """ 34 | 35 | # Create certificate request 36 | req = crypto.X509Req() 37 | 38 | # Generate private key 39 | pkey = crypto.PKey() 40 | pkey.generate_key(keyType, bits) 41 | 42 | req.set_pubkey(pkey) 43 | req.sign(pkey, messageDigest) 44 | 45 | cert_req_pem = crypto.dump_certificate_request(crypto.FILETYPE_ASN1,req) 46 | key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM,pkey) 47 | 48 | # Nasty OpenSSL 1.0 Hack 49 | # OpenSSL 1.0 changes the headers from "RSA PRIVATE KEY" to "PRIVATE KEY" 50 | try: 51 | ssl_version = SSL.SSLeay_version(SSL.SSLEAY_VERSION) 52 | logger.debug('Using SSL: ' + ssl_version) 53 | if ssl_version.startswith("OpenSSL 1"): 54 | key_pem = re.sub(r'BEGIN PRIVATE KEY', r'BEGIN RSA PRIVATE KEY', key_pem) 55 | key_pem = re.sub(r'END PRIVATE KEY', r'END RSA PRIVATE KEY', key_pem) 56 | except: 57 | logger.warn('Using older version of openSSL without SSLeay_version') 58 | 59 | return (cert_req_pem, key_pem) 60 | 61 | def deserialize_response(msg): 62 | """ 63 | Deserialize a MyProxy server response 64 | 65 | Returns: integer response, errortext (if any) 66 | """ 67 | 68 | lines = msg.decode("utf-8").split('\n') 69 | # get response value 70 | resp=dict() 71 | for line in lines: 72 | try: 73 | (k,v)=line.split('=') 74 | resp[k]=v 75 | except: 76 | pass 77 | #responselines = filter( lambda x: x.startswith('RESPONSE'), lines) 78 | #responseline = responselines[0] 79 | #response = int(responseline.split('=')[1]) 80 | if resp['RESPONSE']=='0': 81 | response=0 82 | else: 83 | response=1 84 | #response=int(resp['RESPONSE']) 85 | 86 | # get error text 87 | errortext="" 88 | if 'ERROR' in resp: 89 | errortext = resp['ERROR'] 90 | #errorlines = filter( lambda x: x.startswith('ERROR'), lines) 91 | #for e in errorlines: 92 | # etext = e.split('=')[1] 93 | # errortext += etext 94 | 95 | return response,errortext 96 | 97 | 98 | def deserialize_certs(inp_dat): 99 | 100 | pem_certs = [] 101 | 102 | dat = inp_dat 103 | import base64 104 | while dat: 105 | 106 | # find start of cert, get length 107 | ind = dat.find(b'\x30\x82') 108 | if ind < 0: 109 | break 110 | 111 | #len = 256*ord(dat[ind+2]) + ord(dat[ind+3]) 112 | len = 256*dat[ind+2] + dat[ind+3] 113 | 114 | # extract der-format cert, and convert to pem 115 | c = dat[ind:ind+len+4] 116 | x509 = crypto.load_certificate(crypto.FILETYPE_ASN1,c) 117 | pem_cert = crypto.dump_certificate(crypto.FILETYPE_PEM,x509) 118 | pem_certs.append(pem_cert) 119 | 120 | # trim cert from data 121 | dat = dat[ind + len + 4:] 122 | 123 | 124 | return pem_certs 125 | 126 | 127 | CMD_GET="""VERSION=MYPROXYv2 128 | COMMAND=0 129 | USERNAME=%s 130 | PASSPHRASE=%s 131 | LIFETIME=%d\0""" 132 | 133 | def myproxy_logon_py(hostname,username,passphrase,lifetime=43200,port=7512): 134 | """ 135 | Function to retrieve a proxy credential from a MyProxy server 136 | 137 | Exceptions: GetException, RetrieveProxyException 138 | """ 139 | 140 | context = SSL.Context(SSL.SSLv23_METHOD) 141 | 142 | # disable for compatibility with myproxy server (er, globus) 143 | # globus doesn't handle this case, apparently, and instead 144 | # chokes in proxy delegation code 145 | context.set_options(0x00000800) 146 | 147 | # connect to myproxy server 148 | logger.debug("connect to myproxy server %s" %hostname) 149 | conn = SSL.Connection(context,socket.socket()) 150 | conn.connect((hostname,port)) 151 | 152 | # send globus compatibility stuff 153 | logger.debug("send globus compat byte") 154 | conn.write('0') 155 | 156 | # send get command 157 | logger.debug("send get command") 158 | cmd_get = CMD_GET % (username,passphrase,lifetime) 159 | conn.write(cmd_get) 160 | 161 | # process server response 162 | logger.debug("get server response") 163 | dat = conn.recv(8192) 164 | logger.debug("receive: %r" %dat) 165 | 166 | response,errortext = deserialize_response(dat) 167 | if response: 168 | logger.debug("error: " + errortext) 169 | raise GetException(errortext) 170 | else: 171 | logger.debug("server response ok") 172 | 173 | # generate and send certificate request 174 | # - The client will generate a public/private key pair and send a 175 | # NULL-terminated PKCS#10 certificate request to the server. 176 | logger.debug("send cert request") 177 | certreq,privatekey = create_cert_req() 178 | conn.send(certreq) 179 | 180 | # process certificates 181 | # - 1 byte , number of certs 182 | dat = conn.recv(1) 183 | numcerts = dat[0] 184 | 185 | # - n certs 186 | logger.debug("receive certs") 187 | dat = conn.recv(8192) 188 | # if debuglevel(2): 189 | # logger.debug('dumping cert data to "%s"' %settings.MYPROXY_DUMP_FILE) 190 | # f = file(settings.MYPROXY_DUMP_FILE,'w') 191 | # f.write(dat) 192 | # f.close() 193 | 194 | # process server response 195 | logger.debug("get server response") 196 | resp = conn.recv(8192) 197 | response,errortext = deserialize_response(resp) 198 | if response: 199 | logger.debug("RetrieveProxyException " + errortext) 200 | raise RetrieveProxyException(errortext) 201 | else: 202 | logger.debug("server response ok") 203 | 204 | # deserialize certs from received cert data 205 | pem_certs = deserialize_certs(dat) 206 | if len(pem_certs) != numcerts: 207 | logger.debug("Warning: %d certs expected, %d received" % (numcerts,len(pem_certs))) 208 | 209 | # write certs and private key to file 210 | # - proxy cert 211 | # - private key 212 | # - rest of cert chain 213 | global gsi_keys 214 | gsi_keys[username]=privatekey 215 | 216 | return dict(cert=pem_certs[0], key=privatekey, calist=pem_certs[1:]) 217 | 218 | 219 | 220 | myproxy_logon = myproxy_logon_py 221 | 222 | 223 | if __name__ == '__main__': 224 | import sys 225 | import optparse 226 | import getpass 227 | 228 | parser = optparse.OptionParser() 229 | parser.add_option("-s", "--pshost", dest="host", 230 | help="The hostname of the MyProxy server to contact") 231 | parser.add_option("-p", "--psport", dest="port", default=7512, 232 | help="The port of the MyProxy server to contact") 233 | parser.add_option("-l", "--username", dest="username", 234 | help="The username with which the credential is stored on the MyProxy server") 235 | parser.add_option("-o", "--out", dest="outfile", 236 | help="The username with which the credential is stored on the MyProxy server") 237 | parser.add_option("-t", "--proxy-lifetime", dest="lifetime", default=43200, 238 | help="The username with which the credential is stored on the MyProxy server") 239 | parser.add_option("-d", "--debug", dest="debug", default=0, 240 | help="Debug mode: 1=print debug info ; 2=print as in (1), and dump data to myproxy.dump") 241 | 242 | (options,args) = parser.parse_args() 243 | 244 | debug = options.debug 245 | 246 | # process options 247 | host = options.host 248 | if not host: 249 | print("Error: MyProxy host not specified") 250 | sys.exit(1) 251 | port = int(options.port) 252 | username = options.username 253 | if not username: 254 | if sys.platform == 'win32': 255 | username = os.environ["USERNAME"] 256 | else: 257 | import pwd 258 | username = pwd.getpwuid(os.geteuid())[0] 259 | lifetime = int(options.lifetime) 260 | 261 | outfile = options.outfile 262 | if not outfile: 263 | if sys.platform == 'win32': 264 | outfile = 'proxy' 265 | elif sys.platform in ['linux2','darwin']: 266 | outfile = '/tmp/x509up_u%s' % (os.getuid()) 267 | 268 | # Get MyProxy password 269 | passphrase = getpass.getpass() 270 | 271 | # Retrieve proxy cert 272 | try: 273 | ret = myproxy_logon(host,username,passphrase,lifetime=lifetime,port=port) 274 | with open(outfile,'wb') as f: 275 | f.write(ret['key']) 276 | print("A proxy has been received for user %s in %s." % (username,outfile)) 277 | except Exception as e: 278 | if debuglevel(1): 279 | import traceback 280 | traceback.print_exc() 281 | else: 282 | print("authentication failed") 283 | print (type(e)) 284 | --------------------------------------------------------------------------------