diff --git a/connexion/api.py b/connexion/api.py index e171b5362..6c2e1b65b 100644 --- a/connexion/api.py +++ b/connexion/api.py @@ -17,9 +17,11 @@ import logging import pathlib import yaml +import werkzeug.exceptions from .operation import Operation from . import utils from . import resolver +from .handlers import AuthErrorHandler MODULE_PATH = pathlib.Path(__file__).absolute().parent SWAGGER_UI_PATH = MODULE_PATH / 'vendor' / 'swagger-ui' @@ -34,7 +36,7 @@ class Api: """ def __init__(self, swagger_yaml_path, base_url=None, arguments=None, swagger_ui=None, swagger_path=None, - swagger_url=None, validate_responses=False, resolver=resolver.Resolver()): + swagger_url=None, validate_responses=False, resolver=resolver.Resolver(), auth_all_paths=False): """ :type swagger_yaml_path: pathlib.Path :type base_url: str | None @@ -42,6 +44,7 @@ def __init__(self, swagger_yaml_path, base_url=None, arguments=None, swagger_ui= :type swagger_ui: bool :type swagger_path: string | None :type swagger_url: string | None + :type auth_all_paths: bool :param resolver: Callable that maps operationID to a function """ self.swagger_yaml_path = pathlib.Path(swagger_yaml_path) @@ -50,7 +53,8 @@ def __init__(self, swagger_yaml_path, base_url=None, arguments=None, swagger_ui= 'arguments': arguments, 'swagger_ui': swagger_ui, 'swagger_path': swagger_path, - 'swagger_url': swagger_url}) + 'swagger_url': swagger_url, + 'auth_all_paths': auth_all_paths}) arguments = arguments or {} with swagger_yaml_path.open() as swagger_yaml: swagger_template = swagger_yaml.read() @@ -92,8 +96,12 @@ def __init__(self, swagger_yaml_path, base_url=None, arguments=None, swagger_ui= self.add_swagger_json() if swagger_ui: self.add_swagger_ui() + self.add_paths() + if auth_all_paths: + self.add_auth_on_not_found() + def add_operation(self, method, path, swagger_operation): """ Adds one operation to the api. @@ -137,6 +145,16 @@ def add_paths(self, paths=None): except Exception: # pylint: disable= W0703 logger.exception('Failed to add operation for %s %s%s', method.upper(), self.base_url, path) + def add_auth_on_not_found(self): + """ + Adds a 404 error handler to authenticate and only expose the 404 status if the security validation pass. + """ + logger.debug('Adding path not found authentication') + not_found_error = AuthErrorHandler(werkzeug.exceptions.NotFound(), security=self.security, + security_definitions=self.security_definitions) + endpoint_name = "{name}_not_found".format(name=self.blueprint.name) + self.blueprint.add_url_rule('/', endpoint_name, not_found_error.function) + def add_swagger_json(self): """ Adds swagger json to {base_url}/swagger.json diff --git a/connexion/app.py b/connexion/app.py index 7c5524e7c..d133978b3 100644 --- a/connexion/app.py +++ b/connexion/app.py @@ -23,8 +23,8 @@ class App: - def __init__(self, import_name, port=None, specification_dir='', server=None, arguments=None, debug=False, - swagger_ui=True, swagger_path=None, swagger_url=None): + def __init__(self, import_name, port=None, specification_dir='', server=None, arguments=None, auth_all_paths=False, + debug=False, swagger_ui=True, swagger_path=None, swagger_url=None): """ :param import_name: the name of the application package :type import_name: str @@ -36,6 +36,8 @@ def __init__(self, import_name, port=None, specification_dir='', server=None, ar :type server: str | None :param arguments: arguments to replace on the specification :type arguments: dict | None + :param auth_all_paths: whether to authenticate not defined paths + :type auth_all_paths: bool :param debug: include debugging information :type debug: bool :param swagger_ui: whether to include swagger ui or not @@ -71,6 +73,7 @@ def __init__(self, import_name, port=None, specification_dir='', server=None, ar self.swagger_ui = swagger_ui self.swagger_path = swagger_path self.swagger_url = swagger_url + self.auth_all_paths = auth_all_paths @staticmethod def common_error_handler(exception): @@ -81,8 +84,8 @@ def common_error_handler(exception): exception = werkzeug.exceptions.InternalServerError() return problem(title=exception.name, detail=exception.description, status=exception.code) - def add_api(self, swagger_file, base_path=None, arguments=None, swagger_ui=None, swagger_path=None, - swagger_url=None, validate_responses=False, resolver=Resolver()): + def add_api(self, swagger_file, base_path=None, arguments=None, auth_all_paths=None, swagger_ui=None, + swagger_path=None, swagger_url=None, validate_responses=False, resolver=Resolver()): """ Adds an API to the application based on a swagger file @@ -92,6 +95,8 @@ def add_api(self, swagger_file, base_path=None, arguments=None, swagger_ui=None, :type base_path: str | None :param arguments: api version specific arguments to replace on the specification :type arguments: dict | None + :param auth_all_paths: whether to authenticate not defined paths + :type auth_all_paths: bool :param swagger_ui: whether to include swagger ui or not :type swagger_ui: bool :param swagger_path: path to swagger-ui directory @@ -109,6 +114,7 @@ def add_api(self, swagger_file, base_path=None, arguments=None, swagger_ui=None, swagger_ui = swagger_ui if swagger_ui is not None else self.swagger_ui swagger_path = swagger_path if swagger_path is not None else self.swagger_path swagger_url = swagger_url if swagger_url is not None else self.swagger_url + auth_all_paths = auth_all_paths if auth_all_paths is not None else self.auth_all_paths logger.debug('Adding API: %s', swagger_file) # TODO test if base_url starts with an / (if not none) arguments = arguments or dict() @@ -120,7 +126,8 @@ def add_api(self, swagger_file, base_path=None, arguments=None, swagger_ui=None, swagger_path=swagger_path, swagger_url=swagger_url, resolver=resolver, - validate_responses=validate_responses) + validate_responses=validate_responses, + auth_all_paths=auth_all_paths) self.app.register_blueprint(api.blueprint) return api diff --git a/connexion/handlers.py b/connexion/handlers.py new file mode 100644 index 000000000..d5cf9f81b --- /dev/null +++ b/connexion/handlers.py @@ -0,0 +1,43 @@ + +import logging +from .operation import SecureOperation +from .problem import problem + +logger = logging.getLogger('connexion.handlers') + + +class AuthErrorHandler(SecureOperation): + """ + Wraps an error with authentication. + """ + + def __init__(self, exception, security, security_definitions): + """ + This class uses the exception instance to produce the proper response problem in case the + request is authenticated. + + :param exception: the exception to be wrapped with authentication + :type exception: werkzeug.exceptions.HTTPException + :param security: list of security rules the application uses by default + :type security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + """ + self.exception = exception + SecureOperation.__init__(self, security, security_definitions) + + @property + def function(self): + """ + Configured error auth handler. + """ + security_decorator = self.security_decorator + logger.debug('... Adding security decorator (%r)', security_decorator, extra=vars(self)) + return security_decorator(self.handle) + + def handle(self, *args, **kwargs): + """ + Actual handler for the execution after authentication. + """ + return problem(title=self.exception.name, detail=self.exception.description, status=self.exception.code) diff --git a/connexion/operation.py b/connexion/operation.py index 6ce79c06d..983cedbe5 100644 --- a/connexion/operation.py +++ b/connexion/operation.py @@ -32,7 +32,76 @@ logger = logging.getLogger('connexion.operation') -class Operation: +class SecureOperation: + def __init__(self, security, security_definitions): + """ + :param security: list of security rules the application uses by default + :type security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + """ + self.security = security + self.security_definitions = security_definitions + + @property + def security_decorator(self): + """ + Gets the security decorator for operation + + From Swagger Specification: + + **Security Definitions Object** + + A declaration of the security schemes available to be used in the specification. + + This does not enforce the security schemes on the operations and only serves to provide the relevant details + for each scheme. + + + **Security Requirement Object** + + Lists the required security schemes to execute this operation. The object can have multiple security schemes + declared in it which are all required (that is, there is a logical AND between the schemes). + + The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. + + :rtype: types.FunctionType + """ + logger.debug('... Security: %s', self.security, extra=vars(self)) + if self.security: + if len(self.security) > 1: + logger.warning("... More than one security requirement defined. **IGNORING SECURITY REQUIREMENTS**", + extra=vars(self)) + return security_passthrough + + security = self.security[0] # type: dict + # the following line gets the first (and because of the previous condition only) scheme and scopes + # from the operation's security requirements + + scheme_name, scopes = next(iter(security.items())) # type: str, list + security_definition = self.security_definitions[scheme_name] + if security_definition['type'] == 'oauth2': + token_info_url = security_definition.get('x-tokenInfoUrl', os.getenv('HTTP_TOKENINFO_URL')) + if token_info_url: + scopes = set(scopes) # convert scopes to set because this is needed for verify_oauth + return functools.partial(verify_oauth, token_info_url, scopes) + else: + logger.warning("... OAuth2 token info URL missing. **IGNORING SECURITY REQUIREMENTS**", + extra=vars(self)) + elif security_definition['type'] in ('apiKey', 'basic'): + logger.debug( + "... Security type '%s' not natively supported by Connexion; you should handle it yourself", + security_definition['type'], extra=vars(self)) + else: + logger.warning("... Security type '%s' unknown. **IGNORING SECURITY REQUIREMENTS**", + security_definition['type'], extra=vars(self)) + + # if we don't know how to handle the security or it's not defined we will usa a passthrough decorator + return security_passthrough + + +class Operation(SecureOperation): """ A single API operation on a path. """ @@ -261,7 +330,7 @@ def function(self): function = validation_decorator(function) # NOTE: the security decorator should be applied last to check auth before anything else :-) - security_decorator = self.__security_decorator + security_decorator = self.security_decorator logger.debug('... Adding security decorator (%r)', security_decorator, extra=vars(self)) function = security_decorator(function) @@ -302,62 +371,6 @@ def __content_type_decorator(self): else: return BaseSerializer() - @property - def __security_decorator(self): - """ - Gets the security decorator for operation - - From Swagger Specification: - - **Security Definitions Object** - - A declaration of the security schemes available to be used in the specification. - - This does not enforce the security schemes on the operations and only serves to provide the relevant details - for each scheme. - - - **Security Requirement Object** - - Lists the required security schemes to execute this operation. The object can have multiple security schemes - declared in it which are all required (that is, there is a logical AND between the schemes). - - The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. - - :rtype: types.FunctionType - """ - logger.debug('... Security: %s', self.security, extra=vars(self)) - if self.security: - if len(self.security) > 1: - logger.warning("... More than one security requirement defined. **IGNORING SECURITY REQUIREMENTS**", - extra=vars(self)) - return security_passthrough - - security = self.security[0] # type: dict - # the following line gets the first (and because of the previous condition only) scheme and scopes - # from the operation's security requirements - - scheme_name, scopes = next(iter(security.items())) # type: str, list - security_definition = self.security_definitions[scheme_name] - if security_definition['type'] == 'oauth2': - token_info_url = security_definition.get('x-tokenInfoUrl', os.getenv('HTTP_TOKENINFO_URL')) - if token_info_url: - scopes = set(scopes) # convert scopes to set because this is needed for verify_oauth - return functools.partial(verify_oauth, token_info_url, scopes) - else: - logger.warning("... OAuth2 token info URL missing. **IGNORING SECURITY REQUIREMENTS**", - extra=vars(self)) - elif security_definition['type'] in ('apiKey', 'basic'): - logger.debug( - "... Security type '%s' not natively supported by Connexion; you should handle it yourself", - security_definition['type'], extra=vars(self)) - else: - logger.warning("... Security type '%s' unknown. **IGNORING SECURITY REQUIREMENTS**", - security_definition['type'], extra=vars(self)) - - # if we don't know how to handle the security or it's not defined we will usa a passthrough decorator - return security_passthrough - @property def __validation_decorators(self): """ diff --git a/connexion/problem.py b/connexion/problem.py index b98fb54e7..1cf5f4453 100644 --- a/connexion/problem.py +++ b/connexion/problem.py @@ -19,21 +19,20 @@ def problem(status, title, detail, type='about:blank', instance=None, headers=No Returns a `Problem Details `_ error response. - :param type: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable - documentation for the problem type (e.g., using HTML). When this member is not present its value is - assumed to be "about:blank". - :type: type: str + :param status: The HTTP status code generated by the origin server for this occurrence of the problem. + :type status: int :param title: A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localisation. :type title: str :param detail: An human readable explanation specific to this occurrence of the problem. :type detail: str - :param status: The HTTP status code generated by the origin server for this occurrence of the problem. - :type status: int + :param type: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable + documentation for the problem type (e.g., using HTML). When this member is not present its value is + assumed to be "about:blank". + :type: type: str :param instance: An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. :type instance: str - :type type: str | None :param headers: HTTP headers to include in the response :type headers: dict | None :param ext: Extension members to include in the body diff --git a/tests/fakeapi/secure_api.yaml b/tests/fakeapi/secure_api.yaml new file mode 100644 index 000000000..cee7f3fac --- /dev/null +++ b/tests/fakeapi/secure_api.yaml @@ -0,0 +1,38 @@ +swagger: "2.0" + +info: + title: "{{title}}" + version: "1.0" + +basePath: /v1.0 + +securityDefinitions: + oauth: + type: oauth2 + flow: password + tokenUrl: https://ouath.example/token + x-tokenInfoUrl: https://ouath.example/token_info + scopes: + myscope: can do stuff + +security: + - oauth: + - myscope + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: fakeapi.hello.post_greeting + responses: + 200: + description: greeting response + schema: + type: object + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + type: string diff --git a/tests/test_app.py b/tests/test_app.py index c2776cd77..b08b61630 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -723,3 +723,33 @@ def test_redirect_response_endpoint(app): app_client = app.app.test_client() resp = app_client.get('/v1.0/test-redirect-response-endpoint') assert resp.status_code == 302 + + +def test_security_over_inexistent_endpoints(oauth_requests): + app1 = App(__name__, 5001, SPEC_FOLDER, swagger_ui=False, debug=True, auth_all_paths=True) + app1.add_api('secure_api.yaml') + assert app1.port == 5001 + + app_client = app1.app.test_client() + headers = {"Authorization": "Bearer 300"} + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-invalid-token', headers=headers) # type: flask.Response + assert get_inexistent_endpoint.status_code == 401 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + headers = {"Authorization": "Bearer 100"} + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-valid-token', headers=headers) # type: flask.Response + assert get_inexistent_endpoint.status_code == 404 + assert get_inexistent_endpoint.content_type == 'application/problem+json' + + get_inexistent_endpoint = app_client.get('/v1.0/does-not-exist-no-token') # type: flask.Response + assert get_inexistent_endpoint.status_code == 401 + + swagger_ui = app_client.get('/v1.0/ui/') # type: flask.Response + assert swagger_ui.status_code == 401 + + headers = {"Authorization": "Bearer 100"} + post_greeting = app_client.post('/v1.0/greeting/rcaricio', data={}, headers=headers) # type: flask.Response + assert post_greeting.status_code == 200 + + post_greeting = app_client.post('/v1.0/greeting/rcaricio', data={}) # type: flask.Response + assert post_greeting.status_code == 401 diff --git a/tests/test_operation.py b/tests/test_operation.py index 3364ebc1a..e3fb9e5d2 100644 --- a/tests/test_operation.py +++ b/tests/test_operation.py @@ -249,8 +249,8 @@ def test_operation(): assert isinstance(operation.function, types.FunctionType) # security decorator should be a partial with verify_oauth as the function and token url and scopes as arguments. # See https://docs.python.org/2/library/functools.html#partial-objects - assert operation._Operation__security_decorator.func is verify_oauth - assert operation._Operation__security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) + assert operation.security_decorator.func is verify_oauth + assert operation.security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) assert operation.method == 'GET' assert operation.produces == ['application/json'] @@ -276,8 +276,8 @@ def test_operation_array(): assert isinstance(operation.function, types.FunctionType) # security decorator should be a partial with verify_oauth as the function and token url and scopes as arguments. # See https://docs.python.org/2/library/functools.html#partial-objects - assert operation._Operation__security_decorator.func is verify_oauth - assert operation._Operation__security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) + assert operation.security_decorator.func is verify_oauth + assert operation.security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) assert operation.method == 'GET' assert operation.produces == ['application/json'] @@ -303,8 +303,8 @@ def test_operation_composed_definition(): assert isinstance(operation.function, types.FunctionType) # security decorator should be a partial with verify_oauth as the function and token url and scopes as arguments. # See https://docs.python.org/2/library/functools.html#partial-objects - assert operation._Operation__security_decorator.func is verify_oauth - assert operation._Operation__security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) + assert operation.security_decorator.func is verify_oauth + assert operation.security_decorator.args == ('https://ouath.example/token_info', set(['uid'])) assert operation.method == 'GET' assert operation.produces == ['application/json'] @@ -381,7 +381,7 @@ def test_no_token_info(): parameter_definitions=PARAMETER_DEFINITIONS, resolver=Resolver()) assert isinstance(operation.function, types.FunctionType) - assert operation._Operation__security_decorator is security_passthrough + assert operation.security_decorator is security_passthrough assert operation.method == 'GET' assert operation.produces == ['application/json']