]> git.ipfire.org Git - pbs.git/blob - src/web/base.py
4aaf9a09a168606afdbdfd189dce9e65fe7bd05c
[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_builder(self, *args, **kwargs):
264 name = self.get_argument(*args, **kwargs)
265
266 if name:
267 return self.backend.builders.get_by_name(name)
268
269 def get_argument_distro(self, *args, **kwargs):
270 slug = self.get_argument(*args, **kwargs)
271
272 if slug:
273 return self.backend.distros.get_by_slug(slug)
274
275 # Uploads
276
277 def _get_upload(self, uuid):
278 upload = self.backend.uploads.get_by_uuid(uuid)
279
280 # Check permissions
281 if upload and not upload.has_perm(self.current_user):
282 raise tornado.web.HTTPError(403, "%s has no permissions for upload %s" % (self.current_user, upload))
283
284 return upload
285
286 def get_argument_upload(self, *args, **kwargs):
287 """
288 Returns an upload
289 """
290 uuid = self.get_argument(*args, **kwargs)
291
292 if uuid:
293 return self._get_upload(uuid)
294
295 def get_argument_uploads(self, *args, **kwargs):
296 """
297 Returns a list of uploads
298 """
299 uuids = self.get_arguments(*args, **kwargs)
300
301 # Return all uploads
302 return [self._get_upload(uuid) for uuid in uuids]
303
304 def get_argument_user(self, *args, **kwargs):
305 name = self.get_argument(*args, **kwargs)
306
307 if name:
308 return self.backend.users.get_by_name(name)
309
310 # XXX TODO
311 BackendMixin = BaseHandler
312
313 class APIError(tornado.web.HTTPError):
314 """
315 Raised if there has been an error in the API
316 """
317 def __init__(self, code, message):
318 super().__init__(400, message)
319
320 self.code = code
321 self.message = message
322
323 def __str__(self):
324 return self.message
325
326
327 class APIMixin(KerberosAuthMixin, BackendMixin):
328 # Generally do not permit users to authenticate against the API
329 allow_users = False
330
331 # Do not perform any XSRF cookie validation on API calls
332 def check_xsrf_cookie(self):
333 pass
334
335 def get_current_user(self):
336 """
337 Authenticates a user or builder
338 """
339 # Fetch the Kerberos ticket
340 principal = self.get_authenticated_user()
341
342 # Return nothing if we did not receive any credentials
343 if not principal:
344 return
345
346 logging.debug("Searching for principal %s..." % principal)
347
348 # Strip the realm
349 principal, delimiter, realm = principal.partition("@")
350
351 # Return any builders
352 if principal.startswith("host/"):
353 hostname = principal.removeprefix("host/")
354
355 return self.backend.builders.get_by_name(hostname)
356
357 # End here if users are not allowed to authenticate
358 if not self.allow_users:
359 return
360
361 # Return users
362 return self.backend.users.get_by_name(principal)
363
364 def get_user_locale(self):
365 return self.get_browser_locale()
366
367 @property
368 def builder(self):
369 """
370 This is a convenience handler to access a builder by a better name
371 """
372 if isinstance(self.current_user, builders.Builder):
373 return self.current_user
374
375 raise AttributeError
376
377 def get_compression_options(self):
378 # Enable maximum compression
379 return {
380 "compression_level" : 9,
381 "mem_level" : 9,
382 }
383
384 def write_error(self, code, exc_info):
385 """
386 Sends a JSON-encoded error message
387 """
388 type, error, traceback = exc_info
389
390 # We only handle API errors here
391 if not isinstance(error, APIError):
392 return super().write_error(code, exc_info)
393
394 # We send errors as 200
395 self.set_status(200)
396
397 self.finish({
398 "error" : {
399 "code" : error.code,
400 "message" : error.message,
401 },
402 })
403
404 def _decode_json_message(self, message):
405 # Decode JSON message
406 try:
407 message = json.loads(message)
408
409 except json.DecodeError as e:
410 log.error("Could not decode JSON message", exc_info=True)
411 raise e
412
413 # Log message
414 log.debug("Received message:")
415 log.debug("%s" % json.dumps(message, indent=4))
416
417 return message
418
419
420 def negotiate(method):
421 """
422 Requires clients to use SPNEGO
423 """
424 @functools.wraps(method)
425 def wrapper(self, *args, **kwargs):
426 if not self.current_user:
427 # Send the Negotiate header
428 self.add_header("WWW-Authenticate", "Negotiate")
429
430 # Respond with 401
431 self.set_status(401)
432 self.finish()
433
434 return None
435
436 return method(self, *args, **kwargs)
437
438 return wrapper
439
440 class ratelimit(object):
441 """
442 A decorator class which limits how often a function can be called
443 """
444 def __init__(self, *, minutes, requests):
445 self.minutes = minutes
446 self.requests = requests
447
448 def __call__(self, method):
449 @functools.wraps(method)
450 async def wrapper(handler, *args, **kwargs):
451 # Pass the request to the rate limiter and get a request object
452 req = handler.backend.ratelimiter.handle_request(handler.request,
453 handler, minutes=self.minutes, limit=self.requests)
454
455 # If the rate limit has been reached, we won't allow
456 # processing the request and therefore send HTTP error code 429.
457 if await req.is_ratelimited():
458 raise tornado.web.HTTPError(429, "Rate limit exceeded")
459
460 # Call the wrapped method
461 result = method(handler, *args, **kwargs)
462
463 # Await it if it is a coroutine
464 if asyncio.iscoroutine(result):
465 return await result
466
467 # Return the result
468 return result
469
470 return wrapper