Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework security flow #686

Merged
merged 5 commits into from
Sep 28, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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