Skip to content
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
17 changes: 17 additions & 0 deletions py2http/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from collections.abc import Iterable, Mapping
from typing import Optional

from py2http.constants import BEARER_CHALLENGE

logger = logging.getLogger("py2http")

DFLT_JWT_ALGORITHMS = ("HS256",)
Expand All @@ -21,6 +23,21 @@
_BEARER_PREFIX = "bearer "


class AuthPlugin:
"""What a plugin must be to guard a Flask app (see ``py2http.flask_plugins``): a
subclass with a ``challenge`` (the ``WWW-Authenticate`` value of a refusal) and an
``authenticate`` that raises ``CredentialsRejected`` to refuse a request. It is a
marker class rather than a duck-typed method name, so that a plugin which merely has
some ``authenticate`` method (returning ``False`` to refuse, say) can't be mistaken
for one and let everything through."""

challenge: str = BEARER_CHALLENGE

def authenticate(self, authorization_header, *, method, endpoint):
"""The claims to put on the request, or ``None``; ``CredentialsRejected`` refuses."""
raise NotImplementedError


class CredentialsRejected(Exception):
"""A request's credentials were refused. The message is safe to show the client:
it never contains the token, nor anything about the server's key."""
Expand Down
59 changes: 45 additions & 14 deletions py2http/bottle_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from bottle import request, response

