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

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/>. 

13 

14"""A module with useful decorators.""" 

15 

16from __future__ import annotations 

17 

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 

24 

25from tornado.web import RequestHandler 

26 

27from .token import InvalidTokenError, parse_token 

28from .utils import Permission, anonymize_ip 

29 

30Default = TypeVar("Default") 

31Args = ParamSpec("Args") 

32Ret = TypeVar("Ret") 

33 

34 

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) 

56 

57 

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") 

68 

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 ) 

94 

95 if all(perm is None for perm in permissions): 

96 return None 

97 

98 result = Permission(0) 

99 for perm in permissions: 

100 if perm: 

101 result |= perm 

102 

103 return permission in result 

104 

105 

106_DefaultValue = object() 

107 

108 

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]]: ... 

115 

116 

117@overload 

118def requires( 

119 *perms: Permission, 

120 allow_cookie_auth: bool = True, 

121) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret]]: ... 

122 

123 

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 

133 

134 finish_with_error = return_instead_of_finishing is _DefaultValue 

135 

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__}") 

139 

140 wraps(method) 

141 

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 

162 

163 return method(*args, **kwargs) 

164 

165 return wrapper 

166 

167 return internal 

168 

169 

170@overload 

171def requires_settings( 

172 *settings: str, 

173 return_: Default, 

174) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret | Default]]: ... 

175 

176 

177@overload 

178def requires_settings( 

179 *settings: str, 

180 status_code: int, 

181) -> Callable[[Callable[Args, Ret]], Callable[Args, Ret | None]]: ... 

182 

183 

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 

195 

196 def internal(method: Callable[Args, Ret]) -> Callable[Args, Any]: 

197 logger = logging.getLogger(f"{method.__module__}.{method.__qualname__}") 

198 

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) 

222 

223 return wrapper 

224 

225 return internal 

226 

227 

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.""" 

233 

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) 

242 

243 return wrapper 

244 

245 return internal