Source code for authnzerver.main

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# main.py - Waqas Bhatti (wbhatti@astro.princeton.edu) - Aug 2018
# License: MIT - see the LICENSE file for the full text.

'''
This is the main file for the authnzerver, a simple authorization and
authentication server backed by SQLite, SQLAlchemy, and Tornado.

'''

#############
## LOGGING ##
#############

import logging

# setup a logger
LOGMOD = __name__


#############
## IMPORTS ##
#############

import os
import os.path
import socket
import sys
import signal
import time
from functools import partial
import shutil


# setup signal trapping on SIGINT
def _recv_sigint(signum, stack):
    '''
    handler function to receive and process a SIGINT

    '''
    raise KeyboardInterrupt


#####################
## TORNADO IMPORTS ##
#####################

try:
    import asyncio
    import uvloop
    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
    IOLOOP_SPEC = 'uvloop'
except Exception:
    HAVE_UVLOOP = False
    IOLOOP_SPEC = 'asyncio'

import tornado.ioloop
import tornado.httpserver
import tornado.web
import tornado.options
from tornado.options import define, options
import multiprocessing as mp


#######################
## UTILITY FUNCTIONS ##
#######################

def _setup_auth_worker(authdb_path,
                       fernet_secret,
                       permissions_json):
    '''This stores secrets and the auth DB path in the worker loop's context.

    The worker will then open the DB and set up its Fernet instance by itself.

    '''
    # unregister interrupt signals so they don't get to the worker
    # and the executor can kill them cleanly (hopefully)
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    currproc = mp.current_process()
    currproc.auth_db_path = authdb_path
    currproc.fernet_secret = fernet_secret
    currproc.permissions_json = permissions_json


def _close_authentication_database():

    '''This is used to close the authentication database when the worker loop
    exits.

    '''

    currproc = mp.current_process()
    if getattr(currproc, 'authdb_meta', None):
        del currproc.authdb_meta

    if getattr(currproc, 'authdb_conn', None):
        currproc.authdb_conn.close()
        del currproc.authdb_conn

    if getattr(currproc, 'authdb_engine', None):
        currproc.authdb_engine.dispose()
        del currproc.authdb_engine

    print('Shutting down database engine in process: %s' % currproc.name,
          file=sys.stdout)


########################################################
## CONFIG FROM ENVIRON, COMMAND LINE, AND SUBSTITUTES ##
########################################################

from .modtools import object_from_string

from .confvars import CONF

# this is the module path
modpath = os.path.abspath(os.path.dirname(__file__))

# load all of the conf vars as command-line options
for cv in CONF:
    define(CONF[cv]['cmdline'],
           default=CONF[cv]['default'],
           help=CONF[cv]['help'],
           type=CONF[cv]['type'])

#
# extra config options provided only as command-line parameters
#

# the path to an env file containing environment variables
define('envfile',
       default=None,
       help=('Path to a file containing environ variables '
             'for testing/development.'),
       type=str)

# the path to the confvar file
define('confvars',
       default=os.path.join(modpath,'confvars.py'),
       help=('Path to the file containing the configuration '
             'variables needed by the server and how to load them.'),
       type=str)

# whether to make a new authdb if none exists
define('autosetup',
       default=False,
       help=("If this is True, will automatically generate an SQLite "
             "authentication database in the basedir, "
             "copy over default-permissions-model.json and "
             "confvars.py to the basedir for easy customization, and finally, "
             "generate the communications secret file and the PII salt file."),
       type=bool)


##########
## MAIN ##
##########

