]>
Commit | Line | Data |
---|---|---|
9137135a MT |
1 | #!/usr/bin/python |
2 | ||
4f1cef10 | 3 | import asyncio |
f062b044 | 4 | import base64 |
4f1cef10 | 5 | import functools |
15a45292 | 6 | import http.client |
f062b044 MT |
7 | import json |
8 | import kerberos | |
9 | import logging | |
10 | import os | |
9137135a MT |
11 | import time |
12 | import tornado.locale | |
13 | import tornado.web | |
f062b044 | 14 | import tornado.websocket |
f6e6ff79 | 15 | import traceback |
9137135a | 16 | |
14d7ed77 | 17 | from .. import __version__ |
61428bce | 18 | from .. import builders |
2c909128 | 19 | from .. import misc |
f062b044 | 20 | from .. import users |
d2738057 | 21 | from ..decorators import * |
9137135a | 22 | |
f062b044 | 23 | # Setup logging |
6acc7746 | 24 | log = logging.getLogger("pbs.web.base") |
f062b044 MT |
25 | |
26 | class KerberosAuthMixin(object): | |
27 | """ | |
28 | A mixin that handles Kerberos authentication | |
29 | """ | |
30 | @property | |
31 | def kerberos_realm(self): | |
32 | return "IPFIRE.ORG" | |
33 | ||
34 | @property | |
35 | def kerberos_service(self): | |
36 | return self.settings.get("kerberos_service", "HTTP") | |
37 | ||
38 | def authenticate_redirect(self): | |
39 | """ | |
40 | Called when the application needs the user to authenticate. | |
41 | ||
42 | We will send a response with status code 401 and set the | |
43 | WWW-Authenticate header to ask the client to either initiate | |
44 | some Kerberos authentication, or to perform HTTP Basic authentication. | |
45 | """ | |
46 | # Ask the client to authenticate using Kerberos | |
47 | self.add_header("WWW-Authenticate", "Negotiate") | |
48 | ||
49 | # Ask the client to authenticate using HTTP Basic Auth | |
50 | self.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.kerberos_realm) | |
51 | ||
52 | # Set status to 401 | |
53 | self.set_status(401) | |
54 | ||
4e564583 | 55 | @functools.cache |
f062b044 MT |
56 | def get_authenticated_user(self): |
57 | auth_header = self.request.headers.get("Authorization", None) | |
58 | ||
59 | # No authentication header | |
60 | if not auth_header: | |
61 | return | |
62 | ||
63 | # Perform GSS API Negotiation | |
216a565a | 64 | if auth_header.startswith("Negotiate "): |
f062b044 MT |
65 | return self._auth_negotiate(auth_header) |
66 | ||
67 | # Perform Basic Authentication | |
68 | elif auth_header.startswith("Basic "): | |
69 | return self._auth_basic(auth_header) | |
70 | ||
71 | # Fail on anything else | |
72 | else: | |
73 | raise tornado.web.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header) | |
74 | ||
75 | def _auth_negotiate(self, auth_header): | |
76 | os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") | |
77 | ||
78 | auth_value = auth_header.removeprefix("Negotiate ") | |
79 | ||
80 | try: | |
81 | # Initialise the server session | |
82 | result, context = kerberos.authGSSServerInit(self.kerberos_service) | |
83 | ||
84 | if not result == kerberos.AUTH_GSS_COMPLETE: | |
85 | raise tornado.web.HTTPError(500, "Kerberos Initialization failed: %s" % result) | |
86 | ||
87 | # Check the received authentication header | |
88 | result = kerberos.authGSSServerStep(context, auth_value) | |
89 | ||
90 | # If this was not successful, we will fall back to Basic authentication | |
91 | if not result == kerberos.AUTH_GSS_COMPLETE: | |
92 | return self._auth_basic(auth_header) | |
93 | ||
94 | if not isinstance(self, tornado.websocket.WebSocketHandler): | |
95 | # Fetch the server response | |
96 | response = kerberos.authGSSServerResponse(context) | |
97 | ||
98 | # Send the server response | |
99 | self.set_header("WWW-Authenticate", "Negotiate %s" % response) | |
100 | ||
101 | # Return the user who just authenticated | |
102 | user = kerberos.authGSSServerUserName(context) | |
103 | ||
104 | except kerberos.GSSError as e: | |
105 | log.error("Kerberos Authentication Error: %s" % e) | |
106 | ||
107 | raise tornado.web.HTTPError(500, "Could not initialize the Kerberos context") | |
108 | ||
109 | finally: | |
110 | # Cleanup | |
111 | kerberos.authGSSServerClean(context) | |
112 | ||
113 | log.debug("Successfully authenticated %s" % user) | |
114 | ||
115 | return user | |
116 | ||
117 | def _auth_basic(self, auth_header): | |
118 | os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") | |
119 | ||
120 | # Remove "Basic " | |
121 | auth_header = auth_header.removeprefix("Basic ") | |
122 | ||
123 | try: | |
124 | # Decode base64 | |
125 | auth_header = base64.b64decode(auth_header).decode() | |
126 | ||
127 | username, password = auth_header.split(":", 1) | |
128 | except: | |
129 | raise tornado.web.HTTPError(400, "Authorization data was malformed") | |
130 | ||
216a565a MT |
131 | # Authenticate against Kerberos |
132 | return self._auth_with_credentials(username, password) | |
133 | ||
134 | def _auth_with_credentials(self, username, password): | |
f062b044 MT |
135 | # Check the credentials against the Kerberos database |
136 | try: | |
137 | kerberos.checkPassword(username, password, | |
138 | "%s/pakfire.ipfire.org" % self.kerberos_service, self.kerberos_realm) | |
139 | ||
140 | # Catch any authentication errors | |
141 | except kerberos.BasicAuthError as e: | |
142 | log.error("Could not authenticate %s: %s" % (username, e)) | |
143 | return | |
144 | ||
145 | # Create user principal name | |
146 | user = "%s@%s" % (username, self.kerberos_realm) | |
147 | ||
148 | log.debug("Successfully authenticated %s" % user) | |
149 | ||
150 | return user | |
151 | ||
152 | ||
9137135a | 153 | class BaseHandler(tornado.web.RequestHandler): |
c6c94528 MT |
154 | @property |
155 | def backend(self): | |
156 | return self.application.backend | |
157 | ||
158 | @property | |
159 | def db(self): | |
160 | return self.backend.db | |
161 | ||
d2738057 MT |
162 | @lazy_property |
163 | def session(self): | |
164 | # Get the session from the cookie | |
165 | session_id = self.get_cookie("session_id", None) | |
f6e6ff79 | 166 | |
d2738057 MT |
167 | # Search for a valid database session |
168 | if session_id: | |
72568c46 | 169 | return self.backend.sessions.get(session_id) |
9137135a | 170 | |
d2738057 MT |
171 | def get_current_user(self): |
172 | if self.session: | |
70987fab | 173 | return self.session.user |
bdeb3c62 | 174 | |
9137135a | 175 | def get_user_locale(self): |
4947da2d MT |
176 | # Get the locale from the user settings |
177 | if self.current_user: | |
178 | return self.current_user.locale | |
9137135a | 179 | |
4947da2d | 180 | # If no locale was provided, we take what ever the browser requested |
214463e3 | 181 | return self.get_browser_locale() |
9137135a | 182 | |
f6e6ff79 | 183 | @property |
12d79e59 | 184 | def current_address(self): |
f6e6ff79 MT |
185 | """ |
186 | Returns the IP address the request came from. | |
187 | """ | |
371a879e | 188 | return self.request.headers.get("X-Real-IP") or self.request.remote_ip |
f6e6ff79 | 189 | |
61c74986 MT |
190 | @property |
191 | def user_agent(self): | |
192 | """ | |
193 | Returns the HTTP user agent | |
194 | """ | |
195 | return self.request.headers.get("User-Agent", None) | |
196 | ||
6468877e MT |
197 | def format_date(self, date, relative=True, shorter=False, full_format=False): |
198 | return self.locale.format_date(date, relative=relative, | |
199 | shorter=shorter, full_format=full_format) | |
f6e6ff79 | 200 | |
8fa68307 MT |
201 | def get_template_namespace(self): |
202 | ns = tornado.web.RequestHandler.get_template_namespace(self) | |
203 | ||
204 | ns.update({ | |
fe9b927c | 205 | "backend" : self.backend, |
f6e6ff79 MT |
206 | "hostname" : self.request.host, |
207 | "format_date" : self.format_date, | |
2c909128 | 208 | "format_size" : misc.format_size, |
14d7ed77 | 209 | "version" : __version__, |
d3307cb1 | 210 | "xsrf_token" : self.xsrf_token, |
f6e6ff79 | 211 | "year" : time.strftime("%Y"), |
8fa68307 | 212 | }) |
9137135a | 213 | |
8fa68307 | 214 | return ns |
9137135a | 215 | |
7ec8e0d3 | 216 | def write_error(self, code, exc_info=None, **kwargs): |
703e79ed | 217 | try: |
7ec8e0d3 | 218 | message = http.client.responses[code] |
703e79ed | 219 | except KeyError: |
7ec8e0d3 MT |
220 | message = None |
221 | ||
222 | _traceback = [] | |
f6e6ff79 MT |
223 | |
224 | # Collect more information about the exception if possible. | |
703e79ed | 225 | if exc_info: |
f062b044 MT |
226 | if self.current_user and isinstance(self.current_user, users.User): |
227 | if self.current_user.is_admin(): | |
228 | _traceback += traceback.format_exception(*exc_info) | |
f6e6ff79 | 229 | |
7ec8e0d3 MT |
230 | self.render("errors/error.html", |
231 | code=code, message=message, traceback="".join(_traceback), **kwargs) | |
1af6c96c MT |
232 | |
233 | # Typed Arguments | |
234 | ||
0bcebf53 MT |
235 | def get_argument_bool(self, name): |
236 | arg = self.get_argument(name, default=None) | |
237 | ||
f791a598 MT |
238 | if arg: |
239 | return arg.lower() in ("on", "true", "yes", "1") | |
240 | ||
241 | return False | |
0bcebf53 | 242 | |
1af6c96c MT |
243 | def get_argument_int(self, *args, **kwargs): |
244 | arg = self.get_argument(*args, **kwargs) | |
245 | ||
0bcebf53 MT |
246 | # Return nothing |
247 | if not arg: | |
248 | return None | |
249 | ||
1af6c96c MT |
250 | try: |
251 | return int(arg) | |
252 | except (TypeError, ValueError): | |
253 | raise tornado.web.HTTPError(400, "%s is not an integer" % arg) | |
98ee6714 | 254 | |
bff2ca64 MT |
255 | def get_argument_builder(self, *args, **kwargs): |
256 | name = self.get_argument(*args, **kwargs) | |
257 | ||
258 | if name: | |
259 | return self.backend.builders.get_by_name(name) | |
260 | ||
98ee6714 MT |
261 | def get_argument_distro(self, *args, **kwargs): |
262 | slug = self.get_argument(*args, **kwargs) | |
263 | ||
264 | if slug: | |
265 | return self.backend.distros.get_by_slug(slug) | |
f004aee7 | 266 | |
5c6b5928 MT |
267 | # Uploads |
268 | ||
269 | def _get_upload(self, uuid): | |
270 | upload = self.backend.uploads.get_by_uuid(uuid) | |
271 | ||
272 | # Check permissions | |
273 | if upload and not upload.has_perm(self.current_user): | |
274 | raise tornado.web.HTTPError(403, "%s has no permissions for upload %s" % (self.current_user, upload)) | |
275 | ||
276 | return upload | |
277 | ||
f062b044 MT |
278 | def get_argument_upload(self, *args, **kwargs): |
279 | """ | |
280 | Returns an upload | |
281 | """ | |
282 | uuid = self.get_argument(*args, **kwargs) | |
283 | ||
284 | if uuid: | |
5c6b5928 | 285 | return self._get_upload(uuid) |
f062b044 MT |
286 | |
287 | def get_argument_uploads(self, *args, **kwargs): | |
288 | """ | |
289 | Returns a list of uploads | |
290 | """ | |
291 | uuids = self.get_arguments(*args, **kwargs) | |
292 | ||
293 | # Return all uploads | |
5c6b5928 | 294 | return [self._get_upload(uuid) for uuid in uuids] |
f062b044 | 295 | |
f004aee7 MT |
296 | def get_argument_user(self, *args, **kwargs): |
297 | name = self.get_argument(*args, **kwargs) | |
298 | ||
299 | if name: | |
300 | return self.backend.users.get_by_name(name) | |
f062b044 MT |
301 | |
302 | # XXX TODO | |
303 | BackendMixin = BaseHandler | |
304 | ||
305 | class APIMixin(KerberosAuthMixin, BackendMixin): | |
306 | # Generally do not permit users to authenticate against the API | |
307 | allow_users = False | |
308 | ||
309 | # Do not perform any XSRF cookie validation on API calls | |
310 | def check_xsrf_cookie(self): | |
311 | pass | |
312 | ||
313 | def get_current_user(self): | |
314 | """ | |
315 | Authenticates a user or builder | |
316 | """ | |
317 | # Fetch the Kerberos ticket | |
318 | principal = self.get_authenticated_user() | |
319 | ||
320 | # Return nothing if we did not receive any credentials | |
321 | if not principal: | |
322 | return | |
323 | ||
324 | logging.debug("Searching for principal %s..." % principal) | |
325 | ||
326 | # Strip the realm | |
327 | principal, delimiter, realm = principal.partition("@") | |
328 | ||
329 | # Return any builders | |
330 | if principal.startswith("host/"): | |
331 | hostname = principal.removeprefix("host/") | |
332 | ||
333 | return self.backend.builders.get_by_name(hostname) | |
334 | ||
335 | # End here if users are not allowed to authenticate | |
336 | if not self.allow_users: | |
337 | return | |
338 | ||
339 | # Return users | |
340 | return self.backend.users.get_by_name(principal) | |
341 | ||
342 | def get_user_locale(self): | |
343 | return self.get_browser_locale() | |
344 | ||
46a7bd16 MT |
345 | @property |
346 | def builder(self): | |
347 | """ | |
348 | This is a convenience handler to access a builder by a better name | |
349 | """ | |
350 | if isinstance(self.current_user, builders.Builder): | |
351 | return self.current_user | |
352 | ||
353 | raise AttributeError | |
354 | ||
1a1584ce MT |
355 | def get_compression_options(self): |
356 | # Enable maximum compression | |
357 | return { | |
358 | "compression_level" : 9, | |
359 | "mem_level" : 9, | |
360 | } | |
361 | ||
f062b044 MT |
362 | def write_error(self, code, **kwargs): |
363 | # Send a JSON-encoded error message | |
364 | self.finish({ | |
365 | "error" : True, | |
366 | # XXX add error string | |
367 | }) | |
368 | ||
369 | def _decode_json_message(self, message): | |
370 | # Decode JSON message | |
371 | try: | |
372 | message = json.loads(message) | |
373 | ||
374 | except json.DecodeError as e: | |
375 | log.error("Could not decode JSON message", exc_info=True) | |
376 | raise e | |
377 | ||
378 | # Log message | |
379 | log.debug("Received message:") | |
380 | log.debug("%s" % json.dumps(message, indent=4)) | |
381 | ||
382 | return message | |
4f1cef10 MT |
383 | |
384 | ||
385 | class ratelimit(object): | |
386 | """ | |
387 | A decorator class which limits how often a function can be called | |
388 | """ | |
389 | def __init__(self, *, minutes, requests): | |
390 | self.minutes = minutes | |
391 | self.requests = requests | |
392 | ||
393 | def __call__(self, method): | |
394 | @functools.wraps(method) | |
395 | async def wrapper(handler, *args, **kwargs): | |
396 | # Pass the request to the rate limiter and get a request object | |
397 | req = handler.backend.ratelimiter.handle_request(handler.request, | |
398 | handler, minutes=self.minutes, limit=self.requests) | |
399 | ||
400 | # If the rate limit has been reached, we won't allow | |
401 | # processing the request and therefore send HTTP error code 429. | |
402 | if await req.is_ratelimited(): | |
403 | raise tornado.web.HTTPError(429, "Rate limit exceeded") | |
404 | ||
405 | # Call the wrapped method | |
406 | result = method(handler, *args, **kwargs) | |
407 | ||
408 | # Await it if it is a coroutine | |
409 | if asyncio.iscoroutine(result): | |
410 | return await result | |
411 | ||
412 | # Return the result | |
413 | return result | |
414 | ||
415 | return wrapper |