py2http 0.1.68__tar.gz → 0.1.70__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of py2http might be problematic. Click here for more details.
- {py2http-0.1.68 → py2http-0.1.70}/PKG-INFO +1 -1
- py2http-0.1.70/py2http/auth.py +174 -0
- py2http-0.1.70/py2http/bottle_plugins.py +154 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/constants.py +8 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/default_configs.py +44 -3
- py2http-0.1.70/py2http/middleware.py +126 -0
- py2http-0.1.70/py2http/obj_dispatch.py +711 -0
- py2http-0.1.70/py2http/service.py +623 -0
- py2http-0.1.70/py2http/sizeof.py +101 -0
- py2http-0.1.70/py2http/tests/test_auth_plugins.py +349 -0
- py2http-0.1.70/py2http/tests/test_error_statuses.py +65 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_instance_endpoints.py +3 -24
- py2http-0.1.70/py2http/tests/test_obj_measuring.py +263 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_obj_ownership.py +28 -25
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_positional_only.py +2 -17
- py2http-0.1.70/py2http/tests/utils_for_testing.py +80 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/SOURCES.txt +6 -0
- {py2http-0.1.68 → py2http-0.1.70}/setup.cfg +1 -1
- py2http-0.1.68/py2http/bottle_plugins.py +0 -114
- py2http-0.1.68/py2http/middleware.py +0 -154
- py2http-0.1.68/py2http/service.py +0 -1266
- py2http-0.1.68/py2http/tests/utils_for_testing.py +0 -26
- {py2http-0.1.68 → py2http-0.1.70}/LICENSE +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/README.md +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/__init__.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/config.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/decorators.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/diagnosis.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/route_utils.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/schema_tools.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_attr_dispatch.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_binary_req.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_jwt_middleware.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/types.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http/util.py +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.68 → py2http-0.1.70}/setup.py +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Framework-independent credential checks, shared by the aiohttp middleware
|
|
2
|
+
(``py2http.middleware``) and the Bottle plugins (``py2http.bottle_plugins``), so that
|
|
3
|
+
both refuse the same tokens and keys for the same reasons.
|
|
4
|
+
|
|
5
|
+
>>> bearer_token('Bearer abc'), bearer_token('bearer abc'), bearer_token('Basic abc')
|
|
6
|
+
('abc', 'abc', '')
|
|
7
|
+
>>> key_matches('k-123', 'k-123'), key_matches('k-12', 'k-123'), key_matches(None, 'k')
|
|
8
|
+
(True, False, False)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("py2http")
|
|
17
|
+
|
|
18
|
+
DFLT_JWT_ALGORITHMS = ("HS256",)
|
|
19
|
+
# ``jwt.decode`` arguments the verification setup owns, so ``decode_kwargs`` can't
|
|
20
|
+
_RESERVED_DECODE_KWARGS = frozenset({"jwt", "key", "algorithms", "options"})
|
|
21
|
+
_BEARER_PREFIX = "bearer "
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialsRejected(Exception):
|
|
25
|
+
"""A request's credentials were refused. The message is safe to show the client:
|
|
26
|
+
it never contains the token, nor anything about the server's key."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _algorithm_family(algorithm: str) -> str:
|
|
30
|
+
"""The key family an algorithm verifies with: a shared secret for ``HS*``, a
|
|
31
|
+
public key otherwise.
|
|
32
|
+
|
|
33
|
+
>>> _algorithm_family('HS512'), _algorithm_family('RS256'), _algorithm_family('ES256')
|
|
34
|
+
('hmac', 'public-key', 'public-key')
|
|
35
|
+
"""
|
|
36
|
+
return "hmac" if algorithm.upper().startswith("HS") else "public-key"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _names(name: str, value: Iterable[str]) -> list:
|
|
40
|
+
if isinstance(value, (str, bytes)):
|
|
41
|
+
raise TypeError(f"{name} must be a list of names, e.g. [{value!r}]")
|
|
42
|
+
return list(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def jwt_algorithms(
|
|
46
|
+
secret,
|
|
47
|
+
*,
|
|
48
|
+
verify: bool = True,
|
|
49
|
+
algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
|
|
50
|
+
allow_mixed_algorithm_families: bool = False,
|
|
51
|
+
) -> list:
|
|
52
|
+
"""Validate a JWT verification setup, returning the algorithms as a list.
|
|
53
|
+
|
|
54
|
+
Raises at construction time, rather than accepting forgeries at request time, when:
|
|
55
|
+
|
|
56
|
+
- ``algorithms`` is empty, a bare string, or names an unknown or unsigned (``none``)
|
|
57
|
+
algorithm (PyJWT names are case-sensitive);
|
|
58
|
+
- ``verify`` is True and ``secret`` is empty (an empty HMAC secret lets anyone sign
|
|
59
|
+
tokens), or an HMAC algorithm is asked for with a non-``str``/``bytes`` secret;
|
|
60
|
+
- ``verify`` is True and HMAC and public-key algorithms are mixed with one
|
|
61
|
+
``secret`` (the classic algorithm-confusion setup), unless
|
|
62
|
+
``allow_mixed_algorithm_families`` is True.
|
|
63
|
+
|
|
64
|
+
>>> jwt_algorithms('s3cret')
|
|
65
|
+
['HS256']
|
|
66
|
+
>>> jwt_algorithms('', algorithms=['HS256'])
|
|
67
|
+
Traceback (most recent call last):
|
|
68
|
+
...
|
|
69
|
+
ValueError: secret must be a non-empty key when verify=True
|
|
70
|
+
"""
|
|
71
|
+
import jwt
|
|
72
|
+
|
|
73
|
+
algorithms = _names("algorithms", algorithms)
|
|
74
|
+
if not algorithms:
|
|
75
|
+
raise ValueError("algorithms must name at least one JWT algorithm")
|
|
76
|
+
known = set(jwt.algorithms.get_default_algorithms()) - {"none"}
|
|
77
|
+
if unknown := [a for a in algorithms if a not in known]:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Unknown or unsigned JWT algorithm(s) {unknown}; names are "
|
|
80
|
+
f"case-sensitive, choose from {sorted(known)}"
|
|
81
|
+
)
|
|
82
|
+
if verify and not secret:
|
|
83
|
+
raise ValueError("secret must be a non-empty key when verify=True")
|
|
84
|
+
families = {_algorithm_family(a) for a in algorithms}
|
|
85
|
+
if verify and "hmac" in families and not isinstance(secret, (str, bytes)):
|
|
86
|
+
raise TypeError(f"an HMAC secret must be str or bytes, got {type(secret)}")
|
|
87
|
+
if verify and len(families) > 1 and not allow_mixed_algorithm_families:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"algorithms={algorithms} mixes HMAC and public-key algorithms with one "
|
|
90
|
+
"secret, which allows algorithm-confusion forgeries; use one family, or "
|
|
91
|
+
"pass allow_mixed_algorithm_families=True if you really mean it"
|
|
92
|
+
)
|
|
93
|
+
return algorithms
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def jwt_decode_kwargs(decode_kwargs: Optional[Mapping] = None) -> dict:
|
|
97
|
+
"""Validate extra ``jwt.decode`` arguments (``audience``, ``issuer``,
|
|
98
|
+
``leeway``...), refusing those the verification setup owns.
|
|
99
|
+
|
|
100
|
+
>>> jwt_decode_kwargs({'audience': 'my-api'})
|
|
101
|
+
{'audience': 'my-api'}
|
|
102
|
+
>>> jwt_decode_kwargs({'options': {'verify_signature': False}})
|
|
103
|
+
Traceback (most recent call last):
|
|
104
|
+
...
|
|
105
|
+
ValueError: decode_kwargs may not set ['options']; use verify= and algorithms=
|
|
106
|
+
"""
|
|
107
|
+
decode_kwargs = dict(decode_kwargs or {})
|
|
108
|
+
if reserved := sorted(_RESERVED_DECODE_KWARGS & set(decode_kwargs)):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"decode_kwargs may not set {reserved}; use verify= and algorithms="
|
|
111
|
+
)
|
|
112
|
+
return decode_kwargs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bearer_token(authorization_header) -> str:
|
|
116
|
+
"""The token of an ``Authorization: Bearer <token>`` header, or ``''``."""
|
|
117
|
+
header = authorization_header or ""
|
|
118
|
+
if header[: len(_BEARER_PREFIX)].lower() != _BEARER_PREFIX:
|
|
119
|
+
return ""
|
|
120
|
+
return header[len(_BEARER_PREFIX) :].strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def decode_jwt(
|
|
124
|
+
token: str,
|
|
125
|
+
secret,
|
|
126
|
+
*,
|
|
127
|
+
algorithms: list,
|
|
128
|
+
verify: bool = True,
|
|
129
|
+
decode_kwargs: Optional[Mapping] = None,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""The claims of ``token``, or ``CredentialsRejected`` with a client-safe reason.
|
|
132
|
+
|
|
133
|
+
With ``verify=False`` neither the signature nor the claims (``exp``...) are checked.
|
|
134
|
+
``decode_kwargs`` (``audience``, ``issuer``, ``leeway``...) go to ``jwt.decode``.
|
|
135
|
+
"""
|
|
136
|
+
import jwt
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
return jwt.decode(
|
|
140
|
+
token,
|
|
141
|
+
secret,
|
|
142
|
+
options={"verify_signature": verify},
|
|
143
|
+
algorithms=algorithms,
|
|
144
|
+
**(decode_kwargs or {}),
|
|
145
|
+
)
|
|
146
|
+
except jwt.InvalidTokenError as error: # its text describes the token
|
|
147
|
+
raise CredentialsRejected(f"Invalid authentication token: {error}") from error
|
|
148
|
+
except (jwt.PyJWTError, UnicodeError) as error:
|
|
149
|
+
# a key unfit for the token's alg (InvalidKeyError...) describes the server's
|
|
150
|
+
# key, so it is logged rather than sent; a non-UTF-8 header is just refused
|
|
151
|
+
if not isinstance(error, UnicodeError):
|
|
152
|
+
logger.warning("JWT rejected for a server-side reason: %r", error)
|
|
153
|
+
raise CredentialsRejected(
|
|
154
|
+
"Invalid authentication token: not acceptable"
|
|
155
|
+
) from error
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def required_key(key, *, name: str = "api_key"):
|
|
159
|
+
"""``key``, refused when empty: an empty key would let a request with no
|
|
160
|
+
``Authorization`` header through."""
|
|
161
|
+
if not key or not isinstance(key, (str, bytes)):
|
|
162
|
+
raise ValueError(f"{name} must be a non-empty str or bytes")
|
|
163
|
+
return key
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def key_matches(given, expected) -> bool:
|
|
167
|
+
"""Whether the ``given`` credential equals ``expected``, in constant time."""
|
|
168
|
+
if not given or not expected:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def _bytes(x): # aiohttp keeps non-UTF-8 header bytes as surrogate escapes
|
|
172
|
+
return x.encode(errors="surrogateescape") if isinstance(x, str) else x
|
|
173
|
+
|
|
174
|
+
return hmac.compare_digest(_bytes(given), _bytes(expected))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Plugins for adding middleware functionality to Bottle apps."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
from warnings import warn
|
|
7
|
+
|
|
8
|
+
from bottle import request, response
|
|
9
|
+
|
|
10
|
+
from py2http.auth import (
|
|
11
|
+
DFLT_JWT_ALGORITHMS,
|
|
12
|
+
CredentialsRejected,
|
|
13
|
+
bearer_token,
|
|
14
|
+
decode_jwt,
|
|
15
|
+
jwt_algorithms,
|
|
16
|
+
jwt_decode_kwargs,
|
|
17
|
+
key_matches,
|
|
18
|
+
required_key,
|
|
19
|
+
)
|
|
20
|
+
from py2http.constants import API_KEY_CHALLENGE, BEARER_CHALLENGE, JSON_CONTENT_TYPE
|
|
21
|
+
|
|
22
|
+
OPTIONS = "OPTIONS"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _authorization_header() -> str:
|
|
26
|
+
"""The request's ``Authorization`` header; ``''`` if it is not valid UTF-8 (Bottle
|
|
27
|
+
raises when decoding it), which every check then refuses."""
|
|
28
|
+
try:
|
|
29
|
+
return request.headers.get("Authorization", "")
|
|
30
|
+
except UnicodeError:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _unauthorized(message, challenge=BEARER_CHALLENGE):
|
|
35
|
+
response.status = 401
|
|
36
|
+
response.set_header("WWW-Authenticate", challenge)
|
|
37
|
+
response.content_type = JSON_CONTENT_TYPE
|
|
38
|
+
return json.dumps({"error": message})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class JWTPlugin:
|
|
42
|
+
"""A plugin for validating JWTs and extracting their payloads.
|
|
43
|
+
|
|
44
|
+
After optionally validating a JWT found in the request header, will assign a dict with the JWT claims
|
|
45
|
+
to request.token.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
secret: str = "",
|
|
51
|
+
verify: bool = True,
|
|
52
|
+
mapper: dict = None,
|
|
53
|
+
ignore_methods: Iterable[str] = None,
|
|
54
|
+
algorithms: Iterable[str] = None,
|
|
55
|
+
*,
|
|
56
|
+
allow_mixed_algorithm_families: bool = False,
|
|
57
|
+
decode_kwargs: dict = None,
|
|
58
|
+
):
|
|
59
|
+
"""Creates a new JWTPlugin instance.
|
|
60
|
+
|
|
61
|
+
:param secret: The JWT public key (RS256) or shared secret (HS256) used to
|
|
62
|
+
validate tokens. Required (non-empty) when ``verify`` is True.
|
|
63
|
+
:param verify: (Optional) If True, will verify JWT signatures against the provided secret,
|
|
64
|
+
and reject unverified requests.
|
|
65
|
+
:param mapper: (Optional) A dict that specifies how to map JWT claim value names in the output.
|
|
66
|
+
:param ignore_methods: (Optional) A list of method names for the plugin to ignore.
|
|
67
|
+
:param algorithms: (Optional) The algorithms a token may be signed with
|
|
68
|
+
(default ``("HS256",)``); validated as ``py2http.auth.jwt_algorithms``
|
|
69
|
+
describes, as is ``allow_mixed_algorithm_families``.
|
|
70
|
+
:param decode_kwargs: (Optional) Extra ``jwt.decode`` arguments, such as
|
|
71
|
+
``audience``, ``issuer`` or ``leeway``.
|
|
72
|
+
"""
|
|
73
|
+
self._secret = secret
|
|
74
|
+
self._verify = verify
|
|
75
|
+
self._mapper = mapper if mapper else {}
|
|
76
|
+
self._ignore_methods = ignore_methods if ignore_methods else []
|
|
77
|
+
self._algorithms = jwt_algorithms(
|
|
78
|
+
secret,
|
|
79
|
+
verify=verify,
|
|
80
|
+
algorithms=algorithms or DFLT_JWT_ALGORITHMS,
|
|
81
|
+
allow_mixed_algorithm_families=allow_mixed_algorithm_families,
|
|
82
|
+
)
|
|
83
|
+
self._decode_kwargs = jwt_decode_kwargs(decode_kwargs)
|
|
84
|
+
|
|
85
|
+
def _claims(self):
|
|
86
|
+
"""The (renamed) claims of the request's token; raises ``CredentialsRejected``."""
|
|
87
|
+
decoded = decode_jwt(
|
|
88
|
+
bearer_token(_authorization_header()),
|
|
89
|
+
self._secret,
|
|
90
|
+
algorithms=self._algorithms,
|
|
91
|
+
verify=self._verify,
|
|
92
|
+
decode_kwargs=self._decode_kwargs,
|
|
93
|
+
)
|
|
94
|
+
for k, v in self._mapper.items():
|
|
95
|
+
if k in decoded:
|
|
96
|
+
decoded[v] = decoded.pop(k)
|
|
97
|
+
return decoded
|
|
98
|
+
|
|
99
|
+
def __call__(self, handler):
|
|
100
|
+
if getattr(handler, "method_name", None) in self._ignore_methods:
|
|
101
|
+
return handler
|
|
102
|
+
|
|
103
|
+
@wraps(handler)
|
|
104
|
+
def wrapped_handler(*args, **kwargs):
|
|
105
|
+
if request.method == OPTIONS:
|
|
106
|
+
return handler(*args, **kwargs)
|
|
107
|
+
try:
|
|
108
|
+
request.token = self._claims()
|
|
109
|
+
except CredentialsRejected as rejection:
|
|
110
|
+
if self._verify:
|
|
111
|
+
return _unauthorized(str(rejection))
|
|
112
|
+
warn(f"Invalid JWT: {rejection.__cause__}")
|
|
113
|
+
return handler(*args, **kwargs)
|
|
114
|
+
|
|
115
|
+
return wrapped_handler
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ApiKeyAuthPlugin:
|
|
119
|
+
"""A plugin letting through only requests whose ``Authorization`` header is
|
|
120
|
+
exactly ``api_key`` (compared in constant time; an empty key is refused)."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, api_key: str):
|
|
123
|
+
self._api_key = required_key(api_key)
|
|
124
|
+
|
|
125
|
+
def __call__(self, handler):
|
|
126
|
+
@wraps(handler)
|
|
127
|
+
def wrapped_handler(*args, **kwargs):
|
|
128
|
+
if key_matches(_authorization_header(), self._api_key):
|
|
129
|
+
return handler(*args, **kwargs)
|
|
130
|
+
return _unauthorized("invalid API key", API_KEY_CHALLENGE)
|
|
131
|
+
|
|
132
|
+
return wrapped_handler
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# from https://stackoverflow.com/questions/17262170/bottle-py-enabling-cors-for-jquery-ajax-requests
|
|
136
|
+
# TODO: accept lists of headers and methods as init args
|
|
137
|
+
class CorsPlugin:
|
|
138
|
+
def __init__(self, origins: str = "*"):
|
|
139
|
+
self._origins = origins
|
|
140
|
+
|
|
141
|
+
def __call__(self, handler):
|
|
142
|
+
@wraps(handler)
|
|
143
|
+
def wrapped_handler(*args, **kwargs):
|
|
144
|
+
response.headers["Access-Control-Allow-Origin"] = self._origins
|
|
145
|
+
response.headers["Access-Control-Allow-Methods"] = (
|
|
146
|
+
"GET, POST, PUT, DELETE, OPTIONS"
|
|
147
|
+
)
|
|
148
|
+
response.headers["Access-Control-Allow-Headers"] = (
|
|
149
|
+
"Origin, Accept, Content-Type, X-Requested-With, Authorization, X-api-key"
|
|
150
|
+
)
|
|
151
|
+
if request.method != OPTIONS:
|
|
152
|
+
return handler(*args, **kwargs)
|
|
153
|
+
|
|
154
|
+
return wrapped_handler
|
|
@@ -5,3 +5,11 @@ BINARY_CONTENT_TYPE = "application/octet-stream"
|
|
|
5
5
|
FORM_CONTENT_TYPE = "multipart/form-data"
|
|
6
6
|
RAW_CONTENT_TYPE = "text/plain"
|
|
7
7
|
HTML_CONTENT_TYPE = "text/html"
|
|
8
|
+
|
|
9
|
+
# ``WWW-Authenticate`` challenges sent with every 401 (RFC 7235 requires one): a JWT is
|
|
10
|
+
# sent as ``Authorization: Bearer <token>``; an API key as the bare header value.
|
|
11
|
+
BEARER_CHALLENGE = "Bearer"
|
|
12
|
+
API_KEY_CHALLENGE = "ApiKey"
|
|
13
|
+
#: Environment variable naming the challenge for a 401 a dispatched function raised
|
|
14
|
+
#: (``AuthorizationError``); the deployment's auth decides it. Default: Bearer.
|
|
15
|
+
WWW_AUTHENTICATE_ENV_VAR = "PY2HTTP_WWW_AUTHENTICATE"
|
|
@@ -15,6 +15,7 @@ up an HTTP service in Python using the py2http library."""
|
|
|
15
15
|
from aiohttp import web
|
|
16
16
|
from bottle import response
|
|
17
17
|
import json
|
|
18
|
+
import logging
|
|
18
19
|
import os
|
|
19
20
|
|
|
20
21
|
from i2.errors import (
|
|
@@ -28,7 +29,13 @@ from i2.errors import (
|
|
|
28
29
|
|
|
29
30
|
from py2http.decorators import handle_json_req, send_json_resp, JsonRespEncoder
|
|
30
31
|
from py2http.config import AIOHTTP, BOTTLE, FLASK
|
|
31
|
-
from py2http.constants import
|
|
32
|
+
from py2http.constants import (
|
|
33
|
+
BEARER_CHALLENGE,
|
|
34
|
+
JSON_CONTENT_TYPE,
|
|
35
|
+
WWW_AUTHENTICATE_ENV_VAR,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
32
39
|
|
|
33
40
|
DFLT_CONTENT_TYPE = JSON_CONTENT_TYPE
|
|
34
41
|
|
|
@@ -52,14 +59,41 @@ def bottle_output_mapper(output, **inputs):
|
|
|
52
59
|
return json.dumps(output, cls=JsonRespEncoder)
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
def _raise_http_client_error(error, message, reason=None):
|
|
62
|
+
def _raise_http_client_error(error, message, reason=None, headers=None):
|
|
56
63
|
raise error(
|
|
57
64
|
text=json.dumps({"error": message}),
|
|
58
65
|
content_type=JSON_CONTENT_TYPE,
|
|
59
66
|
reason=reason,
|
|
67
|
+
headers=headers,
|
|
60
68
|
)
|
|
61
69
|
|
|
62
70
|
|
|
71
|
+
def www_authenticate_challenge() -> str:
|
|
72
|
+
"""The ``WWW-Authenticate`` value of a 401 a dispatched function raised: the
|
|
73
|
+
``PY2HTTP_WWW_AUTHENTICATE`` environment variable, else ``Bearer`` (also when the
|
|
74
|
+
variable holds a line break, which would make every 401 fail to send).
|
|
75
|
+
|
|
76
|
+
>>> os.environ.pop(WWW_AUTHENTICATE_ENV_VAR, None) and None
|
|
77
|
+
>>> www_authenticate_challenge()
|
|
78
|
+
'Bearer'
|
|
79
|
+
"""
|
|
80
|
+
challenge = os.getenv(WWW_AUTHENTICATE_ENV_VAR)
|
|
81
|
+
if not challenge:
|
|
82
|
+
return BEARER_CHALLENGE
|
|
83
|
+
if any(c in challenge for c in "\r\n\0"):
|
|
84
|
+
logger.warning(
|
|
85
|
+
"%s holds a control character; sending %r instead",
|
|
86
|
+
WWW_AUTHENTICATE_ENV_VAR,
|
|
87
|
+
BEARER_CHALLENGE,
|
|
88
|
+
)
|
|
89
|
+
return BEARER_CHALLENGE
|
|
90
|
+
return challenge
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _status_headers(status: int) -> dict:
|
|
94
|
+
return {"WWW-Authenticate": www_authenticate_challenge()} if status == 401 else {}
|
|
95
|
+
|
|
96
|
+
|
|
63
97
|
# HTTP status of each client error, most specific class first. ``ForbiddenError`` and
|
|
64
98
|
# ``OverwritesNotAllowed`` mean the client may not do this (403); any other
|
|
65
99
|
# ``AuthorizationError`` means the request did not say who it is (401).
|
|
@@ -96,13 +130,20 @@ def aiohttp_error_handler(error: Exception):
|
|
|
96
130
|
if status == 500:
|
|
97
131
|
_raise_http_client_error(web.HTTPInternalServerError, "Internal server error")
|
|
98
132
|
reason = type(error).__name__ if status == 400 else None
|
|
99
|
-
_raise_http_client_error(
|
|
133
|
+
_raise_http_client_error(
|
|
134
|
+
_AIOHTTP_ERRORS[status],
|
|
135
|
+
str(error),
|
|
136
|
+
reason=reason,
|
|
137
|
+
headers=_status_headers(status) or None,
|
|
138
|
+
)
|
|
100
139
|
|
|
101
140
|
|
|
102
141
|
def bottle_error_handler(error: Exception):
|
|
103
142
|
message = str(error)
|
|
104
143
|
status = error_status(error)
|
|
105
144
|
response.status = f"400 {type(error).__name__}" if status == 400 else status
|
|
145
|
+
for header, value in _status_headers(status).items():
|
|
146
|
+
response.set_header(header, value)
|
|
106
147
|
if status == 500 and os.getenv("OPAQUE_ERRORS", None):
|
|
107
148
|
message = "Internal server error"
|
|
108
149
|
return {"error": message}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""aiohttp middleware that guards every route of an app: ``mk_jwt_middleware``
|
|
2
|
+
(a verified ``Authorization: Bearer`` JWT) and ``mk_superadmin_middleware`` (one
|
|
3
|
+
shared key). The checks themselves live in ``py2http.auth``, shared with the Bottle
|
|
4
|
+
plugins of ``py2http.bottle_plugins``."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from warnings import warn
|
|
9
|
+
|
|
10
|
+
from py2http.auth import (
|
|
11
|
+
DFLT_JWT_ALGORITHMS,
|
|
12
|
+
CredentialsRejected,
|
|
13
|
+
_algorithm_family, # noqa: F401 (was defined here)
|
|
14
|
+
_names,
|
|
15
|
+
bearer_token,
|
|
16
|
+
decode_jwt,
|
|
17
|
+
jwt_algorithms,
|
|
18
|
+
jwt_decode_kwargs,
|
|
19
|
+
key_matches,
|
|
20
|
+
required_key,
|
|
21
|
+
)
|
|
22
|
+
from py2http.constants import API_KEY_CHALLENGE, BEARER_CHALLENGE, JSON_CONTENT_TYPE
|
|
23
|
+
|
|
24
|
+
DFLT_PUBLIC_ROUTES = ("ping", "openapi")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unauthorized(message, challenge=BEARER_CHALLENGE):
|
|
28
|
+
from aiohttp import web
|
|
29
|
+
|
|
30
|
+
return web.HTTPUnauthorized(
|
|
31
|
+
text=json.dumps({"error": message}),
|
|
32
|
+
content_type=JSON_CONTENT_TYPE,
|
|
33
|
+
headers={"WWW-Authenticate": challenge},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mk_jwt_middleware(
|
|
38
|
+
secret,
|
|
39
|
+
verify=True,
|
|
40
|
+
*,
|
|
41
|
+
algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
|
|
42
|
+
public_routes: Iterable[str] = DFLT_PUBLIC_ROUTES,
|
|
43
|
+
allow_mixed_algorithm_families: bool = False,
|
|
44
|
+
decode_kwargs: dict = None,
|
|
45
|
+
):
|
|
46
|
+
"""Make an aiohttp middleware that requires a valid ``Authorization: Bearer``
|
|
47
|
+
JWT on every request, and puts the decoded claims on ``request.token``.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
secret: The key tokens are verified with (an HMAC secret for ``HS*``
|
|
51
|
+
algorithms, a public key for ``RS*``/``ES*``/...).
|
|
52
|
+
verify: If False, signatures and claims (``exp``, ...) are NOT checked:
|
|
53
|
+
whatever a token says ends up on ``request.token``, and an
|
|
54
|
+
undecodable token only triggers a warning. For development only.
|
|
55
|
+
(Before, PyJWT 2 ignored the legacy option this passed, so
|
|
56
|
+
signatures were still checked even with ``verify=False``.)
|
|
57
|
+
algorithms: The algorithms a token may be signed with (PyJWT names,
|
|
58
|
+
case-sensitive; ``"none"`` is refused). Defaults to ``("HS256",)``,
|
|
59
|
+
as ``py2http.bottle_plugins.JWTPlugin`` does.
|
|
60
|
+
public_routes: Names of aiohttp routes (``name=`` in ``add_route``) that
|
|
61
|
+
need no token. Matched on the route name, not the handler's
|
|
62
|
+
``__name__``, so a handler that happens to be called ``ping`` is not
|
|
63
|
+
public by accident. Route names are matched in sub-applications
|
|
64
|
+
too, so a sub-app route named ``ping`` is public as well.
|
|
65
|
+
allow_mixed_algorithm_families: Accepting an HMAC algorithm and a
|
|
66
|
+
public-key algorithm with one ``secret`` is the classic
|
|
67
|
+
algorithm-confusion setup (a token "signed" with HS256 using the
|
|
68
|
+
public key as the HMAC secret), so it raises ``ValueError`` unless
|
|
69
|
+
this is True. The middleware used to accept ``["HS256", "RS256"]``;
|
|
70
|
+
pass that list with this flag to restore it.
|
|
71
|
+
decode_kwargs: Extra ``jwt.decode`` arguments, such as ``audience``,
|
|
72
|
+
``issuer`` or ``leeway`` (without them a token carrying an ``aud``
|
|
73
|
+
claim is always refused).
|
|
74
|
+
"""
|
|
75
|
+
from aiohttp import web
|
|
76
|
+
|
|
77
|
+
algorithms = jwt_algorithms(
|
|
78
|
+
secret,
|
|
79
|
+
verify=verify,
|
|
80
|
+
algorithms=algorithms,
|
|
81
|
+
allow_mixed_algorithm_families=allow_mixed_algorithm_families,
|
|
82
|
+
)
|
|
83
|
+
decode_kwargs = jwt_decode_kwargs(decode_kwargs)
|
|
84
|
+
public_routes = frozenset(
|
|
85
|
+
name for name in _names("public_routes", public_routes) if name is not None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@web.middleware
|
|
89
|
+
async def middleware(req, handler):
|
|
90
|
+
route = req.match_info.route
|
|
91
|
+
if getattr(route, "name", None) in public_routes:
|
|
92
|
+
return await handler(req)
|
|
93
|
+
token = bearer_token(req.headers.get("Authorization"))
|
|
94
|
+
try:
|
|
95
|
+
req.token = decode_jwt(
|
|
96
|
+
token,
|
|
97
|
+
secret,
|
|
98
|
+
algorithms=algorithms,
|
|
99
|
+
verify=verify,
|
|
100
|
+
decode_kwargs=decode_kwargs,
|
|
101
|
+
)
|
|
102
|
+
except CredentialsRejected as rejection:
|
|
103
|
+
if verify:
|
|
104
|
+
raise _unauthorized(str(rejection)) from None
|
|
105
|
+
warn(f"Invalid JWT: {rejection.__cause__}")
|
|
106
|
+
return await handler(req)
|
|
107
|
+
|
|
108
|
+
return middleware
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def mk_superadmin_middleware(secret):
|
|
112
|
+
"""Make an aiohttp middleware that only lets through requests whose
|
|
113
|
+
``Authorization`` header is exactly ``secret`` (compared in constant time).
|
|
114
|
+
An empty ``secret`` is refused, since it would let header-less requests through.
|
|
115
|
+
"""
|
|
116
|
+
from aiohttp import web
|
|
117
|
+
|
|
118
|
+
secret = required_key(secret, name="secret")
|
|
119
|
+
|
|
120
|
+
@web.middleware
|
|
121
|
+
async def middleware(req, handler):
|
|
122
|
+
if key_matches(req.headers.get("Authorization"), secret):
|
|
123
|
+
return await handler(req)
|
|
124
|
+
raise _unauthorized("invalid API key", API_KEY_CHALLENGE)
|
|
125
|
+
|
|
126
|
+
return middleware
|