]> git.ipfire.org Git - pbs.git/blame - src/web/base.py
builders: Fix API authentication
[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
9f586b2e
MT
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
bff2ca64
MT
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
98ee6714
MT
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)
f004aee7 286
5c6b5928
MT
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
f062b044
MT
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:
5c6b5928 305 return self._get_upload(uuid)
f062b044
MT
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
5c6b5928 314 return [self._get_upload(uuid) for uuid in uuids]
f062b044 315
f004aee7
MT
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)
f062b044
MT
321
322# XXX TODO
323BackendMixin = BaseHandler
324
ce7277af 325class APIError(tornado.web.HTTPError):
c27e7f37
MT
326 """
327 Raised if there has been an error in the API
328 """
329 def __init__(self, code, message):
ce7277af 330 super().__init__(400, message)
c27e7f37
MT
331
332 self.code = code
333 self.message = message
334
335 def __str__(self):
336 return self.message
337
338
f5b83990 339class APIMixin(KerberosAuthMixin):
f062b044
MT
340 # Generally do not permit users to authenticate against the API
341 allow_users = False
342
f5b83990
MT
343 # Allow builders to authenticate?
344 allow_builders = True
345
f062b044
MT
346 # Do not perform any XSRF cookie validation on API calls
347 def check_xsrf_cookie(self):
348 pass
349
350 def get_current_user(self):
351 """
352 Authenticates a user or builder
353 """
354 # Fetch the Kerberos ticket
355 principal = self.get_authenticated_user()
356
357 # Return nothing if we did not receive any credentials
358 if not principal:
359 return
360
361 logging.debug("Searching for principal %s..." % principal)
362
363 # Strip the realm
364 principal, delimiter, realm = principal.partition("@")
365
366 # Return any builders
f5b83990 367 if self.allow_builders and principal.startswith("host/"):
f062b044
MT
368 hostname = principal.removeprefix("host/")
369
370 return self.backend.builders.get_by_name(hostname)
371
f5b83990
MT
372 # Return any users
373 if self.allow_users:
374 return self.backend.users.get_by_name(principal)
f062b044
MT
375
376 def get_user_locale(self):
377 return self.get_browser_locale()
378
46a7bd16
MT
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
1a1584ce
MT
389 def get_compression_options(self):
390 # Enable maximum compression
391 return {
392 "compression_level" : 9,
393 "mem_level" : 9,
394 }
395
c27e7f37
MT
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
856433f4 407 self.set_status(200)
c27e7f37 408
f062b044 409 self.finish({
c27e7f37
MT
410 "error" : {
411 "code" : error.code,
412 "message" : error.message,
413 },
f062b044
MT
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
4f1cef10
MT
430
431
8dc73381
MT
432def 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
4f1cef10
MT
452class 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