diff --git a/.gitignore b/.gitignore index d84f115..5151cf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - *.pyc *.o *.so @@ -24,3 +23,9 @@ dist/ # bazel poop bazel-* + +# vscode poop +.vscode/ipch + +# node package manager +node_modules diff --git a/cmake/codestyle.cmake b/cmake/codestyle.cmake index f905e25..edb3b3d 100644 --- a/cmake/codestyle.cmake +++ b/cmake/codestyle.cmake @@ -3,16 +3,19 @@ # Create format and lint rules for module files # # usage: +# ~~~ # format_and_lint(module # bar.h bar.cc # CMAKE CMakeLists.txt test/CMakeLists.txt # CC foo.h foo.cc # PY foo.py) -# +# ~~~ + # Will create rules `${module}_lint` and `${module}_format` using the standard # code formatters and lint checkers for the appropriate language. These # tools are: # +# ~~~ # CMAKE: # formatter: cmake-format # @@ -23,7 +26,7 @@ # PYTHON: # formatter: autopep8 # linter: pylint -# +# ~~~ function(format_and_lint module) set(cmake_files_) set(cc_files_) @@ -65,24 +68,29 @@ function(format_and_lint module) set(fmtcmds_) set(depfiles_) if(cmake_files_) - list(APPEND fmtcmds_ COMMAND python -Bm cmake_format -i ${cmake_files_}) + list(APPEND fmtcmds_ COMMAND env PYTHONPATH=${CMAKE_SOURCE_DIR} + python -Bm cmake_format -i ${cmake_files_}) list(APPEND depfiles_ ${cmake_files_} ${CMAKE_SOURCE_DIR}/.cmake-format.py) endif() if(cc_files_) - list(APPEND fmtcmds_ COMMAND clang-format -style file -i ${cc_files_}) - list(APPEND lntcmds_ COMMAND clang-tidy -p ${CMAKE_BINARY_DIR} ${cc_files_}) + list(APPEND fmtcmds_ COMMAND clang-format-6.0 -style file -i ${cc_files_}) + list(APPEND lntcmds_ + COMMAND clang-tidy-6.0 -p ${CMAKE_BINARY_DIR} ${cc_files_}) list(APPEND lntcmds_ COMMAND cpplint ${cc_files_}) list(APPEND depfiles_ ${cc_files_} ${CMAKE_SOURCE_DIR}/.clang-format - ${CMAKE_SOURCE_DIR}/CPPLINT.cfg - ${CMAKE_SOURCE_DIR}/setup.cfg) + ${CMAKE_SOURCE_DIR}/CPPLINT.cfg) endif() if(py_files_) list(APPEND fmtcmds_ COMMAND autopep8 -i ${py_files_}) - list(APPEND fmtcmds_ COMMAND yapf -i ${py_files_}) - list(APPEND lntcmds_ COMMAND pylint ${py_files_}) - list(APPEND lntcmds_ COMMAND flake8 ${py_files_}) + list(APPEND lntcmds_ COMMAND env PYTHONPATH=${CMAKE_SOURCE_DIR} + pylint ${py_files_}) + # NOTE(josh): flake8 tries to use semaphores which fail in our containers + # https://bugs.python.org/issue3770 (probably due to /proc/shmem or + # something not being mounted) + list(APPEND lntcmds_ COMMAND env PYTHONPATH=${CMAKE_SOURCE_DIR} + flake8 --jobs 1 ${py_files_}) list(APPEND depfiles_ ${py_files_} ${CMAKE_SOURCE_DIR}/.flake8 ${CMAKE_SOURCE_DIR}/.pep8 diff --git a/cmake/doctools.cmake b/cmake/doctools.cmake index 58427ee..1ebccd0 100644 --- a/cmake/doctools.cmake +++ b/cmake/doctools.cmake @@ -14,7 +14,8 @@ function(sphinx module) set(stamp_path_ ${CMAKE_CURRENT_BINARY_DIR}/${module}_doc.stamp) add_custom_command(OUTPUT ${stamp_path_} - COMMAND sphinx-build -M html ${CMAKE_CURRENT_SOURCE_DIR} + COMMAND env PYTHONPATH=${CMAKE_SOURCE_DIR} + sphinx-build -M html ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR} COMMAND touch ${stamp_path_} DEPENDS ${ARGN} @@ -22,4 +23,3 @@ function(sphinx module) add_custom_target(${module}_doc DEPENDS ${stamp_path_}) add_dependencies(doc ${module}_doc) endfunction() - diff --git a/oauthsub/CMakeLists.txt b/oauthsub/CMakeLists.txt index 5f049bc..20fe394 100644 --- a/oauthsub/CMakeLists.txt +++ b/oauthsub/CMakeLists.txt @@ -1,3 +1,8 @@ -format_and_lint(oauthsub __init__.py __main__.py auth_service.py) +format_and_lint(oauthsub + __init__.py + __main__.py + auth_service.py + configuration.py + util.py) add_subdirectory(doc) diff --git a/oauthsub/__init__.py b/oauthsub/__init__.py index ac7d688..bddc5e1 100644 --- a/oauthsub/__init__.py +++ b/oauthsub/__init__.py @@ -5,4 +5,4 @@ See: https://developers.google.com/api-client-library/python/auth/web-app """ -VERSION = "0.1.3" +VERSION = "0.2.0" diff --git a/oauthsub/__main__.py b/oauthsub/__main__.py index 05b3891..dd5acda 100644 --- a/oauthsub/__main__.py +++ b/oauthsub/__main__.py @@ -9,6 +9,7 @@ import oauthsub from oauthsub import auth_service +from oauthsub import configuration logger = logging.getLogger("oauthsub") @@ -39,8 +40,8 @@ def dump_config(config, outfile): Dump configuration to the output stream """ ppr = pprint.PrettyPrinter(indent=2) - for key in auth_service.Configuration.get_fields(): - helptext = auth_service.VARDOCS.get(key, None) + for key in configuration.Configuration.get_fields(): + helptext = configuration.VARDOCS.get(key, None) if helptext: for line in textwrap.wrap(helptext, 78): outfile.write('# ' + line + '\n') @@ -69,12 +70,12 @@ def setup_parser(parser, config_dict): choices=["flask", "gevent", "twisted"], help="Which WGSI server to use") - for key in auth_service.Configuration.get_fields(): + for key in configuration.Configuration.get_fields(): if key in ("server",): continue value = config_dict[key] - helptext = auth_service.VARDOCS.get(key, None) + helptext = configuration.VARDOCS.get(key, None) # NOTE(josh): argparse store_true isn't what we want here because we want # to distinguish between "not specified" = "default" and "specified" if isinstance(value, bool): @@ -96,32 +97,39 @@ def setup_parser(parser, config_dict): def main(): - format_str = '%(levelname)-4s %(filename)s [%(lineno)-3s] : %(message)s' - logging.basicConfig(level=logging.DEBUG, - format=format_str, - datefmt='%Y-%m-%d %H:%M:%S', - filemode='w') + # This is necessary for testing with non-HTTPS localhost + # Remove this if deploying to production + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + + # This is necessary because Azure does not guarantee + # to return scopes in the same case and order as requested + os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' + os.environ['OAUTHLIB_IGNORE_SCOPE_CHANGE'] = '1' + + logging.basicConfig(level=logging.DEBUG, filemode='w') parser = argparse.ArgumentParser( prog='oauthsub', description=auth_service.__doc__) - config_dict = auth_service.Configuration().serialize() + config_dict = configuration.Configuration().serialize() setup_parser(parser, config_dict) args = parser.parse_args() if args.dump_config: - dump_config(auth_service.Configuration(), sys.stdout) + dump_config(configuration.Configuration(), sys.stdout) sys.exit(0) if args.config_file: configpath = os.path.expanduser(args.config_file) + config_dict["__file__"] = os.path.realpath(configpath) with io.open(configpath, 'r', encoding='utf8') as infile: # pylint: disable=W0122 exec(infile.read(), config_dict) + config_dict.pop("__file__") for key, value in vars(args).items(): if key in config_dict and value is not None: config_dict[key] = value - config = auth_service.Configuration(**config_dict) + config = configuration.Configuration(**config_dict) # Create directory for logs if it doesn't exist if not os.path.exists(config.logdir): @@ -138,12 +146,14 @@ def main(): format_str = ('%(asctime)s %(levelname)-4s %(filename)s [%(lineno)-3s] :' ' %(message)s') filelog.setFormatter(logging.Formatter(format_str)) - logging.getLogger('').addHandler(filelog) + logging.getLogger("").addHandler(filelog) config_dict = config.serialize() config_dict.pop('secrets', None) config_dict.pop('client_secrets', None) - logging.info('Configuration: %s', json.dumps(config_dict, indent=2)) + logging.info( + 'Configuration: %s', + json.dumps(config_dict, indent=2, sort_keys=True)) # NOTE(josh): hack to deal with jinja's failure to resolve relative imports # to absolute paths @@ -154,7 +164,7 @@ def main(): app.run(threaded=True, host=config.host, port=config.port) elif config.server == "gevent": from gevent.pywsgi import WSGIServer - WSGIServer((config.host, config.port), app.flask).serve_forever() + WSGIServer((config.host, config.port), app).serve_forever() elif config.server == "twisted": from twisted.web import server from twisted.web.wsgi import WSGIResource @@ -164,7 +174,7 @@ def main(): thread_pool = ThreadPool() thread_pool.start() reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop) - resource = WSGIResource(reactor, thread_pool, app.flask) + resource = WSGIResource(reactor, thread_pool, app) factory = server.Site(resource) reactor.listenTCP(config.port, factory) reactor.run() diff --git a/oauthsub/auth_service.py b/oauthsub/auth_service.py index 0f6f347..d846ae2 100644 --- a/oauthsub/auth_service.py +++ b/oauthsub/auth_service.py @@ -8,190 +8,323 @@ from __future__ import print_function from __future__ import unicode_literals -import base64 import inspect import os import json import logging import logging.handlers import sys -import urllib -import zipfile import flask import jinja2 -import oauth2client.client -import requests -import oauthsub +from requests_oauthlib import OAuth2Session +import oauthsub +from oauthsub import util logger = logging.getLogger("oauthsub") if sys.version_info < (3, 0, 0): # pylint: disable=E1101 - quote_plus = urllib.quote_plus - urlencode = urllib.urlencode + import urllib as parse else: # pylint: disable=E1101 - quote_plus = urllib.parse.quote_plus - urlencode = urllib.parse.urlencode + from urllib import parse + + +def strip_settings(settings_dict): + """ + Return a copy of the settings dictionary including only the kwargs + expected by OAuth2Session + """ + + # NOTE(josh): args[0] is `self` + if sys.version_info < (3, 5, 0): + # pylint: disable=W1505 + fields = inspect.getargspec(OAuth2Session.__init__).args[1:] + else: + sig = getattr(inspect, 'signature')(OAuth2Session.__init__) + fields = [field for field, _ in list(sig.parameters.items())[1:-1]] + + return {k: v for k, v in settings_dict.items() if k in fields} -class ZipfileLoader(jinja2.BaseLoader): +def login(): + """ + The login page. Start of the oauth dance. Construct a flow, get redirect, + bounce the user. + """ - def __init__(self, zipfile_path, directory): - self.zip = zipfile.ZipFile(zipfile_path, mode='r') - self.dir = directory + app = flask.current_app + if app.session_get('user') is not None: + return app.render_message("You are already logged in as {}", + app.session_get('user')) - def __del__(self): - self.zip.close() + provider = flask.request.args.get("provider") + if provider is None: + return flask.make_response( + app.render_message("No provider!"), 403, {}) - def get_source(self, environment, template): - # NOTE(josh): not os.path because zipfile uses forward slash - tplpath = '{}/{}'.format(self.dir, template) - with self.zip.open(tplpath, 'r') as infile: - source = infile.read().decode('utf-8') + if provider not in app.app_config.client_secrets: + message = "Invalid provider: {}".format(provider) + html = app.render_message(message) + response = flask.make_response(html, 403, {}) + return response - return source, tplpath, lambda: True + app.session_set( + "original_uri", flask.request.args.get("original_uri")) + logger.debug("Requesting auth from provider: %s", provider) + settings = app.app_config.client_secrets[provider] + kwargs = strip_settings(settings) + client = OAuth2Session(**kwargs) + auth_uri, csrf_token = client.authorization_url( + settings["authorize_uri"], prompt="login") + app.session_set("csrf_token", csrf_token) + return flask.redirect(auth_uri) -def get_parent_path(): + +def logout(): """ - Return the parent path of the oauthsub package. + Delete the user's session, effectively logging them out. """ - modpath = os.path.dirname(oauthsub.__file__) - return os.path.dirname(modpath) + app = flask.current_app + flask.session.clear() + return app.render_message('Logged out') -def get_zipfile_path(): +def callback(): + """ + Handle oauth bounce-back. """ - If our module is loaded from a zipfile (e.g. a wheel or egg) then return - the pair (zipfile_path, module_relpath) where zipfile_path is the path to - the zipfile and module_relpath is the relative path within that zipfile. + + app = flask.current_app + # If we didn't received a 'code' in the query parameters then this + # definately not a redirect back from google. Assume this is a user meaning + # to use the /login endpoint and punt them to the start of the dance. + if 'code' not in flask.request.args: + return app.login() + + if 'provider' not in flask.request.args: + return app.login() + + provider = flask.request.args.get("provider") + if provider not in app.app_config.client_secrets: + message = "Invalid provider: {}".format(provider) + html = app.render_message(message) + response = flask.make_response(html, 403, {}) + return response + + logger.debug("Fetching token from provider: %s", provider) + settings = app.app_config.client_secrets[provider] + kwargs = strip_settings(settings) + kwargs["state"] = app.session_get("csrf_token") + client = OAuth2Session(**kwargs) + + # Exchange the code that the provider gave us for an actual credentials + # object, and store those credentials in the session for this user. + kwargs = {key: settings[key] + for key in ("token_uri", "client_secret")} + kwargs["token_url"] = kwargs.pop("token_uri", None) + kwargs["authorization_response"] = flask.request.url + token = client.fetch_token(**kwargs) + + # Use the credentials that we have in order to get the users information + # from the provider. We only need one request to get the user's email + # address and name. + if provider == "google": + request_url = "https://www.googleapis.com/userinfo/v2/me?alt=json" + elif provider == "github": + request_url = "https://api.github.com/user" + else: + message = 'Invalid provider: {}'.format(provider) + return flask.make_response(app.render_message(message), 401, {}) + + response = client.get(request_url) + if response.status_code != 200: + message = 'Failed to query {}: [{}]'.format(provider, response.status) + return flask.make_response(app.render_message(message), 500, {}) + + # We'll store the users email, name, and 'given_name' from the provider's + # reponse. This is just to help the user understand which identity + # they currently have authenticated against. + content_str = response.content.decode("utf-8") + parsed_content = json.loads(content_str) + + # If the user logged in with an email domain other than then we want + # to warn them that they are probably not doing what they wanted to do. + # TODO(josh): move into google-specific auth function + if (app.app_config.allowed_domains + and (parsed_content.get('hd') not in app.app_config.allowed_domains)): + content = app.render_message('You did not login with the right account!') + return flask.make_response(content, 401, {}) + + username = app.app_config.user_lookup(provider, parsed_content) + if username is None: + logger.warning("user lookup failed: %s", + json.dumps(parsed_content, indent=2, sort_keys=True)) + content = "Failed user lookup" + return flask.make_response(content, 401, {}) + app.session_set('user', username) + app.session_set("token", json.dumps(token, indent=2, sort_keys=True)) + + # At this point the user is authed + for key in ['email', 'name', 'given_name']: + app.session_set(key, parsed_content.get(key, "unknown")) + + # If we are logging-in due to attempt to access an auth-requiring page, + # then go to back to that page + original_uri = app.session_get('original_uri', None) + if original_uri is None: + logger.info('Finished auth, no original_uri in request') + return flask.redirect(app.app_config.rooturl) + + logger.debug('Finished auth, redirecting to: %s', original_uri) + return flask.redirect(app.app_config.rooturl + original_uri) + + +def query_auth(): """ - modparent = get_parent_path() - zipfile_parts = modparent.split(os.sep) - module_parts = [] + This is the main endpoint used by nginx to check authorization. If this + is an nginx request the X-Original-URI will be passed as an http header. + """ + app = flask.current_app + + original_uri = flask.request.headers.get('X-Original-URI') + if original_uri: + logger.debug('Doing auth for original URI: %s, session %s', + original_uri, flask.session.get('id', None)) + + # If bypass key is present and matches configured, then bypass the + # auth-check and assume the user identity + if app.app_config.bypass_key is not None: + if ('X-OAuthSub-Bypass-Key' in flask.request.headers + and 'X-OAuthSub-Bypass-User' in flask.request.headers): + logger.debug("bypass headers are present") + + if(flask.request.headers['X-OAuthSub-Bypass-Key'] == + app.app_config.bypass_key): + + username = flask.request.headers["X-OAuthSub-Bypass-User"] + logger.debug("admin bypass, setting user to %s", username) + app.session_set("user", username) + else: + logger.warning("admin bypass key doesn't match") + + # NOTE(josh): we don't do any whitelisting here, we'll let the nginx + # config decide which urls to request auth for + if app.session_get('user', None) is not None: + response = flask.make_response("", 200, {}) + if app.app_config.response_header: + response.headers[app.app_config.response_header] \ + = app.session_get('user') + return response - while zipfile_parts: - zipfile_path = os.sep.join(zipfile_parts) - relative_path = "/".join(module_parts) - if os.path.exists(zipfile_path) and zipfile.is_zipfile(zipfile_path): - return zipfile_path, relative_path - module_parts.insert(0, zipfile_parts.pop(-1)) + # NOTE(josh): since nginx will return a 401, it will not pass the + # Set-Cookie header to the client. This session will not be associated + # with the client unless they already have a cookie for this site. + # There's not much point in dealing with the X-Original-URI here since + # we can't realiably maintain any context. + return flask.make_response("", 401, {}) - return None, None + flask.abort(401) + return None -def default_user_lookup(parsed_content): +def forbidden(): """ - Lookup user name from auth JSON. Return None if cannot auth. + The page served when a user isn't authorized. We'll just set the return + path if it's available and then kick them through oauth2. """ - return parsed_content.get("email") + app = flask.current_app + original_uri = flask.request.headers.get('X-Original-URI') + logger.info('Serving forbidden, session %s, original uri: %s', + flask.session.get('id', None), original_uri) + # NOTE(josh): it seems we can't do a redirect from the 401 page, or else it + # might be on the browser side, but we get stuck at some google text saying + # that the page should automatically redirect but it doesn't. Let's just + # print the message and let them login. If they login it will return them + # to where they wanted to go in the first place. + if original_uri is not None and original_uri.endswith("favicon.ico"): + return flask.make_response("", 401, {}) -def flow_from_clientsecrets(client_info, scope, redirect_uri=None, - login_hint=None, device_uri=None, - pkce=None, code_verifier=None, prompt=None): + html = app.render_message('Permission denied. Are you logged in?', + original_uri=original_uri) + return flask.make_response(html) + + +def get_session(): """ - Create a Flow from a clientsecrets json. - See oauth2client.client.flow_from_client_secrets - - Will create the right kind of Flow based on the contents of the - clientsecrets file or will raise InvalidClientSecretsError for unknown - types of Flows. - - Args: - filename: string, File name of client secrets. - scope: string or iterable of strings, scope(s) to request. - redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob' for - a non-web-based application, or a URI that handles the - callback from the authorization server. - Returns: - A Flow object. - - Raises: - UnknownClientSecretsFlowError: if the file describes an unknown kind of - Flow. - clientsecrets.InvalidClientSecretsError: if the clientsecrets file is - invalid. + Return the user's session as a json object. Can be used to retrieve user + identity within other frontend services, or for debugging. """ - constructor_kwargs = { - "redirect_uri": redirect_uri, - "auth_uri": client_info["auth_uri"], - "token_uri": client_info["token_uri"], - "login_hint": login_hint, + + app = flask.current_app + session_dict = { + key: app.session_get(key) + for key in ('email', 'name', 'given_name', 'user') } - revoke_uri = client_info.get("revoke_uri") - if revoke_uri is not None: - constructor_kwargs["revoke_uri"] = revoke_uri - if device_uri is not None: - constructor_kwargs["device_uri"] = device_uri - if pkce is not None: - constructor_kwargs["pkce"] = pkce - if code_verifier is not None: - constructor_kwargs["code_verifier"] = code_verifier - if prompt is not None: - constructor_kwargs["prompt"] = prompt - - return oauth2client.client.OAuth2WebServerFlow( - client_info["client_id"], - client_info["client_secret"], - scope, **constructor_kwargs) - - -class Application(object): + return flask.jsonify(session_dict) + + +class Application(flask.Flask): """ Main application context. Exists as a class to keep things local... even though flask is all about the global state. """ - def __init__(self, config): - """Configure jinja, beaker, etc.""" + def __init__(self, app_config): + """ + Configure jinja, beaker, etc. + """ + + super(Application, self).__init__("oauthsub") + # TODO(josh): validate config.client_secrets - self.config = config + self.app_config = app_config - zipfile_path, package_path = get_zipfile_path() - if self.config.custom_template: + # TODO(josh): move this to main() and pass in the template loader + module_path = os.path.dirname(oauthsub.__file__) + zipfile_path, package_path = util.get_zipfile_path(module_path) + if self.app_config.custom_template: logger.info('Using FilesystemLoader for templates') template_loader = jinja2.FileSystemLoader( - os.path.dirname(self.config.custom_template)) + os.path.dirname(self.app_config.custom_template)) elif zipfile_path: logger.info('Using ZipfileLoader for templates') - template_loader = ZipfileLoader(zipfile_path, - package_path + '/templates') + template_loader = util.ZipfileLoader(zipfile_path, + package_path + '/templates') else: logger.info('Using PackageLoader for templates') template_loader = jinja2.PackageLoader('oauthsub', 'templates') self.jinja = jinja2.Environment(loader=template_loader) - self.jinja.globals.update(url_encode=quote_plus) - - self.flask = flask.Flask(__name__) - self.flask.secret_key = config.flask_privkey - self.flask.debug = self.config.flask_debug - for key, value in config.flaskopt.items(): - self.flask.config[key] = value - - self.flask.add_url_rule(config.route_prefix, 'hello', self.hello) - - self.flask.add_url_rule('{}/login'.format(config.route_prefix), - 'login', self.login) - self.flask.add_url_rule('{}/logout'.format(config.route_prefix), - 'logout', self.logout) - self.flask.add_url_rule('{}/callback'.format(config.route_prefix), - 'callback', self.callback) - self.flask.add_url_rule('{}/get_session'.format(config.route_prefix), - 'get_session', self.get_session) - self.flask.add_url_rule('{}/query_auth'.format(config.route_prefix), - 'query_auth', self.query_auth) - - if self.config.enable_forbidden: - self.flask.add_url_rule('{}/forbidden'.format(config.route_prefix), - 'forbidden', self.forbidden) - - def run(self, *args, **kwargs): - """Just runs the flask app.""" - self.flask.run(*args, **kwargs) + self.jinja.globals.update(url_encode=parse.quote_plus) + + self.secret_key = app_config.flask_privkey + self.debug = app_config.flask_debug + for key, value in app_config.flaskopt.items(): + self.config[key] = value + + self.add_url_rule('{}/login'.format(app_config.route_prefix), + 'login', login) + self.add_url_rule('{}/logout'.format(app_config.route_prefix), + 'logout', logout) + self.add_url_rule('{}/callback'.format(app_config.route_prefix), + 'callback', callback) + self.add_url_rule('{}/get_session'.format(app_config.route_prefix), + 'get_session', get_session) + self.add_url_rule('{}/query_auth'.format(app_config.route_prefix), + 'query_auth', query_auth) + + if app_config.enable_forbidden: + self.add_url_rule('{}/forbidden'.format(app_config.route_prefix), + 'forbidden', forbidden) + + def route(self, rule, **options): + return super(Application, self).route( + "{}/{}".format(self.app_config.route_prefix, rule), + **options) def render_message(self, message, *args, **kwargs): # pylint: disable=no-member @@ -199,122 +332,24 @@ def render_message(self, message, *args, **kwargs): tplargs = { "session": flask.session, "message": message.format(*args, **kwargs), - "providers": sorted(self.config.client_secrets.keys()), + "providers": sorted(self.app_config.client_secrets.keys()), "original_uri": original_uri, - "route_prefix": self.config.route_prefix + "route_prefix": self.app_config.route_prefix } - if self.config.custom_template: - template = os.path.basename(self.config.custom_template) + if self.app_config.custom_template: + template = os.path.basename(self.app_config.custom_template) else: template = "message.html.tpl" return self.jinja.get_template(template).render(**tplargs) - def hello(self): - """A more or less empty endpoint.""" - - # pylint: disable=no-member - return self.jinja.get_template('message.html.tpl').render( - session=flask.session, message='Hello') - - def query_auth(self): - """ - This is the main endpoint used by nginx to check authorization. If this - is an nginx request the X-Original-URI will be passed as an http header. - """ - original_uri = flask.request.headers.get('X-Original-URI') - if original_uri: - logger.debug('Doing auth for original URI: %s, session %s', - original_uri, flask.session.get('id', None)) - - # If bypass key is present and matches configured, then bypass the - # auth-check and assume the user identity - if self.config.bypass_key is not None: - if ('X-OAuthSub-Bypass-Key' in flask.request.headers - and 'X-OAuthSub-Bypass-User' in flask.request.headers): - logger.debug("bypass headers are present") - - if(flask.request.headers['X-OAuthSub-Bypass-Key'] == - self.config.bypass_key): - - username = flask.request.headers["X-OAuthSub-Bypass-User"] - logger.debug("admin bypass, setting user to %s", username) - self.session_set("user", username) - else: - logger.warning("admin bypass key doesn't match") - - # NOTE(josh): we don't do any whitelisting here, we'll let the nginx - # config decide which urls to reqest auth for - if self.session_get('user', None) is not None: - response = flask.make_response("", 200, {}) - if self.config.response_header: - response.headers[self.config.response_header] \ - = self.session_get('user') - return response - - # NOTE(josh): since nginx will return a 401, it will not pass the - # Set-Cookie header to the client. This session will not be associated - # with the client unless they already have a cookie for this site. - # There's not much point in dealing with the X-Original-URI here since - # we can't realiably maintain any context. - return flask.make_response("", 401, {}) - - flask.abort(401) - return None - - def forbidden(self): - """ - The page served when a user isn't authorized. We'll just set the return - path if it's available and then kick them through oauth2. - """ - original_uri = flask.request.headers.get('X-Original-URI') - logger.info('Serving forbidden, session %s, original uri: %s', - flask.session.get('id', None), original_uri) - - # NOTE(josh): it seems we can't do a redirect from the 401 page, or else it - # might be on the browser side, but we get stuck at some google text saying - # that the page should automatically redirect but it doesn't. Let's just - # print the message and let them login. If they login it will return them - # to where they wanted to go in the first place. - if original_uri is not None and original_uri.endswith("favicon.ico"): - return flask.make_response("", 401, {}) - - html = self.render_message('Permission denied. Are you logged in?', - original_uri=original_uri) - return flask.make_response(html) - - def get_flow(self, provider): - """ - Return the oauth2client flow object - """ - - redirect_uri = '{}{}/callback'.format(self.config.rooturl, - self.config.route_prefix) - query_params = {"provider": provider} - - # NOTE(josh): as of 2019 google requires all redirect URIs to be explicit, - # and will not accept additional query parameters in the URI. We'll need to - # use some kind of cookie or token matching to get this back - # original_uri = flask.request.args.get('original_uri', None) - # if original_uri is not None: - # query_params["original_uri"] = original_uri - - redirect_uri += '?' + urlencode(query_params) - - # Construct a 'flow' object which helps us step through the oauth handshake - # TODO(josh): need to protect against invalid provider strings - return flow_from_clientsecrets( - self.config.client_secrets.get(provider), - scope='https://www.googleapis.com/auth/userinfo.email', - redirect_uri=redirect_uri) - def session_get(self, key, default=None): """ Return the value of the session variable `key`, using the prefix-qualifed name for `key` """ - qualified_key = '{}{}'.format(self.config.session_key_prefix, key) + qualified_key = '{}{}'.format(self.app_config.session_key_prefix, key) return flask.session.get(qualified_key, default) def session_set(self, key, value): @@ -322,251 +357,5 @@ def session_set(self, key, value): Set the value of the session variable `key`, using the prefix-qualifed name for `key` """ - qualified_key = '{}{}'.format(self.config.session_key_prefix, key) + qualified_key = '{}{}'.format(self.app_config.session_key_prefix, key) flask.session[qualified_key] = value - - def login(self): - """ - The login page. Start of the oauth dance. Construct a flow, get redirect, - bounce the user. - """ - - if self.session_get('user') is not None: - return self.render_message("You are already logged in as {}", - self.session_get('user')) - - provider = flask.request.args.get("provider") - if provider is None: - return flask.make_response( - self.render_message("No provider!"), 403, {}) - - if provider not in self.config.client_secrets: - message = "Invalid provider: {}".format(provider) - html = self.render_message(message) - response = flask.make_response(html, 403, {}) - return response - - self.session_set( - "original_uri", flask.request.args.get("original_uri")) - - flow = self.get_flow(provider) - auth_uri = flow.step1_get_authorize_url() - return flask.redirect(auth_uri) - - def callback(self): - """ - Handle oauth bounce-back. - """ - - # If we didn't received a 'code' in the query parameters then this - # definately not a redirect back from google. Assume this is a user meaning - # to use the /login endpoint and punt them to the start of the dance. - if 'code' not in flask.request.args: - return self.login() - - if 'provider' not in flask.request.args: - return self.login() - - provider = flask.request.args.get("provider") - flow = self.get_flow(provider) - auth_code = flask.request.args.get('code') - - # Exchange the code that google gave us for an actual credentials object, - # and store those credentials in the session for this user. - - # NOTE(josh): We don't actually do anything persistent with the credentials - # right now, other than to store them as a certificate that the user is - # authenticated. In the normal use case we would need access to the - # credentials in the future in order to hit google API's on behalf of the - # user. - credentials = flow.step2_exchange(auth_code) - - # Use the credentials that we have in order to get the users information - # from google. We only need one request to get the user's email address - # and name. - headers = {'Accept': 'application/json', - 'Content-Type': 'application/json; charset=UTF-8'} - - if provider == "google": - request_url = "https://www.googleapis.com/userinfo/v2/me?alt=json" - headers["Authorization"] = "Bearer {}".format(credentials.access_token) - elif provider == "github": - request_url = "https://api.github.com/user" - headers["Authorization"] = "token {}".format(credentials.access_token) - else: - message = 'Invalid provider: {}'.format(provider) - return flask.make_response(self.render_message(message), 401, {}) - - response = requests.get(request_url, headers=headers) - - if response.status_code != 200: - message = 'Failed to query {}: [{}]'.format(provider, response.status) - return flask.make_response(self.render_message(message), 500, {}) - - # We'll store the users email, name, and 'given_name' from google's - # reponse. This is just to help the user understand which google identity - # they currently have activated. - content_str = response.content.decode("utf-8") - print(content_str) - parsed_content = json.loads(content_str) - - # If the user logged in with an email domain other than the we want - # to warn them that they are probably not doing what they wanted to do. - # TODO(josh): move into google-specific auth function - if (self.config.allowed_domains - and (parsed_content.get('hd') not in self.config.allowed_domains)): - content = self.render_message('You did not login with the right account!') - return flask.make_response(content, 401, {}) - - username = self.config.user_lookup(provider, parsed_content) - if username is None: - logger.warning("user lookup failed: %s", - json.dumps(parsed_content, indent=2, sort_keys=True)) - content = "Failed user lookup" - return flask.make_response(content, 401, {}) - self.session_set('user', username) - - # At this point the user is authed - self.session_set('credentials', credentials.to_json()) - for key in ['email', 'name', 'given_name']: - self.session_set(key, parsed_content.get(key, "unknown")) - - # If we are logging-in due to attempt to access an auth-requiring page, - # then go to back to that page - original_uri = self.session_get('original_uri', None) - if original_uri is None: - logger.info('Finished auth, no original_uri in request') - return flask.redirect(self.config.rooturl) - - logger.debug('Finished auth, redirecting to: %s', original_uri) - return flask.redirect(self.config.rooturl + original_uri) - - def logout(self): - """ - Delete the user's session, effectively logging them out. - """ - flask.session.clear() - return self.render_message('Logged out') - - def get_session(self): - """ - Return the user's session as a json object. Can be used to retrieve user - identity within other frontend services, or for debugging. - """ - - session_dict = {key: self.session_get(key) - for key in ['email', 'name', 'given_name', 'user']} - return flask.jsonify(session_dict) - - -def get_default(obj, default): - """ - If obj is not `None` then return it. Otherwise return default. - """ - if obj is None: - return default - - return obj - - -class Configuration(object): - """ - Simple configuration object. Holds named members for different configuration - options. Can be serialized to a dictionary which would be a valid kwargs - for the constructor. - """ - - # pylint: disable=too-many-arguments - # pylint: disable=too-many-instance-attributes - def __init__(self, rooturl=None, - flask_debug=False, flask_privkey=None, response_header=None, - allowed_domains=None, host=None, port=None, logdir=None, - flaskopt=None, route_prefix=None, session_key_prefix=None, - bypass_key=None, user_lookup=None, client_secrets=None, - custom_template=None, enable_forbidden=True, server=None, - **kwargs): - self.rooturl = get_default(rooturl, 'http://localhost') - self.flask_debug = flask_debug - self.flask_privkey = get_default(flask_privkey, - base64.b64encode(os.urandom(24))) - self.response_header = response_header - self.allowed_domains = get_default(allowed_domains, ['gmail.com']) - self.host = get_default(host, '0.0.0.0') - self.port = get_default(port, 8081) - self.logdir = get_default(logdir, '/tmp/oauthsub/logs') - self.flaskopt = get_default(flaskopt, { - 'SESSION_TYPE': 'filesystem', - 'SESSION_FILE_DIR': '/tmp/oauthsub/session_data', - 'PERMANENT_SESSION_LIFETIME': 864000 - }) - self.route_prefix = get_default(route_prefix, '/auth') - self.session_key_prefix = get_default(session_key_prefix, 'oauthsub-') - self.bypass_key = bypass_key - self.user_lookup = get_default(user_lookup, default_user_lookup) - self.client_secrets = get_default(client_secrets, {}) - self.custom_template = custom_template - self.enable_forbidden = enable_forbidden - self.server = get_default(server, "flask") - - extra_opts = [] - for key, _ in kwargs.items(): - if not key.startswith('_'): - extra_opts.append(key) - - if extra_opts: - logger.warning("Ignoring extra configuration options:\n %s", - "\n ".join(extra_opts)) - - @classmethod - def get_fields(cls): - """ - Return a list of field names in constructor order. - """ - # NOTE(josh): args[0] is `self` - if sys.version_info < (3, 5, 0): - # pylint: disable=W1505 - return inspect.getargspec(cls.__init__).args[1:] - - sig = getattr(inspect, 'signature')(cls.__init__) - return [field for field, _ in list(sig.parameters.items())[1:-1] - if field not in ["user_lookup"]] - - def serialize(self): - """ - Return a dictionary describing the configuration. - """ - return {field: getattr(self, field) - for field in self.get_fields()} - - -VARDOCS = { - "rooturl": "The root URL for browser redirects", - "secrets": "The location of client_secrets.json", - "flask_debug": "Enable flask debugging for testing", - "flask_privkey": "Secret key used to sign cookies", - "response_header": ( - "If specified, the authenticated user's ``username`` " - "will be passed as a response header with this key."), - "allowed_domains": ( - "List of domains that we allow in the `hd` field of the" - "google response. Set this to your company gsuite " - "domains."), - "host": "The address to listening on", - "port": "The port to listen on", - "logdir": "Directory where we store resource files", - "flaskopt": "Flask configuration options. Set session config here.", - "route_prefix": "All flask routes (endpoints) are prefixed with this", - "session_key_prefix": "All session keys are prefixed with this", - "bypass_key": ( - "Secret string which can be used to bypass authorization" - " if provided in an HTTP header `X-OAuthSub-Bypass`"), - "client_secrets": ( - "Dictionary mapping oauth privider names to the client" - " secrets for that provider."), - "custom_template": "Path to custom jinja template", - "enable_forbidden": ( - "If true, enables the /forbidden endpoint, to which you can redirect" - " 401 errors from your reverse proxy. This page is a simple message " - " with active template but includes login links that will redirect back" - " to the forbidden page after a successful auth.") -} diff --git a/oauthsub/configuration.py b/oauthsub/configuration.py new file mode 100644 index 0000000..1f4944b --- /dev/null +++ b/oauthsub/configuration.py @@ -0,0 +1,131 @@ + +import base64 +import inspect +import logging +import os +import sys + +logger = logging.getLogger("oauthsub") + + +def default_user_lookup(_, parsed_content): # pylint: disable=W0613 + """ + Default username resolution just returns the email address reported by + the provider. + """ + return parsed_content.get("email") + + +def get_default(obj, default): + """ + If obj is not `None` then return it. Otherwise return default. + """ + if obj is None: + return default + + return obj + + +class Configuration(object): + """ + Simple configuration object. Holds named members for different configuration + options. Can be serialized to a dictionary which would be a valid kwargs + for the constructor. + """ + + # pylint: disable=too-many-arguments + # pylint: disable=too-many-instance-attributes + def __init__(self, rooturl=None, + flask_debug=False, flask_privkey=None, response_header=None, + allowed_domains=None, host=None, port=None, logdir=None, + flaskopt=None, route_prefix=None, session_key_prefix=None, + bypass_key=None, user_lookup=None, client_secrets=None, + custom_template=None, enable_forbidden=True, server=None, + **kwargs): + self.rooturl = get_default(rooturl, 'http://localhost') + self.flask_debug = flask_debug + self.flask_privkey = get_default(flask_privkey, + base64.b64encode(os.urandom(24))) + self.response_header = response_header + self.allowed_domains = get_default(allowed_domains, ['gmail.com']) + self.host = get_default(host, '0.0.0.0') + self.port = get_default(port, 8081) + self.logdir = get_default(logdir, '/tmp/oauthsub/logs') + self.flaskopt = get_default(flaskopt, { + 'SESSION_TYPE': 'filesystem', + 'SESSION_FILE_DIR': '/tmp/oauthsub/session_data', + 'PERMANENT_SESSION_LIFETIME': 864000 + }) + self.route_prefix = get_default(route_prefix, '/auth') + self.session_key_prefix = get_default(session_key_prefix, 'oauthsub-') + self.bypass_key = bypass_key + self.user_lookup = get_default(user_lookup, default_user_lookup) + self.client_secrets = get_default(client_secrets, {}) + self.custom_template = custom_template + self.enable_forbidden = enable_forbidden + self.server = get_default(server, "flask") + + extra_opts = [] + for key, kwargval in kwargs.items(): + if key.startswith('_'): + continue + if inspect.ismodule(kwargval): + continue + extra_opts.append(key) + + if extra_opts: + logger.warning("Ignoring extra configuration options:\n %s", + "\n ".join(extra_opts)) + + @classmethod + def get_fields(cls): + """ + Return a list of field names in constructor order. + """ + # NOTE(josh): args[0] is `self` + if sys.version_info < (3, 5, 0): + # pylint: disable=W1505 + return inspect.getargspec(cls.__init__).args[1:] + + sig = getattr(inspect, 'signature')(cls.__init__) + return [field for field, _ in list(sig.parameters.items())[1:-1] + if field not in ["user_lookup"]] + + def serialize(self): + """ + Return a dictionary describing the configuration. + """ + return {field: getattr(self, field) + for field in self.get_fields()} + + +VARDOCS = { + "rooturl": "The root URL for browser redirects", + "flask_debug": "Enable flask debugging for testing", + "flask_privkey": "Secret key used to sign cookies", + "response_header": ( + "If specified, the authenticated user's ``username`` " + "will be passed as a response header with this key."), + "allowed_domains": ( + "List of domains that we allow in the `hd` field of the" + "google response. Set this to your company gsuite " + "domains."), + "host": "The address to listening on", + "port": "The port to listen on", + "logdir": "Directory where we store resource files", + "flaskopt": "Flask configuration options. Set session config here.", + "route_prefix": "All flask routes (endpoints) are prefixed with this", + "session_key_prefix": "All session keys are prefixed with this", + "bypass_key": ( + "Secret string which can be used to bypass authorization" + " if provided in an HTTP header `X-OAuthSub-Bypass`"), + "client_secrets": ( + "Dictionary mapping oauth privider names to the client" + " secrets for that provider."), + "custom_template": "Path to custom jinja template", + "enable_forbidden": ( + "If true, enables the /forbidden endpoint, to which you can redirect" + " 401 errors from your reverse proxy. This page is a simple message " + " with active template but includes login links that will redirect back" + " to the forbidden page after a successful auth.") +} diff --git a/oauthsub/doc/changelog.rst b/oauthsub/doc/changelog.rst index ddb16e1..d785268 100644 --- a/oauthsub/doc/changelog.rst +++ b/oauthsub/doc/changelog.rst @@ -2,6 +2,20 @@ Changelog ========= +----------- +v0.2 series +----------- + +v0.2.0 +------ + +* ported to from oauth2client (deprecated) to oauthlib +* slight refactoring into utils/appliation +* refactored application logic into a more flask-familiar layout + +----------- +v0.1 series +----------- v0.1.3 ------ @@ -26,7 +40,6 @@ v0.1.1 * Fix setup.py description string ------- v0.1.0 ------ diff --git a/oauthsub/example/config.py b/oauthsub/example/config.py index 4793001..26aaf5a 100644 --- a/oauthsub/example/config.py +++ b/oauthsub/example/config.py @@ -5,9 +5,6 @@ # The root URL for browser redirects rooturl = 'http://localhost:8081' -# The location of client_secrets.json, or the raw JSON dictionary itself -secrets = '/tmp/client_secrets.json' - # Enable flask debugging for testing. flask_debug = True @@ -57,38 +54,17 @@ "google": { "client_id": ("000000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ".apps.googleusercontent.com"), - "project_id": "example-project", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "authorize_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": - "https://www.googleapis.com/oauth2/v1/certs", "client_secret": "xxxxxxxxxx-xxxxxxxxxxxxx", - "redirect_uris": [ - "http://lvh.me:8080/auth/callback?provider=google", - "http://lvh.me:8081/auth/callback?provider=google", - "https://lvh.me:8443/auth/callback?provider=google" - ], - "javascript_origins": [ - "http://lvh.me:8080", - "http://lvh.me:8081", - "https://lvh.me:8443" - ] + "redirect_uri": "http://lvh.me:8080/auth/callback?provider=google", }, "github": { "client_id": "xxxxxxxxxxxxxxxxxxxx", - "auth_uri": "https://github.com/login/oauth/authorize", + "authorize_uri": "https://github.com/login/oauth/authorize", "token_uri": "https://github.com/login/oauth/access_token", "client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "redirect_uris": [ - "http://lvh.me:8080/auth/callback", - "http://lvh.me:8081/auth/callback", - "https://lvh.me:8443/auth/callback" - ], - "javascript_origins": [ - "http://lvh.me:8080", - "http://lvh.me:8081", - "https://lvh.me:8443" - ] + "redirect_uris": "http://lvh.me:8080/auth/callback" } } diff --git a/oauthsub/pypi/setup.py b/oauthsub/pypi/setup.py index 3cde60b..f976149 100644 --- a/oauthsub/pypi/setup.py +++ b/oauthsub/pypi/setup.py @@ -34,7 +34,6 @@ install_requires=[ 'flask', 'jinja2', - 'oauth2client', - 'requests', + 'requests_oauthlib', ] ) diff --git a/oauthsub/util.py b/oauthsub/util.py new file mode 100644 index 0000000..5929c1c --- /dev/null +++ b/oauthsub/util.py @@ -0,0 +1,44 @@ +import os +import zipfile + +import jinja2 + + +class ZipfileLoader(jinja2.BaseLoader): + """ + Jinja template loader capable of loading templates from a zipfile + """ + + def __init__(self, zipfile_path, directory): + self.zip = zipfile.ZipFile(zipfile_path, mode='r') + self.dir = directory + + def __del__(self): + self.zip.close() + + def get_source(self, environment, template): + # NOTE(josh): not os.path because zipfile uses forward slash + tplpath = '{}/{}'.format(self.dir, template) + with self.zip.open(tplpath, 'r') as infile: + source = infile.read().decode('utf-8') + + return source, tplpath, lambda: True + + +def get_zipfile_path(modparent): + """ + If our module is loaded from a zipfile (e.g. a wheel or egg) then return + the pair (zipfile_path, module_relpath) where zipfile_path is the path to + the zipfile and module_relpath is the relative path within that zipfile. + """ + zipfile_parts = modparent.split(os.sep) + module_parts = [] + + while zipfile_parts: + zipfile_path = os.sep.join(zipfile_parts) + relative_path = "/".join(module_parts) + if os.path.exists(zipfile_path) and zipfile.is_zipfile(zipfile_path): + return zipfile_path, relative_path + module_parts.insert(0, zipfile_parts.pop(-1)) + + return None, None diff --git a/pylintrc b/pylintrc index 66de3d8..b11eb63 100644 --- a/pylintrc +++ b/pylintrc @@ -44,6 +44,7 @@ extension-pkg-whitelist= # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable= + bad-option-value, cyclic-import, duplicate-code, file-ignored, @@ -212,7 +213,7 @@ ignore-imports=yes max-line-length=80 # Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ +ignore-long-lines=^\s*(# )?(:see: )??$ # Allow the body of an if to be on the same line as the test if there is no # else. @@ -236,7 +237,7 @@ init-import=no # A regular expression matching the beginning of the name of dummy variables # (i.e. not used). -dummy-variables-rgx=_$|dummy +# dummy-variables-rgx=(_+[a-zA-Z0-9]*?$) # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. diff --git a/requirements.txt b/requirements.txt index d6918c9..53ae8fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ autopep8==1.3.5 flake8==3.6.0 flask==1.0.2 gevent==1.4.0 -jinja2==2.10 -oauth2client==4.1.3 +jinja2==2.10.1 pylint==1.9.1 requests==2.21.0 +requests-oauthlib==1.2.0 twisted==18.9.0