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