]> git.ipfire.org Git - pbs.git/blobdiff - src/web/base.py
auth: Revert back to authentication using a web form
[pbs.git] / src / web / base.py
index 77541961f35c534c111a73a8a5cb33fdb0df1a09..15dd79a76d5d0841ea1df7973642fc348afa8fa8 100644 (file)
 #!/usr/bin/python
 
-import httplib
-import pytz
+import asyncio
+import base64
+import functools
+import http.client
+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):
@@ -31,7 +170,7 @@ class BaseHandler(tornado.web.RequestHandler):
 
        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
@@ -55,20 +194,9 @@ class BaseHandler(tornado.web.RequestHandler):
                """
                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)
@@ -78,32 +206,210 @@ class BaseHandler(tornado.web.RequestHandler):
                        "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 = httplib.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("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)
+
+               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)
+
+               # Return nothing
+               if not arg:
+                       return None
+
+               try:
+                       return int(arg)
+               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
 
-               self.render(error_document, status_code=status_code,
-                       status_message=status_message, exc_info=exc_info, tb=tb, **kwargs)
+               return wrapper