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
81 AUTH_REALM = "Unspecified"
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
93 @type req: L{http.server._HttpServerRequest}
94 @param req: HTTP request context
96 @return: Authentication realm
99 # today we don't have per-request filtering, but we might want to
100 # add it in the future
101 # pylint: disable-msg=W0613
102 return self.AUTH_REALM
104 def AuthenticationRequired(self, req):
105 """Determines whether authentication is required for a request.
107 To enable authentication, override this function in a subclass and return
108 C{True}. L{AUTH_REALM} must be set.
110 @type req: L{http.server._HttpServerRequest}
111 @param req: HTTP request context
114 # Unused argument, method could be a function
115 # pylint: disable-msg=W0613,R0201
118 def PreHandleRequest(self, req):
119 """Called before a request is handled.
121 @type req: L{http.server._HttpServerRequest}
122 @param req: HTTP request context
125 # Authentication not required, and no credentials given?
126 if not (self.AuthenticationRequired(req) or
127 (req.request_headers and
128 http.HTTP_AUTHORIZATION in req.request_headers)):
131 realm = self.GetAuthRealm(req)
134 raise AssertionError("No authentication realm")
136 # Check "Authorization" header
137 if self._CheckAuthorization(req):
138 # User successfully authenticated
141 # Send 401 Unauthorized response
146 # TODO: Support for Digest authentication (RFC2617, section 3).
147 # TODO: Support for more than one WWW-Authenticate header with the same
148 # response (RFC2617, section 4.6).
150 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
153 raise http.HttpUnauthorized(headers=headers)
155 def _CheckAuthorization(self, req):
156 """Checks 'Authorization' header sent by client.
158 @type req: L{http.server._HttpServerRequest}
159 @param req: HTTP request context
161 @return: Whether user is allowed to execute request
164 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
169 parts = credentials.strip().split(None, 2)
174 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
175 # token to identify the authentication scheme [...]"
176 scheme = parts[0].lower()
178 if scheme == HTTP_BASIC_AUTH.lower():
179 # Do basic authentication
181 raise http.HttpBadRequest(message=("Basic authentication requires"
183 return self._CheckBasicAuthorization(req, parts[1])
185 elif scheme == HTTP_DIGEST_AUTH.lower():
186 # TODO: Implement digest authentication
187 # RFC2617, section 3.3: "Note that the HTTP server does not actually need
188 # to know the user's cleartext password. As long as H(A1) is available to
189 # the server, the validity of an Authorization header may be verified."
192 # Unsupported authentication scheme
195 def _CheckBasicAuthorization(self, req, in_data):
196 """Checks credentials sent for basic authentication.
198 @type req: L{http.server._HttpServerRequest}
199 @param req: HTTP request context
201 @param in_data: Username and password encoded as Base64
203 @return: Whether user is allowed to execute request
207 creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
208 except (TypeError, binascii.Error, UnicodeError):
209 logging.exception("Error when decoding Basic authentication credentials")
215 (user, password) = creds.split(":", 1)
217 return self.Authenticate(req, user, password)
219 def Authenticate(self, req, user, password):
220 """Checks the password for a user.
222 This function MUST be overridden by a subclass.
225 raise NotImplementedError()
227 def VerifyBasicAuthPassword(self, req, username, password, expected):
228 """Checks the password for basic authentication.
230 As long as they don't start with an opening brace ("E{lb}"), old passwords
231 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
232 consists of the username, the authentication realm and the actual password.
234 @type req: L{http.server._HttpServerRequest}
235 @param req: HTTP request context
236 @type username: string
237 @param username: Username from HTTP headers
238 @type password: string
239 @param password: Password from HTTP headers
240 @type expected: string
241 @param expected: Expected password with optional scheme prefix (e.g. from
245 # Backwards compatibility for old-style passwords without a scheme
246 if not expected.startswith("{"):
247 expected = self._CLEARTEXT_SCHEME + expected
249 # Check again, just to be sure
250 if not expected.startswith("{"):
251 raise AssertionError("Invalid scheme")
253 scheme_end_idx = expected.find("}", 1)
255 # Ensure scheme has a length of at least one character
256 if scheme_end_idx <= 1:
257 logging.warning("Invalid scheme in password for user '%s'", username)
260 scheme = expected[:scheme_end_idx + 1].upper()
261 expected_password = expected[scheme_end_idx + 1:]
263 # Good old plain text password
264 if scheme == self._CLEARTEXT_SCHEME:
265 return password == expected_password
267 # H(A1) as described in RFC2617
268 if scheme == self._HA1_SCHEME:
269 realm = self.GetAuthRealm(req)
271 # There can not be a valid password for this case
272 raise AssertionError("No authentication realm")
275 expha1.update("%s:%s:%s" % (username, realm, password))
277 return (expected_password.lower() == expha1.hexdigest().lower())
279 logging.warning("Unknown scheme '%s' in password for user '%s'",
285 class PasswordFileUser(object):
286 """Data structure for users from password file.
289 def __init__(self, name, password, options):
291 self.password = password
292 self.options = options
295 def ReadPasswordFile(file_name):
296 """Reads a password file.
298 Lines in the password file are of the following format::
300 <username> <password> [options]
302 Fields are separated by whitespace. Username and password are mandatory,
303 options are optional and separated by comma (','). Empty lines and comments
307 @param file_name: Path to password file
309 @return: Dictionary containing L{PasswordFileUser} instances
314 for line in utils.ReadFile(file_name).splitlines():
317 # Ignore empty lines and comments
318 if not line or line.startswith("#"):
321 parts = line.split(None, 2)
332 for part in parts[2].split(","):
333 options.append(part.strip())
335 users[name] = PasswordFileUser(name, password, options)