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 compat
31 from ganeti import http
33 from cStringIO import StringIO
35 # Digest types from RFC2617
36 HTTP_BASIC_AUTH = "Basic"
37 HTTP_DIGEST_AUTH = "Digest"
39 # Not exactly as described in RFC2616, section 2.2, but good enough
40 _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
43 def _FormatAuthHeader(scheme, params):
44 """Formats WWW-Authentication header value as per RFC2617, section 1.2
47 @param scheme: Authentication scheme
49 @param params: Additional parameters
51 @return: Formatted header value
58 for name, value in params.iteritems():
62 if _NOQUOTE.match(value):
66 # TODO: Better quoting
67 buf.write(value.replace("\"", "\\\""))
73 class HttpServerRequestAuthentication(object):
74 # Default authentication realm
75 AUTH_REALM = "Unspecified"
77 # Schemes for passwords
78 _CLEARTEXT_SCHEME = "{CLEARTEXT}"
81 def GetAuthRealm(self, req):
82 """Returns the authentication realm for a request.
84 May be overridden by a subclass, which then can return different realms for
87 @type req: L{http.server._HttpServerRequest}
88 @param req: HTTP request context
90 @return: Authentication realm
93 # today we don't have per-request filtering, but we might want to
94 # add it in the future
95 # pylint: disable=W0613
96 return self.AUTH_REALM
98 def AuthenticationRequired(self, req):
99 """Determines whether authentication is required for a request.
101 To enable authentication, override this function in a subclass and return
102 C{True}. L{AUTH_REALM} must be set.
104 @type req: L{http.server._HttpServerRequest}
105 @param req: HTTP request context
108 # Unused argument, method could be a function
109 # pylint: disable=W0613,R0201
112 def PreHandleRequest(self, req):
113 """Called before a request is handled.
115 @type req: L{http.server._HttpServerRequest}
116 @param req: HTTP request context
119 # Authentication not required, and no credentials given?
120 if not (self.AuthenticationRequired(req) or
121 (req.request_headers and
122 http.HTTP_AUTHORIZATION in req.request_headers)):
125 realm = self.GetAuthRealm(req)
128 raise AssertionError("No authentication realm")
130 # Check "Authorization" header
131 if self._CheckAuthorization(req):
132 # User successfully authenticated
135 # Send 401 Unauthorized response
140 # TODO: Support for Digest authentication (RFC2617, section 3).
141 # TODO: Support for more than one WWW-Authenticate header with the same
142 # response (RFC2617, section 4.6).
144 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
147 raise http.HttpUnauthorized(headers=headers)
149 def _CheckAuthorization(self, req):
150 """Checks 'Authorization' header sent by client.
152 @type req: L{http.server._HttpServerRequest}
153 @param req: HTTP request context
155 @return: Whether user is allowed to execute request
158 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
163 parts = credentials.strip().split(None, 2)
168 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
169 # token to identify the authentication scheme [...]"
170 scheme = parts[0].lower()
172 if scheme == HTTP_BASIC_AUTH.lower():
173 # Do basic authentication
175 raise http.HttpBadRequest(message=("Basic authentication requires"
177 return self._CheckBasicAuthorization(req, parts[1])
179 elif scheme == HTTP_DIGEST_AUTH.lower():
180 # TODO: Implement digest authentication
181 # RFC2617, section 3.3: "Note that the HTTP server does not actually need
182 # to know the user's cleartext password. As long as H(A1) is available to
183 # the server, the validity of an Authorization header may be verified."
186 # Unsupported authentication scheme
189 def _CheckBasicAuthorization(self, req, in_data):
190 """Checks credentials sent for basic authentication.
192 @type req: L{http.server._HttpServerRequest}
193 @param req: HTTP request context
195 @param in_data: Username and password encoded as Base64
197 @return: Whether user is allowed to execute request
201 creds = base64.b64decode(in_data.encode("ascii")).decode("ascii")
202 except (TypeError, binascii.Error, UnicodeError):
203 logging.exception("Error when decoding Basic authentication credentials")
209 (user, password) = creds.split(":", 1)
211 return self.Authenticate(req, user, password)
213 def Authenticate(self, req, user, password):
214 """Checks the password for a user.
216 This function MUST be overridden by a subclass.
219 raise NotImplementedError()
221 def VerifyBasicAuthPassword(self, req, username, password, expected):
222 """Checks the password for basic authentication.
224 As long as they don't start with an opening brace ("E{lb}"), old passwords
225 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
226 consists of the username, the authentication realm and the actual password.
228 @type req: L{http.server._HttpServerRequest}
229 @param req: HTTP request context
230 @type username: string
231 @param username: Username from HTTP headers
232 @type password: string
233 @param password: Password from HTTP headers
234 @type expected: string
235 @param expected: Expected password with optional scheme prefix (e.g. from
239 # Backwards compatibility for old-style passwords without a scheme
240 if not expected.startswith("{"):
241 expected = self._CLEARTEXT_SCHEME + expected
243 # Check again, just to be sure
244 if not expected.startswith("{"):
245 raise AssertionError("Invalid scheme")
247 scheme_end_idx = expected.find("}", 1)
249 # Ensure scheme has a length of at least one character
250 if scheme_end_idx <= 1:
251 logging.warning("Invalid scheme in password for user '%s'", username)
254 scheme = expected[:scheme_end_idx + 1].upper()
255 expected_password = expected[scheme_end_idx + 1:]
257 # Good old plain text password
258 if scheme == self._CLEARTEXT_SCHEME:
259 return password == expected_password
261 # H(A1) as described in RFC2617
262 if scheme == self._HA1_SCHEME:
263 realm = self.GetAuthRealm(req)
265 # There can not be a valid password for this case
266 raise AssertionError("No authentication realm")
268 expha1 = compat.md5_hash()
269 expha1.update("%s:%s:%s" % (username, realm, password))
271 return (expected_password.lower() == expha1.hexdigest().lower())
273 logging.warning("Unknown scheme '%s' in password for user '%s'",
279 class PasswordFileUser(object):
280 """Data structure for users from password file.
283 def __init__(self, name, password, options):
285 self.password = password
286 self.options = options
289 def ParsePasswordFile(contents):
290 """Parses the contents of a password file.
292 Lines in the password file are of the following format::
294 <username> <password> [options]
296 Fields are separated by whitespace. Username and password are mandatory,
297 options are optional and separated by comma (','). Empty lines and comments
301 @param contents: Contents of password file
303 @return: Dictionary containing L{PasswordFileUser} instances
308 for line in contents.splitlines():
311 # Ignore empty lines and comments
312 if not line or line.startswith("#"):
315 parts = line.split(None, 2)
326 for part in parts[2].split(","):
327 options.append(part.strip())
329 users[name] = PasswordFileUser(name, password, options)