[docs]def main(): ''' This is the main function. ''' # parse the command line options.parse_command_line() DEBUG = True if options.debugmode == 1 else False # get a logger LOGGER = logging.getLogger(__name__) if DEBUG: LOGGER.setLevel(logging.DEBUG) else: LOGGER.setLevel(logging.INFO) ################### ## LOCAL IMPORTS ## ################### from .external.futures37.process import ProcessPoolExecutor from .autosetup import autogen_secrets_authdb from .confload import load_config ############## ## HANDLERS ## ############## from .handlers import AuthHandler, EchoHandler from . import cache from . import actions ################### ## SET UP CONFIG ## ################### # # handle autosetup # # if autosetup is set, we'll generate the secret and auth DB, then exit # immediately if options.autosetup: authdb_path, creds, secret_file, salt_file = autogen_secrets_authdb( options.basedir, interactive=True ) # copy over the permission model and confvars LOGGER.warning("Copying default-permissions-model.json to basedir: %s" % options.basedir) shutil.copy(os.path.join(modpath, 'default-permissions-model.json'), options.basedir) LOGGER.warning("Copying confvars.py to basedir: %s" % options.basedir) shutil.copy(os.path.join(modpath, 'confvars.py'), options.basedir) LOGGER.warning("Auto-setup complete, exiting...") LOGGER.warning("To start the authnzerver with these parameters, call " "authnzrv again with the appropriate values set " "for the auth DB, the secret key, and the PII salt in " "either the command line options or " "as environment variables.") sys.exit(0) # otherwise, we'll assume that all is well, and we'll proceed to load the # config from an envfile, command line args, or the environment. try: # update the conf dict with that loaded from confvars.py LOCAL_CONF = object_from_string( "%s::CONF" % options.confvars ) CONF.update(LOCAL_CONF) loaded_config = load_config(CONF, options, envfile=options.envfile) except Exception: LOGGER.error("One or more config variables could not be set " "from the environment, an envfile, or the command " "line options. Exiting...") raise maxworkers = loaded_config.workers basedir = loaded_config.basedir LOGGER.info("The server's base directory is: %s" % os.path.abspath(basedir)) cachedir = loaded_config.cachedir LOGGER.info("The server's cache directory is: %s" % os.path.abspath(cachedir)) port = loaded_config.port listen = loaded_config.listen sessionexpiry = loaded_config.sessionexpiry LOGGER.info('Session token expiry is set to: %s days' % sessionexpiry) # # set up the authdb, secret, and permissions model # authdb = loaded_config.authdb secret = loaded_config.secret permissions = loaded_config.permissions # # this is the background executor we'll pass over to the handler # executor = ProcessPoolExecutor( max_workers=maxworkers, initializer=_setup_auth_worker, initargs=(authdb, secret, permissions), finalizer=_close_authentication_database ) ################### ## HANDLER SETUP ## ################### # we only have one actual endpoint, the other one is for testing handlers = [ (r'/', AuthHandler, {'config':loaded_config, 'executor':executor, 'reqid_cache':set(), 'failed_passchecks':{}}), ] if DEBUG: # put in the echo handler for debugging handlers.append( (r'/echo', EchoHandler, {'authdb':authdb, 'fernet_secret':secret, 'executor':executor}) ) ######################## ## APPLICATION SET UP ## ######################## app = tornado.web.Application( debug=DEBUG, autoreload=False, # this sometimes breaks Executors so disable it ) # try to guard against the DNS rebinding attack # http://www.tornadoweb.org/en/stable/guide/security.html#dns-rebinding app.add_handlers(r'(localhost|127\.0\.0\.1)', handlers) # start up the HTTP server and our application http_server = tornado.httpserver.HTTPServer(app) ###################################################### ## CLEAR THE CACHE AND REAP OLD SESSIONS ON STARTUP ## ###################################################### removed_items = cache.cache_flush( cache_dirname=cachedir ) LOGGER.info('Removed %s stale items from authnzerver cache.' % removed_items) session_killer = partial(actions.auth_kill_old_sessions, session_expiry_days=sessionexpiry, override_authdb_path=authdb) # run once at start up session_killer() ###################### ## start the server ## ###################### # register the signal callbacks signal.signal(signal.SIGINT, _recv_sigint) signal.signal(signal.SIGTERM, _recv_sigint) # make sure the port we're going to listen on is ok # inspired by how Jupyter notebook does this portok = False serverport = port maxtries = 10 thistry = 0 while not portok and thistry < maxtries: try: http_server.listen(serverport, listen) portok = True except socket.error: LOGGER.warning('%s:%s is already in use, trying port %s' % (listen, serverport, serverport + 1)) serverport = serverport + 1 if not portok: LOGGER.error('Could not find a free port after %s tries, giving up' % maxtries) sys.exit(1) # start the IOLoop and begin serving requests try: loop = tornado.ioloop.IOLoop.current() # add our periodic callback for the session-killer # runs daily periodic_session_kill = tornado.ioloop.PeriodicCallback( session_killer, 86400000.0, jitter=0.1, ) periodic_session_kill.start() LOGGER.info('Starting authnzerver. Listening on http://%s:%s.' % (listen, serverport)) LOGGER.info('Background worker processes: %s. IOLoop in use: %s.' % (maxworkers, IOLOOP_SPEC)) # start the IOLoop loop.start() except KeyboardInterrupt: LOGGER.info('Received Ctrl-C: shutting down...') # close down the processpool executor.shutdown() time.sleep(2) tornado.ioloop.IOLoop.instance().stop() currproc = mp.current_process() if getattr(currproc, 'authdb_meta', None): del currproc.authdb_meta if getattr(currproc, 'connection', None): currproc.authdb_conn.close() del currproc.authdb_conn if getattr(currproc, 'authdb_engine', None): currproc.authdb_engine.dispose() del currproc.authdb_engine print('Shutting down database engine in process: %s' % currproc.name, file=sys.stdout)
# run the server if __name__ == '__main__': main()