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

Reply via email to