gnt-cluster: Add hv/disk state to init
[ganeti-local] / lib / http / auth.py
index dc4c801..5a7bfac 100644 (file)
 """
 
 import logging
-import time
 import re
 import base64
 import binascii
 
-from ganeti import constants
-from ganeti import utils
+from ganeti import compat
 from ganeti import http
 
 from cStringIO import StringIO
 
-
 # Digest types from RFC2617
 HTTP_BASIC_AUTH = "Basic"
 HTTP_DIGEST_AUTH = "Digest"
 
 # Not exactly as described in RFC2616, section 2.2, but good enough
-_NOQUOTE = re.compile(r"^[-_a-z0-9]$", re.I)
+_NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
 
 
 def _FormatAuthHeader(scheme, params):
@@ -75,23 +72,43 @@ def _FormatAuthHeader(scheme, params):
 
 class HttpServerRequestAuthentication(object):
   # Default authentication realm
-  AUTH_REALM = None
+  AUTH_REALM = "Unspecified"
+
+  # Schemes for passwords
+  _CLEARTEXT_SCHEME = "{CLEARTEXT}"
+  _HA1_SCHEME = "{HA1}"
 
   def GetAuthRealm(self, req):
     """Returns the authentication realm for a request.
 
-    MAY be overriden by a subclass, which then can return different realms for
-    different paths. Returning "None" means no authentication is needed for a
-    request.
+    May be overridden by a subclass, which then can return different realms for
+    different paths.
 
     @type req: L{http.server._HttpServerRequest}
     @param req: HTTP request context
-    @rtype: str or None
+    @rtype: string
     @return: Authentication realm
 
     """
+    # today we don't have per-request filtering, but we might want to
+    # add it in the future
+    # pylint: disable=W0613
     return self.AUTH_REALM
 
+  def AuthenticationRequired(self, req):
+    """Determines whether authentication is required for a request.
+
+    To enable authentication, override this function in a subclass and return
+    C{True}. L{AUTH_REALM} must be set.
+
+    @type req: L{http.server._HttpServerRequest}
+    @param req: HTTP request context
+
+    """
+    # Unused argument, method could be a function
+    # pylint: disable=W0613,R0201
+    return False
+
   def PreHandleRequest(self, req):
     """Called before a request is handled.
 
@@ -99,11 +116,16 @@ class HttpServerRequestAuthentication(object):
     @param req: HTTP request context
 
     """
+    # Authentication not required, and no credentials given?
+    if not (self.AuthenticationRequired(req) or
+            (req.request_headers and
+             http.HTTP_AUTHORIZATION in req.request_headers)):
+      return
+
     realm = self.GetAuthRealm(req)
 
-    # Authentication required?
-    if realm is None:
-      return
+    if not realm:
+      raise AssertionError("No authentication realm")
 
     # Check "Authorization" header
     if self._CheckAuthorization(req):
@@ -176,7 +198,7 @@ class HttpServerRequestAuthentication(object):
 
     """
     try:
-      creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
+      creds = base64.b64decode(in_data.encode("ascii")).decode("ascii")
     except (TypeError, binascii.Error, UnicodeError):
       logging.exception("Error when decoding Basic authentication credentials")
       return False
@@ -188,14 +210,71 @@ class HttpServerRequestAuthentication(object):
 
     return self.Authenticate(req, user, password)
 
-  def AuthenticateBasic(self, req, user, password):
+  def Authenticate(self, req, user, password):
     """Checks the password for a user.
 
-    This function MUST be overriden by a subclass.
+    This function MUST be overridden by a subclass.
 
     """
     raise NotImplementedError()
 
+  def VerifyBasicAuthPassword(self, req, username, password, expected):
+    """Checks the password for basic authentication.
+
+    As long as they don't start with an opening brace ("E{lb}"), old passwords
+    are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
+    consists of the username, the authentication realm and the actual password.
+
+    @type req: L{http.server._HttpServerRequest}
+    @param req: HTTP request context
+    @type username: string
+    @param username: Username from HTTP headers
+    @type password: string
+    @param password: Password from HTTP headers
+    @type expected: string
+    @param expected: Expected password with optional scheme prefix (e.g. from
+                     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)
+      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
+
+    # H(A1) as described in RFC2617
+    if scheme == self._HA1_SCHEME:
+      realm = self.GetAuthRealm(req)
+      if not realm:
+        # There can not be a valid password for this case
+        raise AssertionError("No authentication realm")
+
+      expha1 = compat.md5_hash()
+      expha1.update("%s:%s:%s" % (username, realm, password))
+
+      return (expected_password.lower() == expha1.hexdigest().lower())
+
+    logging.warning("Unknown scheme '%s' in password for user '%s'",
+                    scheme, username)
+
+    return False
+
 
 class PasswordFileUser(object):
   """Data structure for users from password file.
@@ -207,8 +286,8 @@ class PasswordFileUser(object):
     self.options = options
 
 
-def ReadPasswordFile(file_name):
-  """Reads a password file.
+def ParsePasswordFile(contents):
+  """Parses the contents of a password file.
 
   Lines in the password file are of the following format::
 
@@ -218,15 +297,15 @@ def ReadPasswordFile(file_name):
   options are optional and separated by comma (','). Empty lines and comments
   ('#') are ignored.
 
-  @type file_name: str
-  @param file_name: Path to password file
+  @type contents: str
+  @param contents: Contents of password file
   @rtype: dict
   @return: Dictionary containing L{PasswordFileUser} instances
 
   """
   users = {}
 
-  for line in utils.ReadFile(file_name).splitlines():
+  for line in contents.splitlines():
     line = line.strip()
 
     # Ignore empty lines and comments