#!/usr/bin/python
+import asyncio
+import base64
+import functools
import http.client
-import pytz
+import json
+import kerberos
+import logging
+import os
import time
import tornado.locale
import tornado.web
+import tornado.websocket
import traceback
from .. import __version__
+from .. import builders
from .. import misc
+from .. import users
from ..decorators import *
+# Setup logging
+log = logging.getLogger("pbs.web.base")
+
+class KerberosAuthMixin(object):
+ """
+ A mixin that handles Kerberos authentication
+ """
+ @property
+ def kerberos_realm(self):
+ return "IPFIRE.ORG"
+
+ @property
+ def kerberos_service(self):
+ return self.settings.get("kerberos_service", "HTTP")
+
+ def authenticate_redirect(self):
+ """
+ Called when the application needs the user to authenticate.
+
+ We will send a response with status code 401 and set the
+ WWW-Authenticate header to ask the client to either initiate
+ some Kerberos authentication, or to perform HTTP Basic authentication.
+ """
+ # Ask the client to authenticate using Kerberos
+ self.add_header("WWW-Authenticate", "Negotiate")
+
+ # Ask the client to authenticate using HTTP Basic Auth
+ self.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.kerberos_realm)
+
+ # Set status to 401
+ self.set_status(401)
+
+ @functools.cache
+ def get_authenticated_user(self):
+ auth_header = self.request.headers.get("Authorization", None)
+
+ # No authentication header
+ if not auth_header:
+ return
+
+ # Perform GSS API Negotiation
+ if auth_header.startswith("Negotiate "):
+ return self._auth_negotiate(auth_header)
+
+ # Perform Basic Authentication
+ elif auth_header.startswith("Basic "):
+ return self._auth_basic(auth_header)
+
+ # Fail on anything else
+ else:
+ raise tornado.web.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header)
+
+ def _auth_negotiate(self, auth_header):
+ os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
+
+ auth_value = auth_header.removeprefix("Negotiate ")
+
+ try:
+ # Initialise the server session
+ result, context = kerberos.authGSSServerInit(self.kerberos_service)
+
+ if not result == kerberos.AUTH_GSS_COMPLETE:
+ raise tornado.web.HTTPError(500, "Kerberos Initialization failed: %s" % result)
+
+ # Check the received authentication header
+ result = kerberos.authGSSServerStep(context, auth_value)
+
+ # If this was not successful, we will fall back to Basic authentication
+ if not result == kerberos.AUTH_GSS_COMPLETE:
+ return self._auth_basic(auth_header)
+
+ if not isinstance(self, tornado.websocket.WebSocketHandler):
+ # Fetch the server response
+ response = kerberos.authGSSServerResponse(context)
+
+ # Send the server response
+ self.set_header("WWW-Authenticate", "Negotiate %s" % response)
+
+ # Return the user who just authenticated
+ user = kerberos.authGSSServerUserName(context)
+
+ except kerberos.GSSError as e:
+ log.error("Kerberos Authentication Error: %s" % e)
+
+ raise tornado.web.HTTPError(500, "Could not initialize the Kerberos context")
+
+ finally:
+ # Cleanup
+ kerberos.authGSSServerClean(context)
+
+ log.debug("Successfully authenticated %s" % user)
+
+ return user
+
+ def _auth_basic(self, auth_header):
+ os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
+
+ # Remove "Basic "
+ auth_header = auth_header.removeprefix("Basic ")
+
+ try:
+ # Decode base64
+ auth_header = base64.b64decode(auth_header).decode()
+
+ username, password = auth_header.split(":", 1)
+ except:
+ raise tornado.web.HTTPError(400, "Authorization data was malformed")
+
+ # Authenticate against Kerberos
+ return self._auth_with_credentials(username, password)
+
+ def _auth_with_credentials(self, username, password):
+ # Check the credentials against the Kerberos database
+ try:
+ kerberos.checkPassword(username, password,
+ "%s/pakfire.ipfire.org" % self.kerberos_service, self.kerberos_realm)
+
+ # Catch any authentication errors
+ except kerberos.BasicAuthError as e:
+ log.error("Could not authenticate %s: %s" % (username, e))
+ return
+
+ # Create user principal name
+ user = "%s@%s" % (username, self.kerberos_realm)
+
+ log.debug("Successfully authenticated %s" % user)
+
+ return user
+
+
class BaseHandler(tornado.web.RequestHandler):
@property
def backend(self):
def get_current_user(self):
if self.session:
- return self.session.impersonated_user or self.session.user
+ return self.session.user
def get_user_locale(self):
# Get the locale from the user settings
"""
return self.request.headers.get("User-Agent", None)
- @property
- def timezone(self):
- if self.current_user:
- return self.current_user.timezone
-
- return pytz.utc
-
- def format_date(self, date, relative=True, shorter=False,
- full_format=False):
- # XXX not very precise but working for now.
- gmt_offset = self.timezone.utcoffset(date).total_seconds() / -60
-
- return self.locale.format_date(date, gmt_offset=gmt_offset,
- relative=relative, shorter=shorter, full_format=full_format)
+ def format_date(self, date, relative=True, shorter=False, full_format=False):
+ return self.locale.format_date(date, relative=relative,
+ shorter=shorter, full_format=full_format)
def get_template_namespace(self):
ns = tornado.web.RequestHandler.get_template_namespace(self)
"hostname" : self.request.host,
"format_date" : self.format_date,
"format_size" : misc.format_size,
- "friendly_time" : misc.friendly_time,
- "format_filemode" : misc.format_filemode,
- "lang" : self.locale.code[:2],
- "session" : self.session,
"version" : __version__,
+ "xsrf_token" : self.xsrf_token,
"year" : time.strftime("%Y"),
})
return ns
- def write_error(self, status_code, exc_info=None, **kwargs):
- if status_code in (400, 403, 404):
- error_document = "errors/error-%s.html" % status_code
- else:
- error_document = "errors/error.html"
-
+ def write_error(self, code, exc_info=None, **kwargs):
try:
- status_message = http.client.responses[status_code]
+ message = http.client.responses[code]
except KeyError:
- status_message = None
+ message = None
+
+ _traceback = []
# Collect more information about the exception if possible.
if exc_info:
- tb = traceback.format_exception(*exc_info)
- else:
- tb = None
+ if self.current_user and isinstance(self.current_user, users.User):
+ if self.current_user.is_admin():
+ _traceback += traceback.format_exception(*exc_info)
- self.render(error_document, status_code=status_code,
- status_message=status_message, exc_info=exc_info, tb=tb, **kwargs)
+ self.render("errors/error.html",
+ code=code, message=message, traceback="".join(_traceback), **kwargs)
# Typed Arguments
def get_argument_bool(self, name):
arg = self.get_argument(name, default=None)
- return arg == "on"
+ if arg:
+ return arg.lower() in ("on", "true", "yes", "1")
+
+ return False
def get_argument_int(self, *args, **kwargs):
arg = self.get_argument(*args, **kwargs)
except (TypeError, ValueError):
raise tornado.web.HTTPError(400, "%s is not an integer" % arg)
+ def get_argument_builder(self, *args, **kwargs):
+ name = self.get_argument(*args, **kwargs)
+
+ if name:
+ return self.backend.builders.get_by_name(name)
+
def get_argument_distro(self, *args, **kwargs):
slug = self.get_argument(*args, **kwargs)
if slug:
return self.backend.distros.get_by_slug(slug)
+
+ # Uploads
+
+ def _get_upload(self, uuid):
+ upload = self.backend.uploads.get_by_uuid(uuid)
+
+ # Check permissions
+ if upload and not upload.has_perm(self.current_user):
+ raise tornado.web.HTTPError(403, "%s has no permissions for upload %s" % (self.current_user, upload))
+
+ return upload
+
+ def get_argument_upload(self, *args, **kwargs):
+ """
+ Returns an upload
+ """
+ uuid = self.get_argument(*args, **kwargs)
+
+ if uuid:
+ return self._get_upload(uuid)
+
+ def get_argument_uploads(self, *args, **kwargs):
+ """
+ Returns a list of uploads
+ """
+ uuids = self.get_arguments(*args, **kwargs)
+
+ # Return all uploads
+ return [self._get_upload(uuid) for uuid in uuids]
+
+ def get_argument_user(self, *args, **kwargs):
+ name = self.get_argument(*args, **kwargs)
+
+ if name:
+ return self.backend.users.get_by_name(name)
+
+# XXX TODO
+BackendMixin = BaseHandler
+
+class APIMixin(KerberosAuthMixin, BackendMixin):
+ # Generally do not permit users to authenticate against the API
+ allow_users = False
+
+ # Do not perform any XSRF cookie validation on API calls
+ def check_xsrf_cookie(self):
+ pass
+
+ def get_current_user(self):
+ """
+ Authenticates a user or builder
+ """
+ # Fetch the Kerberos ticket
+ principal = self.get_authenticated_user()
+
+ # Return nothing if we did not receive any credentials
+ if not principal:
+ return
+
+ logging.debug("Searching for principal %s..." % principal)
+
+ # Strip the realm
+ principal, delimiter, realm = principal.partition("@")
+
+ # Return any builders
+ if principal.startswith("host/"):
+ hostname = principal.removeprefix("host/")
+
+ return self.backend.builders.get_by_name(hostname)
+
+ # End here if users are not allowed to authenticate
+ if not self.allow_users:
+ return
+
+ # Return users
+ return self.backend.users.get_by_name(principal)
+
+ def get_user_locale(self):
+ return self.get_browser_locale()
+
+ @property
+ def builder(self):
+ """
+ This is a convenience handler to access a builder by a better name
+ """
+ if isinstance(self.current_user, builders.Builder):
+ return self.current_user
+
+ raise AttributeError
+
+ def get_compression_options(self):
+ # Enable maximum compression
+ return {
+ "compression_level" : 9,
+ "mem_level" : 9,
+ }
+
+ def write_error(self, code, **kwargs):
+ # Send a JSON-encoded error message
+ self.finish({
+ "error" : True,
+ # XXX add error string
+ })
+
+ def _decode_json_message(self, message):
+ # Decode JSON message
+ try:
+ message = json.loads(message)
+
+ except json.DecodeError as e:
+ log.error("Could not decode JSON message", exc_info=True)
+ raise e
+
+ # Log message
+ log.debug("Received message:")
+ log.debug("%s" % json.dumps(message, indent=4))
+
+ return message
+
+
+class ratelimit(object):
+ """
+ A decorator class which limits how often a function can be called
+ """
+ def __init__(self, *, minutes, requests):
+ self.minutes = minutes
+ self.requests = requests
+
+ def __call__(self, method):
+ @functools.wraps(method)
+ async def wrapper(handler, *args, **kwargs):
+ # Pass the request to the rate limiter and get a request object
+ req = handler.backend.ratelimiter.handle_request(handler.request,
+ handler, minutes=self.minutes, limit=self.requests)
+
+ # If the rate limit has been reached, we won't allow
+ # processing the request and therefore send HTTP error code 429.
+ if await req.is_ratelimited():
+ raise tornado.web.HTTPError(429, "Rate limit exceeded")
+
+ # Call the wrapped method
+ result = method(handler, *args, **kwargs)
+
+ # Await it if it is a coroutine
+ if asyncio.iscoroutine(result):
+ return await result
+
+ # Return the result
+ return result
+
+ return wrapper