Skip to content

Commit

Permalink
Rework security flow (#686)
Browse files Browse the repository at this point in the history
* Rework security

* Don't inject context into flask.request (Backward incompatible change!)

* Update docu/examples
  • Loading branch information
cziebuhr authored and jmcs committed Sep 28, 2018
1 parent e2fefb0 commit ed6535e
Show file tree
Hide file tree
Showing 22 changed files with 523 additions and 417 deletions.
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ New in Connexion 2.0:
All spec validation errors should be wrapped with `InvalidSpecification`.
- Support for nullable/x-nullable, readOnly and writeOnly/x-writeOnly has been added to the standard json schema validator.
- Custom validators can now be specified on api level (instead of app level).
- Added support for basic authentication and apikey authentication
- If unsupported security requirements are defined or ``x-tokenInfoFunc``/``x-tokenInfoUrl`` is missing, connexion now denies requests instead of allowing access without security-check.
- Accessing ``connexion.request.user`` / ``flask.request.user`` is no longer supported, use ``connexion.context['user']`` instead

How to Use
==========
Expand Down
2 changes: 1 addition & 1 deletion connexion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def _required_lib(exec_info, *args, **kwargs):


try:
from .apis.flask_api import FlaskApi
from .apis.flask_api import FlaskApi, context # NOQA
from .apps.flask_app import FlaskApp
from flask import request # NOQA
except ImportError: # pragma: no cover
Expand Down
23 changes: 7 additions & 16 deletions connexion/apis/flask_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import flask
import six
import werkzeug.exceptions
from werkzeug.local import LocalProxy

from connexion.apis import flask_utils
from connexion.apis.abstract import AbstractAPI
Expand Down Expand Up @@ -218,6 +219,8 @@ def get_request(cls, *args, **params):
:rtype: ConnexionRequest
"""
context_dict = {}
setattr(flask._request_ctx_stack.top, 'connexion_context', context_dict)
flask_request = flask.request
request = ConnexionRequest(
flask_request.url,
Expand All @@ -229,7 +232,7 @@ def get_request(cls, *args, **params):
json_getter=lambda: flask_request.get_json(silent=True),
files=flask_request.files,
path_params=params,
context=FlaskRequestContextProxy()
context=context_dict
)
logger.debug('Getting data and status code',
extra={
Expand All @@ -247,23 +250,11 @@ def _set_jsonifier(cls):
cls.jsonifier = Jsonifier(flask.json)


class FlaskRequestContextProxy(object):
""""Proxy assignments from `ConnexionRequest.context`
to `flask.request` instance.
"""

def __init__(self):
self.values = {}
def _get_context():
return getattr(flask._request_ctx_stack.top, 'connexion_context')

def __setitem__(self, key, value):
# type: (str, Any) -> None
logger.debug('Setting "%s" attribute in flask.request', key)
setattr(flask.request, key, value)
self.values[key] = value

def items(self):
# type: () -> list
return self.values.items()
context = LocalProxy(_get_context)


class InternalHandlers(object):
Expand Down
259 changes: 176 additions & 83 deletions connexion/decorators/security.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Authentication and authorization related decorators
import base64
import functools
import logging
import os
Expand All @@ -8,7 +9,8 @@

from connexion.utils import get_function_from_name

from ..exceptions import OAuthProblem, OAuthResponseProblem, OAuthScopeProblem
from ..exceptions import (ConnexionException, OAuthProblem,
OAuthResponseProblem, OAuthScopeProblem)

logger = logging.getLogger('connexion.api.security')

Expand All @@ -24,25 +26,65 @@ def get_tokeninfo_func(security_definition):
:type security_definition: dict
:rtype: function
>>> get_tokeninfo_url({'x-tokenInfoFunc': 'foo.bar>'})
>>> get_tokeninfo_url({'x-tokenInfoFunc': 'foo.bar'})
'<function foo.bar>'
"""
token_info_func = (security_definition.get("x-tokenInfoFunc") or
os.environ.get('TOKENINFO_FUNC'))
return get_function_from_name(token_info_func) if token_info_func else None
if token_info_func:
return get_function_from_name(token_info_func)

token_info_url = (security_definition.get('x-tokenInfoUrl') or
os.environ.get('TOKENINFO_URL'))
if token_info_url:
return functools.partial(get_tokeninfo_remote, token_info_url)

return None

def get_tokeninfo_url(security_definition):

def get_scope_validate_func(security_definition):
"""
:type security_definition: dict
:rtype: str
:rtype: function
>>> get_tokeninfo_url({'x-tokenInfoUrl': 'foo'})
'foo'
>>> get_scope_validate_func({'x-scopeValidateFunc': 'foo.bar'})
'<function foo.bar>'
"""
token_info_url = (security_definition.get('x-tokenInfoUrl') or
os.environ.get('TOKENINFO_URL'))
return token_info_url
func = (security_definition.get("x-scopeValidateFunc") or
os.environ.get('SCOPEVALIDATE_FUNC'))
if func:
return get_function_from_name(func)
return validate_scope


def get_basicinfo_func(security_definition):
"""
:type security_definition: dict
:rtype: function
>>> get_basicinfo_func({'x-basicInfoFunc': 'foo.bar'})
'<function foo.bar>'
"""
func = (security_definition.get("x-basicInfoFunc") or
os.environ.get('BASICINFO_FUNC'))
if func:
return get_function_from_name(func)
return None


def get_apikeyinfo_func(security_definition):
"""
:type security_definition: dict
:rtype: function
>>> get_apikeyinfo_func({'x-apikeyInfoFunc': 'foo.bar'})
'<function foo.bar>'
"""
func = (security_definition.get("x-apikeyInfoFunc") or
os.environ.get('APIKEYINFO_FUNC'))
if func:
return get_function_from_name(func)
return None


def security_passthrough(function):
Expand All @@ -53,104 +95,155 @@ def security_passthrough(function):
return function


def get_authorization_token(request):
authorization = request.headers.get('Authorization') # type: str
if not authorization:
logger.info("... No auth provided. Aborting with 401.")
raise OAuthProblem(description='No authorization token provided')
else:
try:
_, token = authorization.split() # type: str, str
except ValueError:
raise OAuthProblem(description='Invalid authorization header')
return token
def security_deny(function):
"""
:type function: types.FunctionType
:rtype: types.FunctionType
"""
def deny(*args, **kwargs):
raise ConnexionException("Error in security definitions")
return deny


def get_authorization_info(auth_funcs, request, required_scopes):
for func in auth_funcs:
token_info = func(request, required_scopes)
if token_info is not None:
return token_info

def validate_token_info(token_info, allowed_scopes):
logger.info("... No auth provided. Aborting with 401.")
raise OAuthProblem(description='No authorization token provided')


def validate_scope(required_scopes, token_scopes):
"""
:param allowed_scopes:
:param token_info: Dictionary containing the token_info
:type token_info: dict
:return: None
:param required_scopes: Scopes required to access operation
:param token_scopes: Scopes granted by authorization server
:rtype: bool
"""
scope = token_info.get('scope') or token_info.get('scopes')
if isinstance(scope, list):
user_scopes = set(scope)
required_scopes = set(required_scopes)
if isinstance(token_scopes, list):
token_scopes = set(token_scopes)
else:
user_scopes = set(scope.split())
logger.debug("... Scopes required: %s", allowed_scopes)
logger.debug("... User scopes: %s", user_scopes)
if not allowed_scopes <= user_scopes:
token_scopes = set(token_scopes.split())
logger.debug("... Scopes required: %s", required_scopes)
logger.debug("... Token scopes: %s", token_scopes)
if not required_scopes <= token_scopes:
logger.info(textwrap.dedent("""
... User scopes (%s) do not match the scopes necessary to call endpoint (%s).
... Token scopes (%s) do not match the scopes necessary to call endpoint (%s).
Aborting with 403.""").replace('\n', ''),
user_scopes, allowed_scopes)
raise OAuthScopeProblem(
description='Provided token doesn\'t have the required scope',
required_scopes=allowed_scopes,
token_scopes=user_scopes
)
logger.info("... Token authenticated.")
token_scopes, required_scopes)
return False
return True


