diff --git a/py2http/auth.py b/py2http/auth.py index 7756fc9..b1dc2a5 100644 --- a/py2http/auth.py +++ b/py2http/auth.py @@ -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",) @@ -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.""" diff --git a/py2http/bottle_plugins.py b/py2http/bottle_plugins.py index 894e569..de79df0 100644 --- a/py2http/bottle_plugins.py +++ b/py2http/bottle_plugins.py @@ -8,6 +8,7 @@ from bottle import request, response from py2http.auth import ( + AuthPlugin, DFLT_JWT_ALGORITHMS, CredentialsRejected, bearer_token, @@ -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 @@ -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, @@ -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 diff --git a/py2http/flask_plugins.py b/py2http/flask_plugins.py new file mode 100644 index 0000000..d26e6ee --- /dev/null +++ b/py2http/flask_plugins.py @@ -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 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 diff --git a/py2http/service.py b/py2http/service.py index 8124945..0024f9d 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -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, @@ -311,29 +312,38 @@ 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, @@ -341,10 +351,17 @@ def mk_flask_app(funcs, **configs): 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 diff --git a/py2http/tests/test_error_statuses.py b/py2http/tests/test_error_statuses.py index 7c21c55..8db33ad 100644 --- a/py2http/tests/test_error_statuses.py +++ b/py2http/tests/test_error_statuses.py @@ -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(): diff --git a/py2http/tests/test_flask_plugins.py b/py2http/tests/test_flask_plugins.py new file mode 100644 index 0000000..ae7747b --- /dev/null +++ b/py2http/tests/test_flask_plugins.py @@ -0,0 +1,189 @@ +"""The Bottle-only configs (auth ``plugins``, ``enable_cors``, ``publish_swagger``, +``openapi_insecure``) behave the same under Flask as under Bottle. + +Each test runs once per framework, through a real ``mk_app`` app, so a divergence between +the two shows up as a failure of one parameter. +""" + +import pytest + +jwt = pytest.importorskip("jwt") +pytest.importorskip("flask") + +from py2http import mk_app +from py2http.bottle_plugins import ApiKeyAuthPlugin, JWTPlugin +from py2http.service import current_request +from py2http.tests.utils_for_testing import call_wsgi + +SECRET = "s3cret-for-tests-only-0123456789abcdef" +API_KEY = "api-key-for-tests-only" + +frameworks = pytest.mark.parametrize("framework", ["bottle", "flask"]) + + +def whoami(): + return getattr(current_request.get(), "token", None) + + +def other(): + return "other" + + +def _app(framework, **configs): + return mk_app([whoami, other], framework=framework, **configs) + + +def _bearer(claims=None, *, key=SECRET): + return {"Authorization": "Bearer " + jwt.encode(claims or {"sub": "alice"}, key)} + + +def _status(app, path, *, method="POST", headers=()): + return call_wsgi( + app, path, {} if method == "POST" else None, method=method, headers=headers + )[0][:3] + + +# --------------------------------------------------------------------------- auth + + +@frameworks +def test_jwt_guards_routes_and_exposes_claims(framework): + app = _app(framework, plugins=[JWTPlugin(SECRET, mapper={"sub": "user_id"})]) + status, body = call_wsgi(app, "/whoami", {}, headers=_bearer()) + assert status.startswith("200") and body == {"user_id": "alice"} + assert _status(app, "/whoami") == "401" + assert ( + _status(app, "/other", headers=_bearer(key="wrong-secret-0123456789abcdef0123")) + == "401" + ) + assert _status(app, "/other", headers={"Authorization": "Bearer garbage"}) == "401" + + +@frameworks +def test_unauthorized_answer_is_json_with_a_challenge(framework): + app = _app(framework, plugins=[JWTPlugin(SECRET)]) + status, body, headers = call_wsgi(app, "/other", {}, with_headers=True) + assert status.startswith("401") and "error" in body + assert headers["www-authenticate"] == "Bearer" + assert headers["content-type"].startswith("application/json") + app = _app(framework, plugins=[ApiKeyAuthPlugin(API_KEY)]) + assert ( + call_wsgi(app, "/other", {}, with_headers=True)[2]["www-authenticate"] + == "ApiKey" + ) + + +@frameworks +def test_ping_is_public_and_openapi_follows_openapi_insecure(framework): + plugins = [JWTPlugin(SECRET)] + app = _app(framework, plugins=plugins, publish_openapi=True) + assert _status(app, "/ping", method="GET") == "200" + assert _status(app, "/openapi", method="GET") == "401" + app = _app(framework, plugins=plugins, publish_openapi=True, openapi_insecure=True) + assert _status(app, "/openapi", method="GET") == "200" + assert _status(app, "/other") == "401" # the routes stay guarded + + +@frameworks +def test_ignore_methods_and_api_key(framework): + app = _app(framework, plugins=[JWTPlugin(SECRET, ignore_methods=["other"])]) + assert _status(app, "/other") == "200" + assert _status(app, "/whoami") == "401" + app = _app(framework, plugins=[ApiKeyAuthPlugin(API_KEY)]) + assert _status(app, "/other", headers={"Authorization": API_KEY}) == "200" + assert _status(app, "/other", headers={"Authorization": "nope"}) == "401" + assert _status(app, "/other") == "401" + + +@frameworks +def test_every_plugin_must_pass(framework): + app = _app(framework, plugins=[JWTPlugin(SECRET), ApiKeyAuthPlugin(API_KEY)]) + # a valid JWT is not the API key, and the key is not a JWT + assert _status(app, "/other", headers=_bearer()) == "401" + assert _status(app, "/other", headers={"Authorization": API_KEY}) == "401" + + +@frameworks +def test_unknown_paths_and_methods_are_not_views(framework): + app = _app(framework, plugins=[JWTPlugin(SECRET)]) + assert _status(app, "/nowhere", method="GET") in ("404", "401") + assert _status(app, "/other", method="GET") in ("405", "401") + + +def test_a_bottle_only_plugin_is_refused_under_flask(): + class Mine: + def __call__(self, handler): + return handler + + def authenticate(self, header, *, method, endpoint): # not an AuthPlugin + return False + + with pytest.raises(NotImplementedError, match="Bottle-only"): + _app("flask", plugins=[Mine()]) + + +# --------------------------------------------------------------------------- CORS + + +@frameworks +def test_cors_headers_on_answers_and_preflight(framework): + app = _app(framework, enable_cors=True, cors_allowed_origins="https://a.example") + _, _, headers = call_wsgi(app, "/other", {}, with_headers=True) + assert headers["access-control-allow-origin"] == "https://a.example" + assert "Authorization" in headers["access-control-allow-headers"] + status, _, headers = call_wsgi(app, "/other", method="OPTIONS", with_headers=True) + assert status.startswith("200") + assert headers["access-control-allow-origin"] == "https://a.example" + + +@frameworks +def test_cors_off_adds_no_headers(framework): + _, _, headers = call_wsgi(_app(framework), "/other", {}, with_headers=True) + assert "access-control-allow-origin" not in headers + + +@frameworks +def test_cors_with_auth_preflight_open_and_refusals_carry_headers(framework): + app = _app(framework, enable_cors=True, plugins=[ApiKeyAuthPlugin(API_KEY)]) + status, _, headers = call_wsgi(app, "/other", method="OPTIONS", with_headers=True) + assert status.startswith("200") and "access-control-allow-origin" in headers + status, _, headers = call_wsgi(app, "/other", {}, with_headers=True) + assert status.startswith("401") and "access-control-allow-origin" in headers + + +# ------------------------------------------------------------------------ swagger + + +@frameworks +def test_swagger_is_served_when_published(framework): + app = _app( + framework, publish_openapi=True, publish_swagger=True, swagger_url="/docs" + ) + assert _status(app, "/docs", method="GET") in ("200", "301", "302", "308") + assert _status(_app(framework), "/docs", method="GET") == "404" + + +@frameworks +def test_swagger_is_guarded_by_the_plugins(framework): + app = _app( + framework, + plugins=[ApiKeyAuthPlugin(API_KEY)], + publish_swagger=True, + swagger_url="/docs", + ) + assert _status(app, "/docs/", method="GET") == "401" + + +@frameworks +def test_a_user_function_named_openapi_stays_guarded(framework): + def openapi(): + return "mine" + + app = mk_app( + [openapi], + framework=framework, + plugins=[ApiKeyAuthPlugin(API_KEY)], + openapi_insecure=True, + ) + assert _status(app, "/openapi") == "401" + assert _status(app, "/openapi", headers={"Authorization": API_KEY}) == "200"