from py2http.auth import (
AuthPlugin,
DFLT_JWT_ALGORITHMS,
CredentialsRejected,
bearer_token,
Expand Down Expand Up @@ -38,7 +39,7 @@ def _unauthorized(message, challenge=BEARER_CHALLENGE):
return json.dumps({"error": message})


class JWTPlugin:
class JWTPlugin(AuthPlugin):
"""A plugin for validating JWTs and extracting their payloads.

After optionally validating a JWT found in the request header, will assign a dict with the JWT claims
Expand Down Expand Up @@ -82,10 +83,12 @@ def __init__(
)
self._decode_kwargs = jwt_decode_kwargs(decode_kwargs)

def _claims(self):
"""The (renamed) claims of the request's token; raises ``CredentialsRejected``."""
challenge = BEARER_CHALLENGE

def _claims(self, authorization_header):
"""The (renamed) claims of the token in the header; raises ``CredentialsRejected``."""
decoded = decode_jwt(
bearer_token(_authorization_header()),
bearer_token(authorization_header),
self._secret,
algorithms=self._algorithms,
verify=self._verify,
Expand All @@ -96,38 +99,66 @@ def _claims(self):
decoded[v] = decoded.pop(k)
return decoded

def authenticate(self, authorization_header, *, method, endpoint):
"""The claims to put on the request (``None`` when there are none to put), or
``CredentialsRejected``. Framework-independent: the Bottle plugin and the Flask
``before_request`` hook (``py2http.flask_plugins``) both call it."""
if method == OPTIONS or endpoint in self._ignore_methods:
return None
try:
return self._claims(authorization_header)
except CredentialsRejected as rejection:
if self._verify:
raise
warn(f"Invalid JWT: {rejection.__cause__}")
return None

def __call__(self, handler):
if getattr(handler, "method_name", None) in self._ignore_methods:
return handler

@wraps(handler)
def wrapped_handler(*args, **kwargs):
if request.method == OPTIONS:
return handler(*args, **kwargs)
try:
request.token = self._claims()
claims = self.authenticate(
_authorization_header(),
method=request.method,
endpoint=getattr(handler, "method_name", None),
)
except CredentialsRejected as rejection:
if self._verify:
return _unauthorized(str(rejection))
warn(f"Invalid JWT: {rejection.__cause__}")
return _unauthorized(str(rejection))
if claims is not None:
request.token = claims
return handler(*args, **kwargs)

return wrapped_handler


class ApiKeyAuthPlugin:
class ApiKeyAuthPlugin(AuthPlugin):
"""A plugin letting through only requests whose ``Authorization`` header is
exactly ``api_key`` (compared in constant time; an empty key is refused)."""

challenge = API_KEY_CHALLENGE

def __init__(self, api_key: str):
self._api_key = required_key(api_key)

def authenticate(self, authorization_header, *, method, endpoint):
"""``None`` (the key sets nothing on the request) if the header is the key,
else ``CredentialsRejected``. Framework-independent, like ``JWTPlugin``'s."""
if not key_matches(authorization_header, self._api_key):
raise CredentialsRejected("invalid API key")

def __call__(self, handler):
@wraps(handler)
def wrapped_handler(*args, **kwargs):
if key_matches(_authorization_header(), self._api_key):
return handler(*args, **kwargs)
return _unauthorized("invalid API key", API_KEY_CHALLENGE)
try:
self.authenticate(
_authorization_header(), method=request.method, endpoint=None
)
except CredentialsRejected as rejection:
return _unauthorized(str(rejection), self.challenge)
return handler(*args, **kwargs)

return wrapped_handler

Expand Down
107 changes: 107 additions & 0 deletions py2http/flask_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Flask counterparts of the Bottle plugins: auth as a ``before_request`` hook, CORS as an
``after_request`` hook.

The credential checks are the plugins' own (``JWTPlugin.authenticate``,
``ApiKeyAuthPlugin.authenticate``, which call ``py2http.auth``), so Bottle and Flask
refuse the same requests for the same reasons. A plugin without an ``authenticate``
method is a Bottle-only plugin: it is refused with ``NotImplementedError`` rather than
dropped, because a silently dropped auth plugin would leave its routes open.
"""

import json
from collections.abc import Iterable

from py2http.auth import AuthPlugin, CredentialsRejected
from py2http.constants import JSON_CONTENT_TYPE

OPTIONS = "OPTIONS"
_CORS_METHODS = "GET, POST, PUT, DELETE, OPTIONS"
_CORS_HEADERS = (
"Origin, Accept, Content-Type, X-Requested-With, Authorization, X-api-key"
)


def check_flask_plugins(plugins: Iterable | None) -> list:
"""The ``plugins`` as a list, each known to work under Flask; else ``NotImplementedError``.

>>> check_flask_plugins(None)
[]
>>> check_flask_plugins([object()]) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
NotImplementedError: The plugin <object object at ...> is Bottle-only...
"""
plugins = list(plugins or ())
for plugin in plugins:
if not isinstance(plugin, AuthPlugin):
raise NotImplementedError(
f"The plugin {plugin!r} is Bottle-only: under the framework 'flask' only "
"`py2http.auth.AuthPlugin`s (JWTPlugin, ApiKeyAuthPlugin) are supported"
)
return plugins


def _unauthorized(message: str, challenge: str):
from flask import Response

response = Response(
json.dumps({"error": message}), status=401, content_type=JSON_CONTENT_TYPE
)
response.headers["WWW-Authenticate"] = challenge
return response


def mk_auth_hook(plugins: list, *, public_views: Iterable, cors: bool = False):
"""A ``before_request`` function running ``plugins`` (outermost first, as under Bottle).

Everything is guarded except: the ``public_views`` (the view *functions* themselves,
not endpoint names: a user function named ``openapi`` must stay guarded); a request that matched no
route (it answers 404/405, as under Bottle, and runs no view); and, with ``cors``,
the ``OPTIONS`` preflight, which Bottle's CORS plugin answers before any auth runs.
A plugin's claims go on ``request.token``, where the endpoint finds them through
``current_request``.
"""
from flask import current_app, request

public = list(public_views)

def authenticate_request():
endpoint = request.endpoint
if endpoint is None:
return None
view = current_app.view_functions.get(endpoint)
if any(view is public_view for public_view in public):
return None
if cors and request.method == OPTIONS:
return None
try:
header = request.headers.get("Authorization", "")
except UnicodeError: # a header that is not text is refused by every check
header = ""
for plugin in plugins:
try:
claims = plugin.authenticate(
header, method=request.method, endpoint=endpoint
)
except CredentialsRejected as rejection:
return _unauthorized(str(rejection), plugin.challenge)
if claims is not None:
request.token = claims
return None

return authenticate_request


def mk_cors_hook(origins: str = "*"):
"""An ``after_request`` function adding the CORS headers ``CorsPlugin`` adds.

Flask answers the ``OPTIONS`` preflight itself, so this only adds the headers.
"""

def add_cors_headers(response):
response.headers["Access-Control-Allow-Origin"] = origins
response.headers["Access-Control-Allow-Methods"] = _CORS_METHODS
response.headers["Access-Control-Allow-Headers"] = _CORS_HEADERS
return response

return add_cors_headers
53 changes: 35 additions & 18 deletions py2http/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from i2 import Sig

from py2http.bottle_plugins import CorsPlugin, OPTIONS
from py2http.flask_plugins import check_flask_plugins, mk_auth_hook, mk_cors_hook
from py2http.config import mk_config, FLASK, AIOHTTP, BOTTLE
from py2http.default_configs import (
default_configs,
Expand Down Expand Up @@ -311,40 +312,56 @@ def mk_routes_and_openapi_specs(funcs, **configs):
AppSpec = Union[Handlers, Dict[str, Union[Handlers, SubAppSpec]]]


#: Configs only the Bottle app honours. A Flask app refuses them rather than ignore
#: them: a silently dropped auth plugin would leave its routes open.
_BOTTLE_ONLY_CONFIGS = ("plugins", "enable_cors", "publish_swagger", "openapi_insecure")


def mk_flask_app(funcs, **configs):
"""A Flask app serving ``funcs``. The JSON, raw, binary and form input mappers and
the ``send_*_resp`` output mappers work as under Bottle; ``middleware`` is a list
of WSGI wrappers; ``plugins``, ``enable_cors``, ``publish_swagger`` and
``openapi_insecure`` are Bottle's, and raise ``NotImplementedError`` here."""
of WSGI wrappers.

As under Bottle, ``plugins`` guards every route (``JWTPlugin``, ``ApiKeyAuthPlugin``;
a Bottle-only plugin raises ``NotImplementedError``) except ``/ping``, and except
``/openapi`` when ``openapi_insecure``; ``enable_cors`` adds the CORS headers
(``cors_allowed_origins``) to every response; ``publish_swagger`` serves the Swagger
UI at ``swagger_url``, guarded by ``plugins`` like any other route. See
``py2http.flask_plugins``."""
from flask import Flask

unsupported = [k for k in _BOTTLE_ONLY_CONFIGS if configs.get(k)]
if unsupported:
raise NotImplementedError(
f"The config {unsupported} is not supported for the framework 'flask'"
)
configs = dict(configs, framework=FLASK) # Flask routes whatever the default
get_config = partial(mk_config, func=None, configs=configs, defaults=default_configs)
plugins = check_flask_plugins(get_config("plugins"))
routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs)
app_name = mk_config("app_name", None, configs, default_configs)
app = Flask(app_name)
for wrap in mk_config("middleware", None, configs, default_configs) or ():
app = Flask(get_config("app_name"))
for wrap in get_config("middleware") or ():
app.wsgi_app = wrap(app.wsgi_app)
enable_cors = get_config("enable_cors")
publish_openapi = get_config("publish_openapi")
ping_view = lambda: {"ping": "pong"} # noqa: E731
openapi_view = lambda: openapi_spec # noqa: E731
if plugins:
# the view functions, not their endpoint names: a user's `openapi` stays guarded
public = [ping_view] + ([openapi_view] if get_config("openapi_insecure") else [])
app.before_request(
mk_auth_hook(plugins, public_views=public, cors=bool(enable_cors))
)
if enable_cors:
app.after_request(mk_cors_hook(get_config("cors_allowed_origins")))
for route in routes:
app.add_url_rule(
route.path,
route.method_name,
route,
methods=[route.http_method.upper()],
)
app.add_url_rule("/ping", "ping", lambda: {"ping": "pong"})
if mk_config("publish_openapi", None, configs, default_configs):
app.add_url_rule("/openapi", "openapi", lambda: openapi_spec)
app.add_url_rule("/ping", "ping", ping_view)
if publish_openapi:
app.add_url_rule("/openapi", "openapi", openapi_view)
app.openapi_spec = openapi_spec
if get_config("publish_swagger"):
api_doc(
app,
config_spec=json.dumps(openapi_spec),
url_prefix=get_config("swagger_url"),
title=get_config("swagger_title"),
)
return app


Expand Down
5 changes: 2 additions & 3 deletions py2http/tests/test_error_statuses.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,8 @@ def test_unsupported_framework_is_refused():
def test_flask_refuses_what_it_would_ignore():
"""An auth plugin dropped silently would leave the routes open."""
pytest.importorskip("flask")
for config in ({"plugins": [object()]}, {"enable_cors": True}):
with pytest.raises(NotImplementedError):
mk_app([_app_raising], framework="flask", **config)
with pytest.raises(NotImplementedError):
mk_app([_app_raising], framework="flask", plugins=[object()])


def test_flask_openapi_only_when_published():
Expand Down
Loading
Loading