def verify_oauth_local(token_info_func, allowed_scopes, function):
"""
Decorator to verify oauth locally
def verify_oauth(token_info_func, scope_validate_func):
def wrapper(request, required_scopes):
authorization = request.headers.get('Authorization')
if not authorization:
return None

:param token_info_func: Function to get information about the token
:type token_info_func: Function
:param allowed_scopes: Set with scopes that are allowed to access the endpoint
:type allowed_scopes: set
:type function: types.FunctionType
:rtype: types.FunctionType
"""
try:
auth_type, token = authorization.split(None, 1)
except ValueError:
raise OAuthProblem(description='Invalid authorization header')

@functools.wraps(function)
def wrapper(request):
logger.debug("%s Oauth local verification...", request.url)
if auth_type.lower() != 'bearer':
return None

token = get_authorization_token(request)
token_info = token_info_func(token)
if token_info is None:
raise OAuthResponseProblem(
description='Provided oauth token is not valid',
token_response=token_info
token_response=None
)
validate_token_info(token_info, allowed_scopes)
request.context['user'] = token_info.get('uid')
request.context['token_info'] = token_info
return function(request)

# Fallback to 'scopes' for backward compability
token_scopes = token_info.get('scope', token_info.get('scopes', ''))
if not scope_validate_func(required_scopes, token_scopes):
raise OAuthScopeProblem(
description='Provided token doesn\'t have the required scope',
required_scopes=required_scopes,
token_scopes=token_scopes
)

