py2http 0.1.67__tar.gz → 0.1.69__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.
- {py2http-0.1.67 → py2http-0.1.69}/PKG-INFO +1 -1
- py2http-0.1.69/py2http/auth.py +174 -0
- py2http-0.1.69/py2http/bottle_plugins.py +153 -0
- py2http-0.1.69/py2http/middleware.py +124 -0
- py2http-0.1.69/py2http/obj_dispatch.py +603 -0
- py2http-0.1.69/py2http/service.py +623 -0
- py2http-0.1.69/py2http/sizeof.py +101 -0
- py2http-0.1.69/py2http/tests/test_auth_plugins.py +349 -0
- py2http-0.1.69/py2http/tests/test_error_statuses.py +65 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_instance_endpoints.py +3 -24
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_obj_ownership.py +28 -25
- py2http-0.1.69/py2http/tests/test_positional_only.py +135 -0
- py2http-0.1.69/py2http/tests/utils_for_testing.py +69 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/util.py +80 -1
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/PKG-INFO +1 -1
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/SOURCES.txt +6 -0
- {py2http-0.1.67 → py2http-0.1.69}/setup.cfg +1 -1
- py2http-0.1.67/py2http/bottle_plugins.py +0 -114
- py2http-0.1.67/py2http/middleware.py +0 -154
- py2http-0.1.67/py2http/service.py +0 -1266
- py2http-0.1.67/py2http/tests/utils_for_testing.py +0 -26
- {py2http-0.1.67 → py2http-0.1.69}/LICENSE +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/README.md +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/__init__.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/config.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/constants.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/decorators.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/default_configs.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/diagnosis.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/__init__.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/custom_input_mapper.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/custom_input_mappers.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service_bottle.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/example_service_flask.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/examples/object_dispatch.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/openapi_utils.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/route_utils.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/schema_tools.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/__init__.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/example_service_test.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/objects_for_testing.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/simple_run_process_test.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_attr_dispatch.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_binary_req.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_decorators.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_flask.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_jwt_middleware.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_not_set_defaults.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/tests/test_p2h2p.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http/types.py +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/dependency_links.txt +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/not-zip-safe +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/requires.txt +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/py2http.egg-info/top_level.txt +0 -0
- {py2http-0.1.67 → py2http-0.1.69}/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,153 @@
|
|
|
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 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):
|
|
35
|
+
response.status = 401
|
|
36
|
+
response.content_type = JSON_CONTENT_TYPE
|
|
37
|
+
return json.dumps({"error": message})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JWTPlugin:
|
|
41
|
+
"""A plugin for validating JWTs and extracting their payloads.
|
|
42
|
+
|
|
43
|
+
After optionally validating a JWT found in the request header, will assign a dict with the JWT claims
|
|
44
|
+
to request.token.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
secret: str = "",
|
|
50
|
+
verify: bool = True,
|
|
51
|
+
mapper: dict = None,
|
|
52
|
+
ignore_methods: Iterable[str] = None,
|
|
53
|
+
algorithms: Iterable[str] = None,
|
|
54
|
+
*,
|
|
55
|
+
allow_mixed_algorithm_families: bool = False,
|
|
56
|
+
decode_kwargs: dict = None,
|
|
57
|
+
):
|
|
58
|
+
"""Creates a new JWTPlugin instance.
|
|
59
|
+
|
|
60
|
+
:param secret: The JWT public key (RS256) or shared secret (HS256) used to
|
|
61
|
+
validate tokens. Required (non-empty) when ``verify`` is True.
|
|
62
|
+
:param verify: (Optional) If True, will verify JWT signatures against the provided secret,
|
|
63
|
+
and reject unverified requests.
|
|
64
|
+
:param mapper: (Optional) A dict that specifies how to map JWT claim value names in the output.
|
|
65
|
+
:param ignore_methods: (Optional) A list of method names for the plugin to ignore.
|
|
66
|
+
:param algorithms: (Optional) The algorithms a token may be signed with
|
|
67
|
+
(default ``("HS256",)``); validated as ``py2http.auth.jwt_algorithms``
|
|
68
|
+
describes, as is ``allow_mixed_algorithm_families``.
|
|
69
|
+
:param decode_kwargs: (Optional) Extra ``jwt.decode`` arguments, such as
|
|
70
|
+
``audience``, ``issuer`` or ``leeway``.
|
|
71
|
+
"""
|
|
72
|
+
self._secret = secret
|
|
73
|
+
self._verify = verify
|
|
74
|
+
self._mapper = mapper if mapper else {}
|
|
75
|
+
self._ignore_methods = ignore_methods if ignore_methods else []
|
|
76
|
+
self._algorithms = jwt_algorithms(
|
|
77
|
+
secret,
|
|
78
|
+
verify=verify,
|
|
79
|
+
algorithms=algorithms or DFLT_JWT_ALGORITHMS,
|
|
80
|
+
allow_mixed_algorithm_families=allow_mixed_algorithm_families,
|
|
81
|
+
)
|
|
82
|
+
self._decode_kwargs = jwt_decode_kwargs(decode_kwargs)
|
|
83
|
+
|
|
84
|
+
def _claims(self):
|
|
85
|
+
"""The (renamed) claims of the request's token; raises ``CredentialsRejected``."""
|
|
86
|
+
decoded = decode_jwt(
|
|
87
|
+
bearer_token(_authorization_header()),
|
|
88
|
+
self._secret,
|
|
89
|
+
algorithms=self._algorithms,
|
|
90
|
+
verify=self._verify,
|
|
91
|
+
decode_kwargs=self._decode_kwargs,
|
|
92
|
+
)
|
|
93
|
+
for k, v in self._mapper.items():
|
|
94
|
+
if k in decoded:
|
|
95
|
+
decoded[v] = decoded.pop(k)
|
|
96
|
+
return decoded
|
|
97
|
+
|
|
98
|
+
def __call__(self, handler):
|
|
99
|
+
if getattr(handler, "method_name", None) in self._ignore_methods:
|
|
100
|
+
return handler
|
|
101
|
+
|
|
102
|
+
@wraps(handler)
|
|
103
|
+
def wrapped_handler(*args, **kwargs):
|
|
104
|
+
if request.method == OPTIONS:
|
|
105
|
+
return handler(*args, **kwargs)
|
|
106
|
+
try:
|
|
107
|
+
request.token = self._claims()
|
|
108
|
+
except CredentialsRejected as rejection:
|
|
109
|
+
if self._verify:
|
|
110
|
+
return _unauthorized(str(rejection))
|
|
111
|
+
warn(f"Invalid JWT: {rejection.__cause__}")
|
|
112
|
+
return handler(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
return wrapped_handler
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ApiKeyAuthPlugin:
|
|
118
|
+
"""A plugin letting through only requests whose ``Authorization`` header is
|
|
119
|
+
exactly ``api_key`` (compared in constant time; an empty key is refused)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, api_key: str):
|
|
122
|
+
self._api_key = required_key(api_key)
|
|
123
|
+
|
|
124
|
+
def __call__(self, handler):
|
|
125
|
+
@wraps(handler)
|
|
126
|
+
def wrapped_handler(*args, **kwargs):
|
|
127
|
+
if key_matches(_authorization_header(), self._api_key):
|
|
128
|
+
return handler(*args, **kwargs)
|
|
129
|
+
return _unauthorized("invalid API key")
|
|
130
|
+
|
|
131
|
+
return wrapped_handler
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# from https://stackoverflow.com/questions/17262170/bottle-py-enabling-cors-for-jquery-ajax-requests
|
|
135
|
+
# TODO: accept lists of headers and methods as init args
|
|
136
|
+
class CorsPlugin:
|
|
137
|
+
def __init__(self, origins: str = "*"):
|
|
138
|
+
self._origins = origins
|
|
139
|
+
|
|
140
|
+
def __call__(self, handler):
|
|
141
|
+
@wraps(handler)
|
|
142
|
+
def wrapped_handler(*args, **kwargs):
|
|
143
|
+
response.headers["Access-Control-Allow-Origin"] = self._origins
|
|
144
|
+
response.headers["Access-Control-Allow-Methods"] = (
|
|
145
|
+
"GET, POST, PUT, DELETE, OPTIONS"
|
|
146
|
+
)
|
|
147
|
+
response.headers["Access-Control-Allow-Headers"] = (
|
|
148
|
+
"Origin, Accept, Content-Type, X-Requested-With, Authorization, X-api-key"
|
|
149
|
+
)
|
|
150
|
+
if request.method != OPTIONS:
|
|
151
|
+
return handler(*args, **kwargs)
|
|
152
|
+
|
|
153
|
+
return wrapped_handler
|
|
@@ -0,0 +1,124 @@
|
|
|
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 JSON_CONTENT_TYPE
|
|
23
|
+
|
|
24
|
+
DFLT_PUBLIC_ROUTES = ("ping", "openapi")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unauthorized(message):
|
|
28
|
+
from aiohttp import web
|
|
29
|
+
|
|
30
|
+
return web.HTTPUnauthorized(
|
|
31
|
+
text=json.dumps({"error": message}), content_type=JSON_CONTENT_TYPE
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mk_jwt_middleware(
|
|
36
|
+
secret,
|
|
37
|
+
verify=True,
|
|
38
|
+
*,
|
|
39
|
+
algorithms: Iterable[str] = DFLT_JWT_ALGORITHMS,
|
|
40
|
+
public_routes: Iterable[str] = DFLT_PUBLIC_ROUTES,
|
|
41
|
+
allow_mixed_algorithm_families: bool = False,
|
|
42
|
+
decode_kwargs: dict = None,
|
|
43
|
+
):
|
|
44
|
+
"""Make an aiohttp middleware that requires a valid ``Authorization: Bearer``
|
|
45
|
+
JWT on every request, and puts the decoded claims on ``request.token``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
secret: The key tokens are verified with (an HMAC secret for ``HS*``
|
|
49
|
+
algorithms, a public key for ``RS*``/``ES*``/...).
|
|
50
|
+
verify: If False, signatures and claims (``exp``, ...) are NOT checked:
|
|
51
|
+
whatever a token says ends up on ``request.token``, and an
|
|
52
|
+
undecodable token only triggers a warning. For development only.
|
|
53
|
+
(Before, PyJWT 2 ignored the legacy option this passed, so
|
|
54
|
+
signatures were still checked even with ``verify=False``.)
|
|
55
|
+
algorithms: The algorithms a token may be signed with (PyJWT names,
|
|
56
|
+
case-sensitive; ``"none"`` is refused). Defaults to ``("HS256",)``,
|
|
57
|
+
as ``py2http.bottle_plugins.JWTPlugin`` does.
|
|
58
|
+
public_routes: Names of aiohttp routes (``name=`` in ``add_route``) that
|
|
59
|
+
need no token. Matched on the route name, not the handler's
|
|
60
|
+
``__name__``, so a handler that happens to be called ``ping`` is not
|
|
61
|
+
public by accident. Route names are matched in sub-applications
|
|
62
|
+
too, so a sub-app route named ``ping`` is public as well.
|
|
63
|
+
allow_mixed_algorithm_families: Accepting an HMAC algorithm and a
|
|
64
|
+
public-key algorithm with one ``secret`` is the classic
|
|
65
|
+
algorithm-confusion setup (a token "signed" with HS256 using the
|
|
66
|
+
public key as the HMAC secret), so it raises ``ValueError`` unless
|
|
67
|
+
this is True. The middleware used to accept ``["HS256", "RS256"]``;
|
|
68
|
+
pass that list with this flag to restore it.
|
|
69
|
+
decode_kwargs: Extra ``jwt.decode`` arguments, such as ``audience``,
|
|
70
|
+
``issuer`` or ``leeway`` (without them a token carrying an ``aud``
|
|
71
|
+
claim is always refused).
|
|
72
|
+
"""
|
|
73
|
+
from aiohttp import web
|
|
74
|
+
|
|
75
|
+
algorithms = jwt_algorithms(
|
|
76
|
+
secret,
|
|
77
|
+
verify=verify,
|
|
78
|
+
algorithms=algorithms,
|
|
79
|
+
allow_mixed_algorithm_families=allow_mixed_algorithm_families,
|
|
80
|
+
)
|
|
81
|
+
decode_kwargs = jwt_decode_kwargs(decode_kwargs)
|
|
82
|
+
public_routes = frozenset(
|
|
83
|
+
name for name in _names("public_routes", public_routes) if name is not None
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@web.middleware
|
|
87
|
+
async def middleware(req, handler):
|
|
88
|
+
route = req.match_info.route
|
|
89
|
+
if getattr(route, "name", None) in public_routes:
|
|
90
|
+
return await handler(req)
|
|
91
|
+
token = bearer_token(req.headers.get("Authorization"))
|
|
92
|
+
try:
|
|
93
|
+
req.token = decode_jwt(
|
|
94
|
+
token,
|
|
95
|
+
secret,
|
|
96
|
+
algorithms=algorithms,
|
|
97
|
+
verify=verify,
|
|
98
|
+
decode_kwargs=decode_kwargs,
|
|
99
|
+
)
|
|
100
|
+
except CredentialsRejected as rejection:
|
|
101
|
+
if verify:
|
|
102
|
+
raise _unauthorized(str(rejection)) from None
|
|
103
|
+
warn(f"Invalid JWT: {rejection.__cause__}")
|
|
104
|
+
return await handler(req)
|
|
105
|
+
|
|
106
|
+
return middleware
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def mk_superadmin_middleware(secret):
|
|
110
|
+
"""Make an aiohttp middleware that only lets through requests whose
|
|
111
|
+
``Authorization`` header is exactly ``secret`` (compared in constant time).
|
|
112
|
+
An empty ``secret`` is refused, since it would let header-less requests through.
|
|
113
|
+
"""
|
|
114
|
+
from aiohttp import web
|
|
115
|
+
|
|
116
|
+
secret = required_key(secret, name="secret")
|
|
117
|
+
|
|
118
|
+
@web.middleware
|
|
119
|
+
async def middleware(req, handler):
|
|
120
|
+
if key_matches(req.headers.get("Authorization"), secret):
|
|
121
|
+
return await handler(req)
|
|
122
|
+
raise _unauthorized("invalid API key")
|
|
123
|
+
|
|
124
|
+
return middleware
|