4 # Copyright (C) 2007, 2008 Google Inc.
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 # General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
21 """HTTP authentication module.
30 from ganeti import utils
31 from ganeti import http
33 from cStringIO import StringIO
36 from hashlib import md5
38 from md5 import new as md5
41 # Digest types from RFC2617
42 HTTP_BASIC_AUTH = "Basic"
43 HTTP_DIGEST_AUTH = "Digest"
45 # Not exactly as described in RFC2616, section 2.2, but good enough
46 _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
49 def _FormatAuthHeader(scheme, params):
50 """Formats WWW-Authentication header value as per RFC2617, section 1.2
53 @param scheme: Authentication scheme
55 @param params: Additional parameters
57 @return: Formatted header value
64 for name, value in params.iteritems():
68 if _NOQUOTE.match(value):
72 # TODO: Better quoting
73 buf.write(value.replace("\"", "\\\""))
79 class HttpServerRequestAuthentication(object):
80 # Default authentication realm
83 # Schemes for passwords
84 _CLEARTEXT_SCHEME = "{CLEARTEXT}"
87 def GetAuthRealm(self, req):
88 """Returns the authentication realm for a request.
90 MAY be overridden by a subclass, which then can return different realms for
91 different paths. Returning "None" means no authentication is needed for a
94 @type req: L{http.server._HttpServerRequest}
95 @param req: HTTP request context
97 @return: Authentication realm
100 # today we don't have per-request filtering, but we might want to
101 # add it in the future
102 # pylint: disable-msg=W0613
103 return self.AUTH_REALM
105 def PreHandleRequest(self, req):
106 """Called before a request is handled.
108 @type req: L{http.server._HttpServerRequest}
109 @param req: HTTP request context
112 realm = self.GetAuthRealm(req)
114 # Authentication not required, and no credentials given?
115 if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
118 if realm is None: # in case we don't require auth but someone
119 # passed the crendentials anyway
120 realm = "Unspecified"
122 # Check "Authorization" header
123 if self._CheckAuthorization(req):
124 # User successfully authenticated
127 # Send 401 Unauthorized response
132 # TODO: Support for Digest authentication (RFC2617, section 3).
133 # TODO: Support for more than one WWW-Authenticate header with the same
134 # response (RFC2617, section 4.6).
136 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
139 raise http.HttpUnauthorized(headers=headers)
141 def _CheckAuthorization(self, req):
142 """Checks 'Authorization' header sent by client.
144 @type req: L{http.server._HttpServerRequest}
145 @param req: HTTP request context
147 @return: Whether user is allowed to execute request
150 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
155 parts = credentials.strip().split(None, 2)
160 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
161 # token to identify the authentication scheme [...]"
162 scheme = parts[0].lower()
164 if scheme == HTTP_BASIC_AUTH.lower():
165 # Do basic authentication
167 raise http.HttpBadRequest(message=("Basic authentication requires"
169 return self._CheckBasicAuthorization(req, parts[1])
171 elif scheme == HTTP_DIGEST_AUTH.lower():
172 # TODO: Implement digest authentication
173 # RFC2617, section 3.3: "Note that the HTTP server does not actually need
174 # to know the user's cleartext password. As long as H(A1) is available to
175 # the server, the validity of an Authorization header may be verified."
178 # Unsupported authentication scheme
181 def _CheckBasicAuthorization(self, req, in_data):
182 """Checks credentials sent for basic authentication.
184 @type req: L{http.server._HttpServerRequest}
185 @param req: HTTP request context
187 @param in_data: Username and password encoded as Base64
189 @return: Whether user is allowed to execute request
193 creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
194 except (TypeError, binascii.Error, UnicodeError):
195 logging.exception("Error when decoding Basic authentication credentials")
201 (user, password) = creds.split(":", 1)
203 return self.Authenticate(req, user, password)
205 def Authenticate(self, req, user, password):
206 """Checks the password for a user.
208 This function MUST be overridden by a subclass.
211 raise NotImplementedError()
213 def VerifyBasicAuthPassword(self, req, username, password, expected):
214 """Checks the password for basic authentication.
216 As long as they don't start with an opening brace ("E{lb}"), old passwords
217 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
218 consists of the username, the authentication realm and the actual password.
220 @type req: L{http.server._HttpServerRequest}
221 @param req: HTTP request context
222 @type username: string
223 @param username: Username from HTTP headers
224 @type password: string
225 @param password: Password from HTTP headers
226 @type expected: string
227 @param expected: Expected password with optional scheme prefix (e.g. from
231 # Backwards compatibility for old-style passwords without a scheme
232 if not expected.startswith("{"):
233 expected = self._CLEARTEXT_SCHEME + expected
235 # Check again, just to be sure
236 if not expected.startswith("{"):
237 raise AssertionError("Invalid scheme")
239 scheme_end_idx = expected.find("}", 1)
241 # Ensure scheme has a length of at least one character
242 if scheme_end_idx <= 1:
243 logging.warning("Invalid scheme in password for user '%s'", username)
246 scheme = expected[:scheme_end_idx + 1].upper()
247 expected_password = expected[scheme_end_idx + 1:]
249 # Good old plain text password
250 if scheme == self._CLEARTEXT_SCHEME:
251 return password == expected_password
253 # H(A1) as described in RFC2617
254 if scheme == self._HA1_SCHEME:
255 realm = self.GetAuthRealm(req)
257 # There can not be a valid password for this case
261 expha1.update("%s:%s:%s" % (username, realm, password))
263 return (expected_password.lower() == expha1.hexdigest().lower())
265 logging.warning("Unknown scheme '%s' in password for user '%s'",
271 class PasswordFileUser(object):
272 """Data structure for users from password file.
275 def __init__(self, name, password, options):
277 self.password = password
278 self.options = options
281 def ReadPasswordFile(file_name):
282 """Reads a password file.
284 Lines in the password file are of the following format::
286 <username> <password> [options]
288 Fields are separated by whitespace. Username and password are mandatory,
289 options are optional and separated by comma (','). Empty lines and comments
293 @param file_name: Path to password file
295 @return: Dictionary containing L{PasswordFileUser} instances
300 for line in utils.ReadFile(file_name).splitlines():
303 # Ignore empty lines and comments
304 if not line or line.startswith("#"):
307 parts = line.split(None, 2)
318 for part in parts[2].split(","):
319 options.append(part.strip())
321 users[name] = PasswordFileUser(name, password, options)