Coverage for an_website/utils/decorators.py: 80.180%
111 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-16 19:56 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-16 19:56 +0000
1# This program is free software: you can redistribute it and/or modify
2# it under the terms of the GNU Affero General Public License as
3# published by the Free Software Foundation, either version 3 of the
4# License, or (at your option) any later version.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <https://www.gnu.org/licenses/>.
14"""A module with useful decorators."""
16from __future__ import annotations
18import contextlib
19import logging
20from base64 import b64decode
21from collections.abc import Callable, Mapping
22from functools import wraps
23from typing import Any, ParamSpec, TypeVar, cast, overload
25from tornado.web import RequestHandler
27from .token import InvalidTokenError, parse_token
28from .utils import Permission, anonymize_ip
30Default = TypeVar("Default")
31Args = ParamSpec("Args")
32Ret = TypeVar("Ret")
35def keydecode(
36 token: str,
37 api_secrets: Mapping[str | None, Permission],
38 token_secret: str | bytes | None,
39) -> None | Permission:
40 """Decode a key."""
41 tokens: list[str] = [token]
42 decoded: str | None
43 try:
44 decoded = b64decode(token).decode("UTF-8")
45 except ValueError:
46 decoded = None
47 else:
48 tokens.append(decoded)
49 if token_secret:
50 for _ in tokens:
51 with contextlib.suppress(InvalidTokenError):
52 return parse_token(_, secret=token_secret).permissions
53 if decoded is None:
54 return None
55 return api_secrets.get(decoded)
58def is_authorized(
59 inst: RequestHandler,
60 permission: Permission,
61 allow_cookie_auth: bool = True,
62) -> None | bool:
63 """Check whether the request is authorized."""
64 keys: dict[str | None, Permission] = inst.settings.get(
65 "TRUSTED_API_SECRETS", {}
66 )
67 token_secret: str | bytes | None = inst.settings.get("AUTH_TOKEN_SECRET")
69 permissions: tuple[None | Permission, ...] = ( # TODO: CLEAN-UP THIS MESS!!
70 *(
71 (
72 keydecode(_[7:], keys, token_secret)
73 if _.lower().startswith("bearer ")
74 else keys.get(_)
75 )
76 for _ in inst.request.headers.get_list("Authorization")
77 ),
78 *(
79 keydecode(_, keys, token_secret)
80 for _ in inst.get_arguments("access_token")
81 ),
82 *(keys.get(_) for _ in inst.get_arguments("key")),
83 (
84 keydecode(
85 cast(str, inst.get_cookie("access_token", "")),
86 keys,
87 token_secret,
88 )
89 if allow_cookie_auth
90 else None
91 ),
92 keys.get(inst.get_cookie("key", None)) if allow_cookie_auth else None,
93 )
95 if all(perm is None for perm in permissions):
96 return None
98 result = Permission(0)
99 for perm in permissions:
100 if perm:
101 result |= perm
103 return permission in result
106_DefaultValue = object()
109@overload
110def requires(
111 *perms: Permission,
112 return_instead_of_finishing: Default,
113 allow_cookie_auth: bool = True,
114) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret | Default]]: ...
117@overload
118def requires(
119 *perms: Permission,
120 allow_cookie_auth: bool = True,
121) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret]]: ...
124def requires(
125 *perms: Permission,
126 return_instead_of_finishing: Any = _DefaultValue,
127 allow_cookie_auth: bool = True,
128) -> Callable[[Callable[Args, Ret]], Callable[Args, Any]]:
129 """Handle required permissions."""
130 permissions = Permission(0)
131 for perm in perms:
132 permissions |= perm
134 finish_with_error = return_instead_of_finishing is _DefaultValue
136 def internal(method: Callable[Args, Ret]) -> Callable[Args, Any]:
137 method.required_perms = permissions # type: ignore[attr-defined]
138 logger = logging.getLogger(f"{method.__module__}.{method.__qualname__}")
140 wraps(method)
142 @wraps(method)
143 def wrapper(*args: Args.args, **kwargs: Args.kwargs) -> Any:
144 instance = args[0]
145 if not isinstance(instance, RequestHandler):
146 raise TypeError(f"Instance has invalid type {type(instance)}")
147 authorized = is_authorized(instance, permissions, allow_cookie_auth)
148 if not authorized:
149 if not finish_with_error:
150 return cast(Ret, return_instead_of_finishing)
151 logger.warning(
152 "Unauthorized access to %s from %s",
153 instance.request.path,
154 anonymize_ip(instance.request.remote_ip),
155 )
156 instance.clear()
157 instance.set_header("WWW-Authenticate", "Bearer")
158 status = 401 if authorized is None else 403
159 instance.set_status(status)
160 instance.write_error(status, **kwargs)
161 return None
163 return method(*args, **kwargs)
165 return wrapper
167 return internal
170@overload
171def requires_settings(
172 *settings: str,
173 return_: Default,
174) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret | Default]]: ...
177@overload
178def requires_settings(
179 *settings: str,
180 status_code: int,
181) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret | None]]: ...
184def requires_settings(
185 *settings: str,
186 return_: Any = _DefaultValue,
187 status_code: int | None = None,
188) -> Callable[[Callable[Args, Ret]], Callable[Args, Any]]:
189 """Require some settings to execute a method."""
190 finish_with_error = return_ is _DefaultValue
191 if not finish_with_error and isinstance(status_code, int):
192 raise ValueError("return_ and finish_status specified")
193 if finish_with_error and status_code is None:
194 status_code = 503
196 def internal(method: Callable[Args, Ret]) -> Callable[Args, Any]:
197 logger = logging.getLogger(f"{method.__module__}.{method.__qualname__}")
199 @wraps(method)
200 def wrapper(*args: Args.args, **kwargs: Args.kwargs) -> Any:
201 instance = args[0]
202 if not isinstance(instance, RequestHandler):
203 raise TypeError(f"Instance has invalid type {type(instance)}")
204 missing = [
205 setting
206 for setting in settings
207 if instance.settings.get(setting) is None
208 ]
209 if missing:
210 if not finish_with_error:
211 return cast(Ret, return_)
212 logger.warning(
213 "Missing settings %s for request to %s",
214 ", ".join(missing),
215 instance.request.path,
216 )
217 instance.send_error(cast(int, status_code))
218 return None
219 for setting in settings:
220 kwargs[setting.lower()] = instance.settings[setting]
221 return method(*args, **kwargs)
223 return wrapper
225 return internal
228def get_setting_or_default(
229 setting: str,
230 default: Any,
231) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret]]:
232 """Require some settings to execute a method."""
234 def internal(method: Callable[Args, Ret]) -> Callable[Args, Ret]:
235 @wraps(method)
236 def wrapper(*args: Args.args, **kwargs: Args.kwargs) -> Ret:
237 instance = args[0]
238 if not isinstance(instance, RequestHandler):
239 raise TypeError(f"Instance has invalid type {type(instance)}")
240 kwargs[setting.lower()] = instance.settings.get(setting, default)
241 return method(*args, **kwargs)
243 return wrapper
245 return internal