X-Git-Url: https://code.grnet.gr/git/ganeti-local/blobdiff_plain/be500c29b32a1e644840338dfd667273b197e1b9..a7f884d3bebdbce19bc75e95d107e2ffd6840d2a:/lib/http/auth.py diff --git a/lib/http/auth.py b/lib/http/auth.py index a85baa8..09b0ce7 100644 --- a/lib/http/auth.py +++ b/lib/http/auth.py @@ -23,24 +23,21 @@ """ 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-msg=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-msg=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): @@ -125,12 +147,10 @@ class HttpServerRequestAuthentication(object): raise http.HttpUnauthorized(headers=headers) def _CheckAuthorization(self, req): - """Checks "Authorization" header sent by client. + """Checks 'Authorization' header sent by client. @type req: L{http.server._HttpServerRequest} @param req: HTTP request context - @type credentials: str - @param credentials: Credentials sent @rtype: bool @return: Whether user is allowed to execute request @@ -166,19 +186,19 @@ class HttpServerRequestAuthentication(object): # Unsupported authentication scheme return False - def _CheckBasicAuthorization(self, req, input): + def _CheckBasicAuthorization(self, req, in_data): """Checks credentials sent for basic authentication. @type req: L{http.server._HttpServerRequest} @param req: HTTP request context - @type input: str - @param input: Username and password encoded as Base64 + @type in_data: str + @param in_data: Username and password encoded as Base64 @rtype: bool @return: Whether user is allowed to execute request """ try: - creds = base64.b64decode(input.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 @@ -190,10 +210,122 @@ 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. + + """ + 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:: + + [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 contents.splitlines(): + line = line.strip() + + # Ignore empty lines and comments + if not line or line.startswith("#"): + continue + + parts = line.split(None, 2) + if len(parts) < 2: + # Invalid line + continue + + name = parts[0] + password = parts[1] + + # Extract options + options = [] + if len(parts) >= 3: + for part in parts[2].split(","): + options.append(part.strip()) + + users[name] = PasswordFileUser(name, password, options) + + return users