]> git.ipfire.org Git - pbs.git/blame - src/web/base.py
web: Make APIError inherit from HTTPError
[pbs.git] / src / web / base.py
CommitLineData
9137135a
MT
1#!/usr/bin/python
2
4f1cef10 3import asyncio
f062b044 4import base64
4f1cef10 5import functools
15a45292 6import http.client
f062b044
MT
7import json
8import kerberos
9import logging
10import os
e6e7d284 11import socket
9137135a
MT
12import time
13import tornado.locale
14import tornado.web
f062b044 15import tornado.websocket
f6e6ff79 16import traceback
9137135a 17
14d7ed77 18from .. import __version__
61428bce 19from .. import builders
2c909128 20from .. import misc
f062b044 21from .. import users
d2738057 22from ..decorators import *
9137135a 23
f062b044 24# Setup logging
6acc7746 25log = logging.getLogger("pbs.web.base")
f062b044
MT
26
27class 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):
e6e7d284
MT
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())
f062b044
MT
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
4e564583 60 @functools.cache
f062b044
MT
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
216a565a 69 if auth_header.startswith("Negotiate "):
f062b044
MT
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):
f062b044
MT
81 auth_value = auth_header.removeprefix("Negotiate ")
82
e6e7d284
MT
83 # Set keytab to use
84 os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
85
f062b044
MT
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):
f062b044
MT
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
216a565a
MT
135 # Authenticate against Kerberos
136 return self._auth_with_credentials(username, password)
137
138 def _auth_with_credentials(self, username, password):
e6e7d284
MT
139
140 # Set keytab to use
141 os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
142
f062b044
MT
143 # Check the credentials against the Kerberos database
144 try:
145 kerberos.checkPassword(username, password,
e6e7d284 146 self.kerberos_principal, self.kerberos_realm)
f062b044
MT
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
9137135a 161class BaseHandler(tornado.web.RequestHandler):
c6c94528
MT
162 @property
163 def backend(self):
164 return self.application.backend
165
166 @property
167 def db(self):
168 return self.backend.db
169
d2738057
MT
170 @lazy_property
171 def session(self):
172 # Get the session from the cookie
173 session_id = self.get_cookie("session_id", None)
f6e6ff79 174
d2738057
MT
175 # Search for a valid database session
176 if session_id:
72568c46 177 return self.backend.sessions.get(session_id)
9137135a 178
d2738057
MT
179 def get_current_user(self):
180 if self.session:
70987fab 181 return self.session.user
bdeb3c62 182
9137135a 183 def get_user_locale(self):
4947da2d
MT
184 # Get the locale from the user settings
185 if self.current_user:
186 return self.current_user.locale
9137135a 187
4947da2d 188 # If no locale was provided, we take what ever the browser requested
214463e3 189 return self.get_browser_locale()
9137135a 190
f6e6ff79 191 @property
12d79e59 192 def current_address(self):
f6e6ff79
MT
193 """
194 Returns the IP address the request came from.
195 """
371a879e 196 return self.request.headers.get("X-Real-IP") or self.request.remote_ip
f6e6ff79 197
61c74986
MT
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
6468877e
MT
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)
f6e6ff79 208
8fa68307
MT
209 def get_template_namespace(self):
210 ns = tornado.web.RequestHandler.get_template_namespace(self)
211
212 ns.update({
fe9b927c 213 "backend" : self.backend,
f6e6ff79
MT
214 "hostname" : self.request.host,
215 "format_date" : self.format_date,
2c909128 216 "format_size" : misc.format_size,
14d7ed77 217 "version" : __version__,
d3307cb1 218 "xsrf_token" : self.xsrf_token,
f6e6ff79 219 "year" : time.strftime("%Y"),
8fa68307 220 })
9137135a 221
8fa68307 222 return ns
9137135a 223
7ec8e0d3 224 def write_error(self, code, exc_info=None, **kwargs):
703e79ed 225 try:
7ec8e0d3 226 message = http.client.responses[code]
703e79ed 227 except KeyError:
7ec8e0d3
MT
228 message = None
229
230 _traceback = []
f6e6ff79
MT
231
232 # Collect more information about the exception if possible.
703e79ed 233 if exc_info:
f062b044
MT
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)
f6e6ff79 237
7ec8e0d3
MT
238 self.render("errors/error.html",
239 code=code, message=message, traceback="".join(_traceback), **kwargs)
1af6c96c
MT
240
241 # Typed Arguments
242
0bcebf53
MT
243 def get_argument_bool(self, name):
244 arg = self.get_argument(name, default=None)
245
f791a598
MT
246 if arg:
247 return arg.lower() in ("on", "true", "yes", "1")
248
249 return False
0bcebf53 250
1af6c96c
MT
251 def get_argument_int(self, *args, **kwargs):
252 arg = self.get_argument(*args, **kwargs)
253
0bcebf53
MT
254 # Return nothing
255 if not arg:
256 return None
257
1af6c96c
MT
258 try:
259 return int(arg)
260 except (TypeError, ValueError):
261 raise tornado.web.HTTPError(400, "%s is not an integer" % arg)
98ee6714 262
bff2ca64
MT
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
98ee6714
MT
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)
f004aee7 274
5c6b5928
MT
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
f062b044
MT
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:
5c6b5928 293 return self._get_upload(uuid)
f062b044
MT
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
5c6b5928 302 return [self._get_upload(uuid) for uuid in uuids]
f062b044 303
f004aee7
MT
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)
f062b044
MT
309
310# XXX TODO
311BackendMixin = BaseHandler
312
ce7277af 313class APIError(tornado.web.HTTPError):
c27e7f37
MT
314 """
315 Raised if there has been an error in the API
316 """
317 def __init__(self, code, message):
ce7277af 318 super().__init__(400, message)
c27e7f37
MT
319
320 self.code = code
321 self.message = message
322
323 def __str__(self):
324 return self.message
325
326
f062b044
MT
327class 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
46a7bd16
MT
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
1a1584ce
MT
377 def get_compression_options(self):
378 # Enable maximum compression
379 return {
380 "compression_level" : 9,
381 "mem_level" : 9,
382 }
383
c27e7f37
MT
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, reason=error.message)
396
f062b044 397 self.finish({
c27e7f37
MT
398 "error" : {
399 "code" : error.code,
400 "message" : error.message,
401 },
f062b044
MT
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
4f1cef10
MT
418
419
8dc73381
MT
420def 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
4f1cef10
MT
440class 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