Functions and classes related to authentication moved to rapi.auth module and are abstracted by RapiAuthenticator class in order to simplify implementation of other authentication modules.
Signed-off-by: Oleg Ponomarev <oponoma...@google.com> --- Makefile.am | 11 ++- lib/http/auth.py | 102 ++++++++++++++--------- lib/rapi/auth/__init__.py | 52 ++++++++++++ lib/rapi/auth/basic_auth.py | 148 +++++++++++++++++++++++++++++++++ lib/rapi/auth/users_file.py | 137 ++++++++++++++++++++++++++++++ lib/rapi/testutils.py | 22 +++-- lib/rapi/users_file.py | 137 ------------------------------ lib/server/rapi.py | 101 +++------------------- qa/qa_rapi.py | 2 +- test/py/ganeti.http_unittest.py | 42 +++++++--- test/py/ganeti.server.rapi_unittest.py | 42 ++++++---- 11 files changed, 489 insertions(+), 307 deletions(-) create mode 100644 lib/rapi/auth/__init__.py create mode 100644 lib/rapi/auth/basic_auth.py create mode 100644 lib/rapi/auth/users_file.py delete mode 100644 lib/rapi/users_file.py diff --git a/Makefile.am b/Makefile.am index 979f596..1f50bf2 100644 --- a/Makefile.am +++ b/Makefile.am @@ -99,6 +99,7 @@ httpdir = $(pkgpythondir)/http masterddir = $(pkgpythondir)/masterd confddir = $(pkgpythondir)/confd rapidir = $(pkgpythondir)/rapi +rapi_authdir = $(pkgpythondir)/rapi/auth rpcdir = $(pkgpythondir)/rpc rpc_stubdir = $(pkgpythondir)/rpc/stub serverdir = $(pkgpythondir)/server @@ -219,6 +220,7 @@ DIRS = \ lib/impexpd \ lib/masterd \ lib/rapi \ + lib/rapi/auth \ lib/rpc \ lib/rpc/stub \ lib/server \ @@ -568,8 +570,12 @@ rapi_PYTHON = \ lib/rapi/client_utils.py \ lib/rapi/connector.py \ lib/rapi/rlib2.py \ - lib/rapi/testutils.py \ - lib/rapi/users_file.py + lib/rapi/testutils.py + +rapi_auth_PYTHON = \ + lib/rapi/auth/__init__.py \ + lib/rapi/auth/basic_auth.py \ + lib/rapi/auth/users_file.py http_PYTHON = \ lib/http/__init__.py \ @@ -2121,6 +2127,7 @@ all_python_code = \ $(jqueue_PYTHON) \ $(storage_PYTHON) \ $(rapi_PYTHON) \ + $(rapi_auth_PYTHON) \ $(server_PYTHON) \ $(rpc_PYTHON) \ $(rpc_stub_PYTHON) \ diff --git a/lib/http/auth.py b/lib/http/auth.py index 83f0cba..0e07f00 100644 --- a/lib/http/auth.py +++ b/lib/http/auth.py @@ -136,8 +136,8 @@ class HttpServerRequestAuthentication(object): if not realm: raise AssertionError("No authentication realm") - # Check "Authorization" header - if self._CheckAuthorization(req): + # Check Authentication + if self.Authenticate(req): # User successfully authenticated return @@ -155,24 +155,25 @@ class HttpServerRequestAuthentication(object): raise http.HttpUnauthorized(headers=headers) - def _CheckAuthorization(self, req): - """Checks 'Authorization' header sent by client. + @classmethod + def ExtractUserPassword(cls, req): + """Extracts a user and a password from the http authorization header. @type req: L{http.server._HttpServerRequest} - @param req: HTTP request context - @rtype: bool - @return: Whether user is allowed to execute request - + @param req: HTTP request + @rtype: (str, str) + @return: A tuple containing a user and a password. One or both values + might be None if they are not presented """ credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None) if not credentials: - return False + return None, None # Extract scheme parts = credentials.strip().split(None, 2) if len(parts) < 1: # Missing scheme - return False + return None, None # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive # token to identify the authentication scheme [...]" @@ -183,7 +184,7 @@ class HttpServerRequestAuthentication(object): if len(parts) < 2: raise http.HttpBadRequest(message=("Basic authentication requires" " credentials")) - return self._CheckBasicAuthorization(req, parts[1]) + return cls._ExtractBasicUserPassword(parts[1]) elif scheme == HTTP_DIGEST_AUTH.lower(): # TODO: Implement digest authentication @@ -193,40 +194,73 @@ class HttpServerRequestAuthentication(object): pass # Unsupported authentication scheme - return False + return None, None - def _CheckBasicAuthorization(self, req, in_data): - """Checks credentials sent for basic authentication. + @staticmethod + def _ExtractBasicUserPassword(in_data): + """Extracts user and password from the contents of an authorization header. - @type req: L{http.server._HttpServerRequest} - @param req: HTTP request context @type in_data: str @param in_data: Username and password encoded as Base64 - @rtype: bool - @return: Whether user is allowed to execute request + @rtype: (str, str) + @return: A tuple containing user and password. One or both values might be + None if they are not presented """ try: creds = base64.b64decode(in_data.encode("ascii")).decode("ascii") except (TypeError, binascii.Error, UnicodeError): logging.exception("Error when decoding Basic authentication credentials") - return False + raise http.HttpBadRequest(message=("Invalid basic authorization header")) if ":" not in creds: - return False + # We have just a username without password + return creds, None - (user, password) = creds.split(":", 1) + # return (user, password) tuple + return creds.split(":", 1) - return self.Authenticate(req, user, password) - - def Authenticate(self, req, user, password): - """Checks the password for a user. + def Authenticate(self, req): + """Checks the credentiales. This function MUST be overridden by a subclass. """ raise NotImplementedError() + @classmethod + def ExtractSchemePassword(cls, expected_password): + """Extracts a scheme and a password from the expected_password. + + @type expected_password: str + @param expected_password: Username and password encoded as Base64 + @rtype: (str, str) + @return: A tuple containing a scheme and a password. Both values will be + None when an invalid scheme or password encoded + + """ + if expected_password is None: + return None, None + # Backwards compatibility for old-style passwords without a scheme + if not expected_password.startswith("{"): + expected_password = cls._CLEARTEXT_SCHEME + expected_password + + # Check again, just to be sure + if not expected_password.startswith("{"): + raise AssertionError("Invalid scheme") + + scheme_end_idx = expected_password.find("}", 1) + + # Ensure scheme has a length of at least one character + if scheme_end_idx <= 1: + logging.warning("Invalid scheme in password") + return None, None + + scheme = expected_password[:scheme_end_idx + 1].upper() + password = expected_password[scheme_end_idx + 1:] + + return scheme, password + def VerifyBasicAuthPassword(self, req, username, password, expected): """Checks the password for basic authentication. @@ -245,24 +279,11 @@ class HttpServerRequestAuthentication(object): users file) """ - # Backwards compatibility for old-style passwords without a scheme - if not expected.startswith("{"): - expected = self._CLEARTEXT_SCHEME + expected - # Check again, just to be sure - if not expected.startswith("{"): - raise AssertionError("Invalid scheme") - - scheme_end_idx = expected.find("}", 1) - - # Ensure scheme has a length of at least one character - if scheme_end_idx <= 1: - logging.warning("Invalid scheme in password for user '%s'", username) + scheme, expected_password = self.ExtractSchemePassword(expected) + if scheme is None or password is None: return False - scheme = expected[:scheme_end_idx + 1].upper() - expected_password = expected[scheme_end_idx + 1:] - # Good old plain text password if scheme == self._CLEARTEXT_SCHEME: return password == expected_password @@ -283,4 +304,3 @@ class HttpServerRequestAuthentication(object): scheme, username) return False - diff --git a/lib/rapi/auth/__init__.py b/lib/rapi/auth/__init__.py new file mode 100644 index 0000000..bb43f2e --- /dev/null +++ b/lib/rapi/auth/__init__.py @@ -0,0 +1,52 @@ +# +# + +# Copyright (C) 2015 Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Module containing different authentificators that can be used by RAPI + +""" + + +class RapiAuthenticator(object): + """Class providing authentication interface for RAPi requests. + + """ + def ValidateRequest(self, req, handler_access): + """Checks whether it's permitted to execute an rapi request. + + Must be implemented in derived classes. + + @type req: L{http.server._HttpServerRequest} + @param req: HTTP request context + @type handler_access: set of strings + @param handler_access: access rights required by the requested resourse + @rtype: bool + @return: Whether request execution is permitted + + """ + raise NotImplementedError() diff --git a/lib/rapi/auth/basic_auth.py b/lib/rapi/auth/basic_auth.py new file mode 100644 index 0000000..66400b8 --- /dev/null +++ b/lib/rapi/auth/basic_auth.py @@ -0,0 +1,148 @@ +# +# + +# Copyright (C) 2006, 2007, 2008, 2009, 2010, 2012, 2013, 2015 Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +"""Module interacting with RAPI users config file + +""" + +import logging +import os +try: + from pyinotify import pyinotify # pylint: disable=E0611 +except ImportError: + import pyinotify + +from ganeti import asyncnotifier +from ganeti import compat +from ganeti import http +from ganeti.http.auth import HttpServerRequestAuthentication +from ganeti import pathutils +from ganeti.rapi import auth +from ganeti.rapi.auth import users_file + + +class FileEventHandler(asyncnotifier.FileEventHandlerBase): + def __init__(self, wm, path, cb): + """Initializes this class. + + @param wm: Inotify watch manager + @type path: string + @param path: File path + @type cb: callable + @param cb: Function called on file change + + """ + asyncnotifier.FileEventHandlerBase.__init__(self, wm) + + self._cb = cb + self._filename = os.path.basename(path) + + # Different Pyinotify versions have the flag constants at different places, + # hence not accessing them directly + mask = (pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"] | + pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"] | + pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_FROM"] | + pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_TO"]) + + self._handle = self.AddWatch(os.path.dirname(path), mask) + + def process_default(self, event): + """Called upon inotify event. + + """ + if event.name == self._filename: + logging.debug("Received inotify event %s", event) + self._cb() + + +def SetupFileWatcher(filename, cb): + """Configures an inotify watcher for a file. + + @type filename: string + @param filename: File to watch + @type cb: callable + @param cb: Function called on file change + + """ + wm = pyinotify.WatchManager() + handler = FileEventHandler(wm, filename, cb) + asyncnotifier.AsyncNotifier(wm, default_proc_fun=handler) + + +class BasicAuthenticator(auth.RapiAuthenticator): + """Class providing an Authenticate method based on basic http authentication. + + """ + + def __init__(self, user_fn=None): + """Loads users file and initializes a watcher for it. + + @param user_fn: A function that should be called to obtain a user info + instead of the default users_file interface. + + """ + self.user_fn = user_fn + if user_fn: + return + + self.users = users_file.RapiUsers() + # Setup file watcher (it'll be driven by asyncore) + SetupFileWatcher(pathutils.RAPI_USERS_FILE, + compat.partial(self.users.Load, + pathutils.RAPI_USERS_FILE)) + + self.users.Load(pathutils.RAPI_USERS_FILE) + + def ValidateRequest(self, req, handler_access): + """Checks whether a user can access a resource. + + """ + username, password = HttpServerRequestAuthentication \ + .ExtractUserPassword(req) + if username is None: + raise http.HttpUnauthorized() + if password is None: + raise http.HttpBadRequest(message=("Basic authentication requires" + " password")) + + user = self.user_fn(username) if self.user_fn else self.users.Get(username) + _, expected_password = HttpServerRequestAuthentication \ + .ExtractSchemePassword(password) + if not (user and expected_password == user.password): + # Unknown user or password wrong + return False + + if (not handler_access or + set(user.options).intersection(handler_access)): + # Allow access + return True + + # Access forbidden + raise http.HttpForbidden() diff --git a/lib/rapi/auth/users_file.py b/lib/rapi/auth/users_file.py new file mode 100644 index 0000000..e2a26d3 --- /dev/null +++ b/lib/rapi/auth/users_file.py @@ -0,0 +1,137 @@ +# +# + +# Copyright (C) 2006, 2007, 2008, 2009, 2010, 2012, 2013, 2015 Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""RAPI users config file parser. + +""" + +import errno +import logging + +from ganeti import utils + + +class PasswordFileUser(object): + """Data structure for users from password file. + + """ + def __init__(self, name, password, options): + self.name = name + self.password = password + self.options = options + + +def ParsePasswordFile(contents): + """Parses the contents of a password file. + + Lines in the password file are of the following format:: + + <username> <password> [options] + + Fields are separated by whitespace. Username and password are mandatory, + options are optional and separated by comma (','). Empty lines and comments + ('#') are ignored. + + @type contents: str + @param contents: Contents of password file + @rtype: dict + @return: Dictionary containing L{PasswordFileUser} instances + + """ + users = {} + + for line in utils.FilterEmptyLinesAndComments(contents): + parts = line.split(None, 2) + if len(parts) < 2: + # Invalid line + # TODO: Return line number from FilterEmptyLinesAndComments + logging.warning("Ignoring non-comment line with less than two fields") + continue + + name = parts[0] + password = parts[1] + + # Extract options + options = [] + if len(parts) >= 3: + for part in parts[2].split(","): + options.append(part.strip()) + else: + logging.warning("Ignoring values for user '%s': %s", name, parts[3:]) + + users[name] = PasswordFileUser(name, password, options) + + return users + + +class RapiUsers(object): + def __init__(self): + """Initializes this class. + + """ + self._users = None + + def Get(self, username): + """Checks whether a user exists. + + """ + if self._users: + return self._users.get(username, None) + else: + return None + + def Load(self, filename): + """Loads a file containing users and passwords. + + @type filename: string + @param filename: Path to file + + """ + logging.info("Reading users file at %s", filename) + try: + try: + contents = utils.ReadFile(filename) + except EnvironmentError, err: + self._users = None + if err.errno == errno.ENOENT: + logging.warning("No users file at %s", filename) + else: + logging.warning("Error while reading %s: %s", filename, err) + return False + + users = ParsePasswordFile(contents) + + except Exception, err: # pylint: disable=W0703 + # We don't care about the type of exception + logging.error("Error while parsing %s: %s", filename, err) + return False + + self._users = users + + return True diff --git a/lib/rapi/testutils.py b/lib/rapi/testutils.py index d4aa04b..efa1405 100644 --- a/lib/rapi/testutils.py +++ b/lib/rapi/testutils.py @@ -50,10 +50,9 @@ from ganeti import rapi import ganeti.http.server # pylint: disable=W0611 import ganeti.server.rapi -from ganeti.rapi import users_file +from ganeti.rapi.auth import users_file import ganeti.rapi.client - _URI_RE = re.compile(r"https://(?P<host>.*):(?P<port>\d+)(?P<path>/.*)") @@ -359,18 +358,23 @@ class InputTestClient(object): username = utils.GenerateSecret() password = utils.GenerateSecret() - def user_fn(wanted): - """Called to verify user credentials given in HTTP request. + # pylint: disable=W0232 + class SimpleAuthenticator(): + # pylint: disable=R0201 + def ValidateRequest(self, req, _=None): + """Called to verify user credentials given in HTTP request. - """ - assert username == wanted - return users_file.PasswordFileUser(username, password, - [rapi.RAPI_ACCESS_WRITE]) + """ + wanted, _ = http.auth.HttpServerRequestAuthentication \ + .ExtractUserPassword(req) + assert username == wanted + return users_file.PasswordFileUser(username, password, + [rapi.RAPI_ACCESS_WRITE]).name self._lcr = _LuxiCallRecorder() # Create a mock RAPI server - handler = _RapiMock(user_fn, self._lcr) + handler = _RapiMock(SimpleAuthenticator(), self._lcr) self._client = \ rapi.client.GanetiRapiClient("master.example.com", diff --git a/lib/rapi/users_file.py b/lib/rapi/users_file.py deleted file mode 100644 index e2a26d3..0000000 --- a/lib/rapi/users_file.py +++ /dev/null @@ -1,137 +0,0 @@ -# -# - -# Copyright (C) 2006, 2007, 2008, 2009, 2010, 2012, 2013, 2015 Google Inc. -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -"""RAPI users config file parser. - -""" - -import errno -import logging - -from ganeti import utils - - -class PasswordFileUser(object): - """Data structure for users from password file. - - """ - def __init__(self, name, password, options): - self.name = name - self.password = password - self.options = options - - -def ParsePasswordFile(contents): - """Parses the contents of a password file. - - Lines in the password file are of the following format:: - - <username> <password> [options] - - Fields are separated by whitespace. Username and password are mandatory, - options are optional and separated by comma (','). Empty lines and comments - ('#') are ignored. - - @type contents: str - @param contents: Contents of password file - @rtype: dict - @return: Dictionary containing L{PasswordFileUser} instances - - """ - users = {} - - for line in utils.FilterEmptyLinesAndComments(contents): - parts = line.split(None, 2) - if len(parts) < 2: - # Invalid line - # TODO: Return line number from FilterEmptyLinesAndComments - logging.warning("Ignoring non-comment line with less than two fields") - continue - - name = parts[0] - password = parts[1] - - # Extract options - options = [] - if len(parts) >= 3: - for part in parts[2].split(","): - options.append(part.strip()) - else: - logging.warning("Ignoring values for user '%s': %s", name, parts[3:]) - - users[name] = PasswordFileUser(name, password, options) - - return users - - -class RapiUsers(object): - def __init__(self): - """Initializes this class. - - """ - self._users = None - - def Get(self, username): - """Checks whether a user exists. - - """ - if self._users: - return self._users.get(username, None) - else: - return None - - def Load(self, filename): - """Loads a file containing users and passwords. - - @type filename: string - @param filename: Path to file - - """ - logging.info("Reading users file at %s", filename) - try: - try: - contents = utils.ReadFile(filename) - except EnvironmentError, err: - self._users = None - if err.errno == errno.ENOENT: - logging.warning("No users file at %s", filename) - else: - logging.warning("Error while reading %s: %s", filename, err) - return False - - users = ParsePasswordFile(contents) - - except Exception, err: # pylint: disable=W0703 - # We don't care about the type of exception - logging.error("Error while parsing %s: %s", filename, err) - return False - - self._users = users - - return True diff --git a/lib/server/rapi.py b/lib/server/rapi.py index 2fd61f7..f4ca7b5 100644 --- a/lib/server/rapi.py +++ b/lib/server/rapi.py @@ -38,26 +38,17 @@ import logging import optparse import sys -import os -import os.path -try: - from pyinotify import pyinotify # pylint: disable=E0611 -except ImportError: - import pyinotify - -from ganeti import asyncnotifier from ganeti import constants from ganeti import http from ganeti import daemon from ganeti import ssconf import ganeti.rpc.errors as rpcerr from ganeti import serializer -from ganeti import compat from ganeti import pathutils from ganeti.rapi import connector from ganeti.rapi import baserlib -from ganeti.rapi import users_file +from ganeti.rapi.auth import basic_auth import ganeti.http.auth # pylint: disable=W0611 import ganeti.http.server @@ -81,12 +72,12 @@ class RemoteApiHandler(http.auth.HttpServerRequestAuthentication, """ AUTH_REALM = "Ganeti Remote API" - def __init__(self, user_fn, reqauth, _client_cls=None): + def __init__(self, authenticator, reqauth, _client_cls=None): """Initializes this class. - @type user_fn: callable - @param user_fn: Function receiving username as string and returning - L{http.auth.PasswordFileUser} or C{None} if user is not found + @type authenticator: an implementation of {RapiAuthenticator} interface + @param authenticator: a class containing an implementation of + ValidateRequest function @type reqauth: bool @param reqauth: Whether to require authentication @@ -97,7 +88,7 @@ class RemoteApiHandler(http.auth.HttpServerRequestAuthentication, http.auth.HttpServerRequestAuthentication.__init__(self) self._client_cls = _client_cls self._resmap = connector.Mapper() - self._user_fn = user_fn + self._authenticator = authenticator self._reqauth = reqauth @staticmethod @@ -154,28 +145,14 @@ class RemoteApiHandler(http.auth.HttpServerRequestAuthentication, """Determine whether authentication is required. """ - return self._reqauth or bool(self._GetRequestContext(req).handler_access) + return self._reqauth - def Authenticate(self, req, username, password): + def Authenticate(self, req): """Checks whether a user can access a resource. """ ctx = self._GetRequestContext(req) - - user = self._user_fn(username) - if not (user and - self.VerifyBasicAuthPassword(req, username, password, - user.password)): - # Unknown user or password wrong - return False - - if (not ctx.handler_access or - set(user.options).intersection(ctx.handler_access)): - # Allow access - return True - - # Access forbidden - raise http.HttpForbidden() + return self._authenticator.ValidateRequest(req, ctx.handler_access) def HandleRequest(self, req): """Handles a request. @@ -213,54 +190,6 @@ class RemoteApiHandler(http.auth.HttpServerRequestAuthentication, return serializer.DumpJson(result) -class FileEventHandler(asyncnotifier.FileEventHandlerBase): - def __init__(self, wm, path, cb): - """Initializes this class. - - @param wm: Inotify watch manager - @type path: string - @param path: File path - @type cb: callable - @param cb: Function called on file change - - """ - asyncnotifier.FileEventHandlerBase.__init__(self, wm) - - self._cb = cb - self._filename = os.path.basename(path) - - # Different Pyinotify versions have the flag constants at different places, - # hence not accessing them directly - mask = (pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"] | - pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"] | - pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_FROM"] | - pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_TO"]) - - self._handle = self.AddWatch(os.path.dirname(path), mask) - - def process_default(self, event): - """Called upon inotify event. - - """ - if event.name == self._filename: - logging.debug("Received inotify event %s", event) - self._cb() - - -def SetupFileWatcher(filename, cb): - """Configures an inotify watcher for a file. - - @type filename: string - @param filename: File to watch - @type cb: callable - @param cb: Function called on file change - - """ - wm = pyinotify.WatchManager() - handler = FileEventHandler(wm, filename, cb) - asyncnotifier.AsyncNotifier(wm, default_proc_fun=handler) - - def CheckRapi(options, args): """Initial checks whether to run or exit with a failure. @@ -286,15 +215,9 @@ def PrepRapi(options, _): """ mainloop = daemon.Mainloop() - users = users_file.RapiUsers() + authenticator = basic_auth.BasicAuthenticator() - handler = RemoteApiHandler(users.Get, options.reqauth) - - # Setup file watcher (it'll be driven by asyncore) - SetupFileWatcher(pathutils.RAPI_USERS_FILE, - compat.partial(users.Load, pathutils.RAPI_USERS_FILE)) - - users.Load(pathutils.RAPI_USERS_FILE) + handler = RemoteApiHandler(authenticator, options.reqauth) server = \ http.server.HttpServer(mainloop, options.bind_address, options.port, @@ -309,10 +232,12 @@ def ExecRapi(options, args, prep_data): # pylint: disable=W0613 """Main remote API function, executed with the PID file held. """ + (mainloop, server) = prep_data try: mainloop.Run() finally: + logging.error("RAPI Daemon Failed") server.Stop() diff --git a/qa/qa_rapi.py b/qa/qa_rapi.py index df3edcb..41a8ca3 100644 --- a/qa/qa_rapi.py +++ b/qa/qa_rapi.py @@ -54,7 +54,7 @@ from ganeti import query from ganeti import rapi from ganeti import utils -from ganeti.rapi.users_file import ParsePasswordFile +from ganeti.rapi.auth.users_file import ParsePasswordFile import ganeti.rapi.client # pylint: disable=W0611 import ganeti.rapi.client_utils diff --git a/test/py/ganeti.http_unittest.py b/test/py/ganeti.http_unittest.py index c713395..7f94ba1 100755 --- a/test/py/ganeti.http_unittest.py +++ b/test/py/ganeti.http_unittest.py @@ -42,7 +42,7 @@ from cStringIO import StringIO from ganeti import http from ganeti import compat -from ganeti.rapi import users_file +from ganeti.rapi.auth import users_file import ganeti.http.server import ganeti.http.client @@ -122,12 +122,12 @@ class TestMisc(unittest.TestCase): class _FakeRequestAuth(http.auth.HttpServerRequestAuthentication): - def __init__(self, realm, authreq, authenticate_fn): + def __init__(self, realm, authreq, authenticator): http.auth.HttpServerRequestAuthentication.__init__(self) self.realm = realm self.authreq = authreq - self.authenticate_fn = authenticate_fn + self.authenticator = authenticator def AuthenticationRequired(self, req): return self.authreq @@ -136,8 +136,8 @@ class _FakeRequestAuth(http.auth.HttpServerRequestAuthentication): return self.realm def Authenticate(self, *args): - if self.authenticate_fn: - return self.authenticate_fn(*args) + if self.authenticator: + return self.authenticator.ValidateRequest(*args) raise NotImplementedError() @@ -204,9 +204,20 @@ class _SimpleAuthenticator: self.password = password self.called = False - def __call__(self, req, user, password): + def ValidateRequest(self, req, _=None): self.called = True - return self.user == user and self.password == password + + username, password = http.auth.HttpServerRequestAuthentication \ + .ExtractUserPassword(req) + if username is None or password is None: + return False + + _, expected_password = http.auth.HttpServerRequestAuthentication \ + .ExtractSchemePassword(password) + if self.user == username and expected_password == self.password: + return True + + return False class TestHttpServerRequestAuthentication(unittest.TestCase): @@ -217,26 +228,30 @@ class TestHttpServerRequestAuthentication(unittest.TestCase): def testNoRealm(self): headers = { http.HTTP_AUTHORIZATION: "", } req = http.server._HttpServerRequest("GET", "/", headers, None) - ra = _FakeRequestAuth(None, False, None) + ac = _SimpleAuthenticator("foo", "bar") + ra = _FakeRequestAuth(None, False, ac) self.assertRaises(AssertionError, ra.PreHandleRequest, req) def testNoScheme(self): headers = { http.HTTP_AUTHORIZATION: "", } req = http.server._HttpServerRequest("GET", "/", headers, None) - ra = _FakeRequestAuth("area1", False, None) + ac = _SimpleAuthenticator("foo", "bar") + ra = _FakeRequestAuth("area1", False, ac) self.assertRaises(http.HttpUnauthorized, ra.PreHandleRequest, req) def testUnknownScheme(self): headers = { http.HTTP_AUTHORIZATION: "NewStyleAuth abc", } req = http.server._HttpServerRequest("GET", "/", headers, None) - ra = _FakeRequestAuth("area1", False, None) + ac = _SimpleAuthenticator("foo", "bar") + ra = _FakeRequestAuth("area1", False, ac) self.assertRaises(http.HttpUnauthorized, ra.PreHandleRequest, req) def testInvalidBase64(self): headers = { http.HTTP_AUTHORIZATION: "Basic x_=_", } req = http.server._HttpServerRequest("GET", "/", headers, None) - ra = _FakeRequestAuth("area1", False, None) - self.assertRaises(http.HttpUnauthorized, ra.PreHandleRequest, req) + ac = _SimpleAuthenticator("foo", "bar") + ra = _FakeRequestAuth("area1", False, ac) + self.assertRaises(http.HttpBadRequest, ra.PreHandleRequest, req) def testAuthForPublicResource(self): headers = { @@ -268,11 +283,12 @@ class TestHttpServerRequestAuthentication(unittest.TestCase): http.HttpBadRequest: ["Basic"], } + ac = _SimpleAuthenticator("foo", "bar") for exc, headers in checks.items(): for i in headers: headers = { http.HTTP_AUTHORIZATION: i, } req = http.server._HttpServerRequest("GET", "/", headers, None) - ra = _FakeRequestAuth("area1", False, None) + ra = _FakeRequestAuth("area1", False, ac) self.assertRaises(exc, ra.PreHandleRequest, req) def testBasicAuth(self): diff --git a/test/py/ganeti.server.rapi_unittest.py b/test/py/ganeti.server.rapi_unittest.py index 1bdda1d..d3b5a4a 100755 --- a/test/py/ganeti.server.rapi_unittest.py +++ b/test/py/ganeti.server.rapi_unittest.py @@ -46,8 +46,8 @@ from ganeti import rapi from ganeti import http from ganeti import objects -import ganeti.rapi.baserlib -from ganeti.rapi import users_file +from ganeti.rapi.auth.basic_auth import BasicAuthenticator +from ganeti.rapi.auth import users_file import ganeti.rapi.testutils import ganeti.rapi.rlib2 import ganeti.http.auth @@ -63,7 +63,8 @@ class TestRemoteApiHandler(unittest.TestCase): def _Test(self, method, path, headers, reqbody, user_fn=NotImplemented, luxi_client=NotImplemented, reqauth=False): - rm = rapi.testutils._RapiMock(user_fn, luxi_client, reqauth=reqauth) + rm = rapi.testutils._RapiMock(BasicAuthenticator(user_fn), luxi_client, + reqauth=reqauth) (resp_code, resp_headers, resp_body) = \ rm.FetchResponse(path, method, http.ParseHeaders(StringIO(headers)), @@ -106,7 +107,8 @@ class TestRemoteApiHandler(unittest.TestCase): self.assertTrue(data["message"].startswith("Method PUT is unsupported")) def testPostInstancesNoAuth(self): - (code, _, _) = self._Test(http.HTTP_POST, "/2/instances", "", None) + (code, _, _) = self._Test(http.HTTP_POST, "/2/instances", "", None, + reqauth=True) self.assertEqual(code, http.HttpUnauthorized.code) def testRequestWithUnsupportedMediaType(self): @@ -135,7 +137,8 @@ class TestRemoteApiHandler(unittest.TestCase): "%s: %s" % (http.HTTP_AUTHORIZATION, "Unsupported scheme"), ]) - (code, _, _) = self._Test(http.HTTP_POST, "/2/instances", headers, "") + (code, _, _) = self._Test(http.HTTP_POST, "/2/instances", headers, "", + reqauth=True) self.assertEqual(code, http.HttpUnauthorized.code) def testIncompleteBasicAuth(self): @@ -143,7 +146,8 @@ class TestRemoteApiHandler(unittest.TestCase): "%s: Basic" % http.HTTP_AUTHORIZATION, ]) - (code, _, data) = self._Test(http.HTTP_POST, "/2/instances", headers, "") + (code, _, data) = self._Test(http.HTTP_POST, "/2/instances", headers, "", + reqauth=True) self.assertEqual(code, http.HttpBadRequest.code) self.assertEqual(data["message"], "Basic authentication requires credentials") @@ -155,8 +159,9 @@ class TestRemoteApiHandler(unittest.TestCase): "%s: Basic %s" % (http.HTTP_AUTHORIZATION, auth), ]) - (code, _, data) = self._Test(http.HTTP_POST, "/2/instances", headers, "") - self.assertEqual(code, http.HttpUnauthorized.code) + (code, _, data) = self._Test(http.HTTP_POST, "/2/instances", headers, "", + reqauth=True) + self.assertEqual(code, http.HttpBadRequest.code) @staticmethod def _MakeAuthHeaders(username, password, correct_password): @@ -197,7 +202,7 @@ class TestRemoteApiHandler(unittest.TestCase): for method in rapi.baserlib._SUPPORTED_METHODS: # No authorization - (code, _, _) = self._Test(method, path, "", "") + (code, _, _) = self._Test(method, path, "", "", reqauth=True) if method in (http.HTTP_DELETE, http.HTTP_POST): self.assertEqual(code, http.HttpNotImplemented.code) @@ -207,22 +212,22 @@ class TestRemoteApiHandler(unittest.TestCase): # Incorrect user (code, _, _) = self._Test(method, path, header_fn(True), "", - user_fn=self._LookupWrongUser) + user_fn=self._LookupWrongUser, reqauth=True) self.assertEqual(code, http.HttpUnauthorized.code) # User has no write access, but the password is correct (code, _, _) = self._Test(method, path, header_fn(True), "", - user_fn=_LookupUserNoWrite) + user_fn=_LookupUserNoWrite, reqauth=True) self.assertEqual(code, http.HttpForbidden.code) # Wrong password and no write access (code, _, _) = self._Test(method, path, header_fn(False), "", - user_fn=_LookupUserNoWrite) + user_fn=_LookupUserNoWrite, reqauth=True) self.assertEqual(code, http.HttpUnauthorized.code) # Wrong password with write access (code, _, _) = self._Test(method, path, header_fn(False), "", - user_fn=_LookupUserWithWrite) + user_fn=_LookupUserWithWrite, reqauth=True) self.assertEqual(code, http.HttpUnauthorized.code) # Prepare request information @@ -240,7 +245,8 @@ class TestRemoteApiHandler(unittest.TestCase): # User has write access, password is correct (code, _, data) = self._Test(method, reqpath, header_fn(True), body, user_fn=_LookupUserWithWrite, - luxi_client=_FakeLuxiClientForQuery) + luxi_client=_FakeLuxiClientForQuery, + reqauth=True) self.assertEqual(code, http.HTTP_OK) self.assertTrue(objects.QueryResponse.FromDict(data)) @@ -249,10 +255,14 @@ class TestRemoteApiHandler(unittest.TestCase): for method in rapi.baserlib._SUPPORTED_METHODS: for reqauth in [False, True]: + if method == http.HTTP_GET and not reqauth: + # we don't have a mock client to test this case + continue # No authorization - (code, _, _) = self._Test(method, path, "", "", reqauth=reqauth) + (code, _, _) = self._Test(method, path, "", "", + user_fn=lambda _ : None, reqauth=reqauth) - if method == http.HTTP_GET or reqauth: + if method == http.HTTP_GET and reqauth: self.assertEqual(code, http.HttpUnauthorized.code) else: self.assertEqual(code, http.HttpNotImplemented.code) -- 2.6.0.rc2.230.g3dd15c0