diff --git a/nimsapi.py b/nimsapi.py index 724fa77c07f30e5a0622dbb3a9d080108dcf40e5..e205035404d21b03dc58313cd7e3ad2c26d4be39 100755 --- a/nimsapi.py +++ b/nimsapi.py @@ -16,6 +16,7 @@ import argparse import markdown import bson.json_util import webapp2_extras.routes +import Crypto.PublicKey.RSA import nimsutil @@ -37,7 +38,8 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler): def get(self): """Return API documentation""" - resource = """Resource | Description + resources = """ + Resource | Description :-------------------------------------------------|:----------------------- /nimsapi/download | download /nimsapi/dump | dump @@ -69,7 +71,7 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler): [(/nimsapi/epochs/listschema)] | schema for epoch list [(/nimsapi/epochs/schema)] | schema for single epoch /nimsapi/epochs/*<eid>* | details for one epoch, *<eid>*""" - resource = re.sub(r'\[\((.*)\)\]', r'[\1](\1)', resource).replace('<', '<').replace('>', '>') + resources = re.sub(r'\[\((.*)\)\]', r'[\1](\1)', resources).replace('<', '<').replace('>', '>').strip() self.response.headers['Content-Type'] = 'text/html; charset=utf-8' self.response.write('<html>\n') self.response.write('<head>\n') @@ -88,7 +90,7 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler): self.response.write('</style>\n') self.response.write('</head>\n') self.response.write('<body style="min-width:900px">\n') - self.response.write(markdown.markdown(resource, ['extra'])) + self.response.write(markdown.markdown(resources, ['extra'])) self.response.write('</body>\n') self.response.write('</html>\n') @@ -390,7 +392,7 @@ class ArgumentParser(argparse.ArgumentParser): super(ArgumentParser, self).__init__() self.add_argument('uri', help='NIMS DB URI') self.add_argument('stage_path', help='path to staging area') - self.add_argument('-k', '--pubkey', help='path to public SSL key file') + self.add_argument('--privkey', help='path to private SSL key file') self.add_argument('-u', '--uid', help='site UID') self.add_argument('-f', '--logfile', help='path to log file') self.add_argument('-l', '--loglevel', default='info', help='path to log file') @@ -438,14 +440,16 @@ app = webapp2.WSGIApplication(routes, debug=True) if __name__ == '__main__': args = ArgumentParser().parse_args() nimsutil.configure_log(args.logfile, not args.quiet, args.loglevel) - if args.pubkey: - pubkey = open(args.pubkey).read() # failure raises a sensible IOError - log.debug('SSL pubkey loaded') + + if args.privkey: + privkey = Crypto.PublicKey.RSA.importKey(open(args.privkey).read()) + log.debug('SSL private key loaded') else: - pubkey = None - log.warning('PUBKEY NOT SPECIFIED') + privkey = None + log.warning('PRIVKEY NOT SPECIFIED') + from paste import httpserver - app.config = dict(stage_path=args.stage_path, site_id=args.uid, pubkey=pubkey) + app.config = dict(stage_path=args.stage_path, site_id=args.uid, privkey=privkey) app.db = (pymongo.MongoReplicaSetClient(args.uri) if 'replicaSet' in args.uri else pymongo.MongoClient(args.uri)).get_default_database() httpserver.serve(app, host=httpserver.socket.gethostname(), port='8080') diff --git a/nimsapi.wsgi b/nimsapi.wsgi index 8f7b59ae06b0e0c7d4c2be599ae552a72471ade1..5fcc9b609251aa6d05fd2f12bac326561e77ad68 100644 --- a/nimsapi.wsgi +++ b/nimsapi.wsgi @@ -15,9 +15,10 @@ import pymongo import webapp2 import nimsapi import nimsutil +import Crypto.PublicKey.RSA log_file = '/var/local/log/nimsapi.log' -pubkey_file = '/var/local/nims/internims/internims.pub' +privkey_file = '/var/local/nims/internims/internims.pem' db_uri = 'mongodb://nims:cnimr750@cnifs.stanford.edu,cnibk.stanford.edu/nims?replicaSet=cni' stage_path = '/scratch/upload' @@ -25,11 +26,10 @@ nimsutil.configure_log(log_file, False) db_client = pymongo.MongoReplicaSetClient(db_uri) if 'replicaSet' in db_uri else pymongo.MongoClient(db_uri) try: - pubkey = open(pubkey_file).read() # FIXME: don't read too much - # FIXME: verify that this is a valid public key -except IOError: - pubkey = None + privkey = Crypto.PublicKey.RSA.importKey(open(privkey_file).read()) +except ValueError as e: + privkey = None application = nimsapi.app -application.config = dict(stage_path=stage_path, site_id='stanford-cni', pubkey=pubkey) +application.config = dict(stage_path=stage_path, site_id='stanford_cni', privkey=privkey) application.db = db_client.get_default_database() diff --git a/nimsapiutil.py b/nimsapiutil.py index c44e6e26bf12535047c7d56e111c3b9c07d6d532..a07d5a549dce91dd3bb95021e0b515b7af63e141 100644 --- a/nimsapiutil.py +++ b/nimsapiutil.py @@ -8,13 +8,15 @@ import webapp2 import datetime import requests import bson.json_util -import Crypto.Hash.HMAC -import Crypto.Random.random +import Crypto.Hash.SHA +import Crypto.PublicKey.RSA +import Crypto.Signature.PKCS1_v1_5 log = logging.getLogger('nimsapi') requests_log = logging.getLogger('requests') # configure Requests logging requests_log.setLevel(logging.WARNING) # set level to WARNING (default is INFO) + class NIMSRequestHandler(webapp2.RequestHandler): """fetches pubkey from own self.db.remotes. needs to be aware of OWN site uid""" @@ -62,86 +64,72 @@ class NIMSRequestHandler(webapp2.RequestHandler): self.userid = self.request.remote_user or '@public' self.user = self.app.db.users.find_one({'_id': self.userid}) self.user_is_superuser = self.user.get('superuser') - self.response.headers['Content-Type'] = 'application/json' self.target_id = self.request.get('iid', None) self.site_id = self.app.config.get('site_id') # is ALREADY 'None' if not specified in args, never empty - self.pubkey = self.app.config.get('pubkey') # is ALREADY 'None' if not specified in args, never empty - - # requests coming from another NIMS instance are dealt with differently - if self.request.user_agent.startswith('NIMS Instance'): - log.debug('request from "{0}", interNIMS p2p initiated'.format(self.request.user_agent)) - try: - authinfo = self.request.headers['authorization'] - challenge_id, digest = base64.b64decode(authinfo).split() - user, remote_site = challenge_id.split(':') - # look up pubkey from db.remotes - projection = {'_id': False, 'pubkey': True} - remote_pubkey = self.app.db.remotes.find_one({'_id': remote_site}, projection)['pubkey'] - # look up challenge from db.challenges - projection = {'_id': False, 'challenge': True} - challenge = self.app.db.challenges.find_one({'_id': challenge_id}, projection)['challenge'] - # delete challenge from db.challenges - self.app.db.challenges.remove({'_id': challenge_id}) - # calculate expected response - h = Crypto.Hash.HMAC.new(remote_pubkey, challenge) - self.expected = base64.b64encode('%s %s' % (challenge_id, h.hexdigest())) - log.debug('recieved: %s' % authinfo) - log.debug('expected: %s' % self.expected) - # verify - if self.expected == authinfo: - log.debug('CRAM response accepted - %s authenticated' % challenge_id) - else: - self.abort(403, 'Not Authorized: cram failed') - except KeyError as e: - # send a 401 with a fresh challenge - # challenge associated with a challenge-id, cid, to ease lookup - cid = self.request.get('cid') - if not cid: self.abort(403, 'cid, challenge_id, required') - challenge = {'_id': cid, - 'challenge': str(Crypto.Random.random.getrandbits(128)), - 'timestamp': datetime.datetime.now()} - # upsert challenge with time of creation - self.app.db.challenges.find_and_modify(query={'_id': cid}, update=challenge, upsert=True, new=True) - # send 401 + challenge in 'www-authenticate' header - self.response.headers['www-authenticate'] = base64.b64encode(challenge['challenge']) - self.response.set_status(401) - log.debug('issued challenge to %s; %s' % (cid, challenge['challenge'])) + self.privkey = self.app.config.get('privkey') # is ALREADY 'None' if not specified in args, never empty def dispatch(self): """dispatching and request forwarding""" + # dispatch to local instance if self.target_id in [None, self.site_id]: - log.debug('{0} dispatching to local {1}'.format(socket.gethostname(), self.request.url)) - super(NIMSRequestHandler, self).dispatch() - elif self.pubkey is None and self.site_id is None: - log.warning('target is %s, but no site ID, and no pubkey. cannot dispatch') - elif self.pubkey is not None and self.site_id is not None: - log.debug('{0} dispatching to remote {1}'.format(socket.gethostname(), self.target_id)) + log.debug(socket.gethostname() + ' dispatching to local ' + self.request.url) + # request originates from remote instance + if self.request.user_agent.startswith('NIMS Instance'): + # is the requester an authorized remote site + requester = self.request.user_agent.replace('NIMS Instance', '').strip() + target = self.app.db.remotes.find_one({'_id':requester}) + if not target: + log.debug('remote host ' + requester + ' not in auth list. DENIED') + self.abort(403, requester + ' is not authorized') + log.debug('request from ' + self.request.user_agent + ', interNIMS p2p initiated') + # verify signature + self.signature = base64.b64decode(self.request.headers.get('Authorization')) + payload = self.request.body + key = Crypto.PublicKey.RSA.importKey(target['pubkey']) + h = Crypto.Hash.SHA.new(payload) + verifier = Crypto.Signature.PKCS1_v1_5.new(key) + if verifier.verify(h, self.signature): + log.debug('message/signature is authentic') + super(NIMSRequestHandler, self).dispatch() + else: + log.debug('message/signature is not authentic') + self.abort(403, 'authentication failed') + # request originates from self + else: + super(NIMSRequestHandler, self).dispatch() + + # dispatch to remote instance + elif self.privkey is not None and self.site_id is not None: + log.debug(socket.gethostname() + ' dispatching to remote ' + self.target_id) # is target registered? target = self.app.db.remotes.find_one({'_id': self.target_id}, {'_id':False, 'hostname':True}) if not target: - log.debug('remote host {0} not in auth list. DENIED'.format(self.target_id)) - self.abort(403, 'forbidden: site is not registered with interNIMS') - self.cid = self.userid + ':' + self.site_id + log.debug('remote host ' + self.target_id + ' not in auth list. DENIED') + self.abort(403, self.target_id + 'is not authorized') + + # disassemble the incoming request + reqparams = dict(self.request.params) + reqpayload = self.request.body # request payload, almost always empty reqheaders = dict(self.request.headers) - # adjust the request, pass as much of orig request as possible - reqheaders['User-Agent'] = 'NIMS Instance {0}'.format(self.site_id) - del reqheaders['Host'] - target_api = 'http://{0}{1}?{2}'.format(target['hostname'], self.request.path, self.request.query_string) - reqparams = {'cid': self.cid} - # first attempt, expect 401, send as little as possible... - # TODO: error handling for host-down/host-unreachable - # TODO: timeout? - r = requests.request(method=self.request.method, url=target_api, params=reqparams, headers=reqheaders) - if r.status_code == 401: - challenge = base64.b64decode(r.headers['www-authenticate']) - log.debug('Authorization requested - challenge: %s' % challenge) - h = Crypto.Hash.HMAC.new(self.pubkey, challenge) - response = base64.b64encode('%s %s' % (self.cid, h.hexdigest())) - log.debug('response: %s %s' % (self.cid, h.hexdigest())) - log.debug('b4encoded: %s' % response) - reqheaders['authorization'] = response - r = requests.request(method=self.request.method, url=target_api, params=reqparams, data=self.request.body, headers=reqheaders, cookies=self.request.cookies) + reqheaders['User-Agent'] = 'NIMS Instance ' + self.site_id + del reqheaders['Host'] # delete old host destination + + # create a signature of the incoming request payload + h = Crypto.Hash.SHA.new(reqpayload) + signature = Crypto.Signature.PKCS1_v1_5.new(self.privkey).sign(h) + reqheaders['Authorization'] = base64.b64encode(signature) + + # construct outgoing request + target_api = 'http://' + target['hostname'] + self.request.path # TODO: switch to https + # target_api = 'https://' + target['hostname'] + self.request.path) + r = requests.request(method=self.request.method, data=reqpayload, url=target_api, params=reqparams, headers=reqheaders, verify=False) + + # return response content + # TODO: headers self.response.write(r.content) + elif self.privkey is None or self.site_id is None: + log.debug('no private key (privkey), or local instance id (iid). cannot dispatch to remote') + def schema(self, *args, **kwargs): self.response.write(json.dumps(self.json_schema, default=bson.json_util.default)) diff --git a/uwsgi.ini b/uwsgi.ini index 80353d18f0835a2c7a74507789825fe0bb42a23c..96570d75f04ad95cd6c824199128794a18262f1f 100644 --- a/uwsgi.ini +++ b/uwsgi.ini @@ -7,4 +7,5 @@ wsgi-file = /var/local/nims/nimsapi/nimsapi.wsgi processes = 2 threads = 2 master = 1 +lazy = 1 # vacuum = 1