return token_info
return wrapper


def verify_oauth_remote(token_info_url, allowed_scopes, function):
"""
Decorator to verify oauth remotely using HTTP
def verify_basic(basic_info_func):
def wrapper(request, required_scopes):
authorization = request.headers.get('Authorization')
if not authorization:
return None

:param token_info_url: Url to get information about the token
:type token_info_url: str
:param allowed_scopes: Set with scopes that are allowed to access the endpoint
:type allowed_scopes: set
:type function: types.FunctionType
:rtype: types.FunctionType
"""
try:
auth_type, user_pass = authorization.split(None, 1)
except ValueError:
raise OAuthProblem(description='Invalid authorization header')

@functools.wraps(function)
def wrapper(request):
logger.debug("%s Oauth remote verification...", request.url)
token = get_authorization_token(request)
logger.debug("... Getting token from %s", token_info_url)
token_request = session.get(token_info_url, headers={'Authorization': 'Bearer {}'.format(token)}, timeout=5)
logger.debug("... Token info (%d): %s", token_request.status_code, token_request.text)
if not token_request.ok:
if auth_type.lower() != 'basic':
return None

try:
username, password = base64.b64decode(user_pass).decode('latin1').split(':', 1)
except Exception:
raise OAuthProblem(description='Invalid authorization header')

token_info = basic_info_func(username, password, required_scopes=required_scopes)
if token_info is None:
raise OAuthResponseProblem(
description='Provided oauth token is not valid',
token_response=token_request
description='Provided authorization is not valid',
token_response=None
)
return token_info
return wrapper


def verify_apikey(apikey_info_func, loc, name):
def wrapper(request, required_scopes):
if loc == 'query':
apikey = request.query.get(name)
elif loc == 'header':
apikey = request.headers.get(name)
else:
return None

if apikey is None:
return None

token_info = apikey_info_func(apikey, required_scopes=required_scopes)
if token_info is None:
raise OAuthResponseProblem(
description='Provided apikey is not valid',
token_response=None
)
return token_info
return wrapper

token_info = token_request.json() # type: dict
validate_token_info(token_info, allowed_scopes)
request.context['user'] = token_info.get('uid')

def verify_security(auth_funcs, required_scopes, function):
@functools.wraps(function)
def wrapper(request):
token_info = get_authorization_info(auth_funcs, request, required_scopes)

# Fallback to 'uid' for backward compability
request.context['user'] = token_info.get('sub', token_info.get('uid'))
request.context['token_info'] = token_info
return function(request)
return wrapper


def get_tokeninfo_remote(token_info_url, token):
"""
Retrieve oauth token_info remotely using HTTP
:param token_info_url: Url to get information about the token
:type token_info_url: str
:param token: oauth token from authorization header
:type token: str
:rtype: dict
"""
token_request = session.get(token_info_url, headers={'Authorization': 'Bearer {}'.format(token)}, timeout=5)
if not token_request.ok:
return None
return token_request.json()
1 change: 0 additions & 1 deletion connexion/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ class OAuthScopeProblem(Forbidden):
def __init__(self, token_scopes, required_scopes, **kwargs):
self.required_scopes = required_scopes
self.token_scopes = token_scopes
self.missing_scopes = required_scopes - token_scopes

super(OAuthScopeProblem, self).__init__(**kwargs)

Expand Down
Loading

0 comments on commit ed6535e

Please sign in to comment.