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 return self.AUTH_REALM
102 def PreHandleRequest(self, req):
103 """Called before a request is handled.
105 @type req: L{http.server._HttpServerRequest}
106 @param req: HTTP request context
109 realm = self.GetAuthRealm(req)
111 # Authentication not required, and no credentials given?
112 if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
115 if realm is None: # in case we don't require auth but someone
116 # passed the crendentials anyway
117 realm = "Unspecified"
119 # Check "Authorization" header
120 if self._CheckAuthorization(req):
121 # User successfully authenticated
124 # Send 401 Unauthorized response
129 # TODO: Support for Digest authentication (RFC2617, section 3).
130 # TODO: Support for more than one WWW-Authenticate header with the same
131 # response (RFC2617, section 4.6).
133 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
136 raise http.HttpUnauthorized(headers=headers)
138 def _CheckAuthorization(self, req):
139 """Checks 'Authorization' header sent by client.
141 @type req: L{http.server._HttpServerRequest}
142 @param req: HTTP request context
144 @return: Whether user is allowed to execute request
147 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
152 parts = credentials.strip().split(None, 2)
157 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
158 # token to identify the authentication scheme [...]"
159 scheme = parts[0].lower()
161 if scheme == HTTP_BASIC_AUTH.lower():
162 # Do basic authentication
164 raise http.HttpBadRequest(message=("Basic authentication requires"
166 return self._CheckBasicAuthorization(req, parts[1])
168 elif scheme == HTTP_DIGEST_AUTH.lower():
169 # TODO: Implement digest authentication
170 # RFC2617, section 3.3: "Note that the HTTP server does not actually need
171 # to know the user's cleartext password. As long as H(A1) is available to
172 # the server, the validity of an Authorization header may be verified."
175 # Unsupported authentication scheme
178 def _CheckBasicAuthorization(self, req, in_data):
179 """Checks credentials sent for basic authentication.
181 @type req: L{http.server._HttpServerRequest}
182 @param req: HTTP request context
184 @param in_data: Username and password encoded as Base64
186 @return: Whether user is allowed to execute request
190 creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
191 except (TypeError, binascii.Error, UnicodeError):
192 logging.exception("Error when decoding Basic authentication credentials")
198 (user, password) = creds.split(":", 1)
200 return self.Authenticate(req, user, password)
202 def Authenticate(self, req, user, password):
203 """Checks the password for a user.
205 This function MUST be overridden by a subclass.
208 raise NotImplementedError()
210 def VerifyBasicAuthPassword(self, req, username, password, expected):
211 """Checks the password for basic authentication.
213 As long as they don't start with an opening brace ("E{lb}"), old passwords
214 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
215 consists of the username, the authentication realm and the actual password.
217 @type req: L{http.server._HttpServerRequest}
218 @param req: HTTP request context
219 @type username: string
220 @param username: Username from HTTP headers
221 @type password: string
222 @param password: Password from HTTP headers
223 @type expected: string
224 @param expected: Expected password with optional scheme prefix (e.g. from
228 # Backwards compatibility for old-style passwords without a scheme
229 if not expected.startswith("{"):
230 expected = self._CLEARTEXT_SCHEME + expected
232 # Check again, just to be sure
233 if not expected.startswith("{"):
234 raise AssertionError("Invalid scheme")
236 scheme_end_idx = expected.find("}", 1)
238 # Ensure scheme has a length of at least one character
239 if scheme_end_idx <= 1:
240 logging.warning("Invalid scheme in password for user '%s'", username)
243 scheme = expected[:scheme_end_idx + 1].upper()
244 expected_password = expected[scheme_end_idx + 1:]
246 # Good old plain text password
247 if scheme == self._CLEARTEXT_SCHEME:
248 return password == expected_password
250 # H(A1) as described in RFC2617
251 if scheme == self._HA1_SCHEME:
252 realm = self.GetAuthRealm(req)
254 # There can not be a valid password for this case
258 expha1.update("%s:%s:%s" % (username, realm, password))
260 return (expected_password.lower() == expha1.hexdigest().lower())
262 logging.warning("Unknown scheme '%s' in password for user '%s'",
268 class PasswordFileUser(object):
269 """Data structure for users from password file.
272 def __init__(self, name, password, options):
274 self.password = password
275 self.options = options
278 def ReadPasswordFile(file_name):
279 """Reads a password file.
281 Lines in the password file are of the following format::
283 <username> <password> [options]
285 Fields are separated by whitespace. Username and password are mandatory,
286 options are optional and separated by comma (','). Empty lines and comments
290 @param file_name: Path to password file
292 @return: Dictionary containing L{PasswordFileUser} instances
297 for line in utils.ReadFile(file_name).splitlines():
300 # Ignore empty lines and comments
301 if not line or line.startswith("#"):
304 parts = line.split(None, 2)
315 for part in parts[2].split(","):
316 options.append(part.strip())
318 users[name] = PasswordFileUser(name, password, options)