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 compat
32 from ganeti import http
34 from cStringIO import StringIO
36 # Digest types from RFC2617
37 HTTP_BASIC_AUTH = "Basic"
38 HTTP_DIGEST_AUTH = "Digest"
40 # Not exactly as described in RFC2616, section 2.2, but good enough
41 _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
44 def _FormatAuthHeader(scheme, params):
45 """Formats WWW-Authentication header value as per RFC2617, section 1.2
48 @param scheme: Authentication scheme
50 @param params: Additional parameters
52 @return: Formatted header value
59 for name, value in params.iteritems():
63 if _NOQUOTE.match(value):
67 # TODO: Better quoting
68 buf.write(value.replace("\"", "\\\""))
74 class HttpServerRequestAuthentication(object):
75 # Default authentication realm
76 AUTH_REALM = "Unspecified"
78 # Schemes for passwords
79 _CLEARTEXT_SCHEME = "{CLEARTEXT}"
82 def GetAuthRealm(self, req):
83 """Returns the authentication realm for a request.
85 May be overridden by a subclass, which then can return different realms for
88 @type req: L{http.server._HttpServerRequest}
89 @param req: HTTP request context
91 @return: Authentication realm
94 # today we don't have per-request filtering, but we might want to
95 # add it in the future
96 # pylint: disable-msg=W0613
97 return self.AUTH_REALM
99 def AuthenticationRequired(self, req):
100 """Determines whether authentication is required for a request.
102 To enable authentication, override this function in a subclass and return
103 C{True}. L{AUTH_REALM} must be set.
105 @type req: L{http.server._HttpServerRequest}
106 @param req: HTTP request context
109 # Unused argument, method could be a function
110 # pylint: disable-msg=W0613,R0201
113 def PreHandleRequest(self, req):
114 """Called before a request is handled.
116 @type req: L{http.server._HttpServerRequest}
117 @param req: HTTP request context
120 # Authentication not required, and no credentials given?
121 if not (self.AuthenticationRequired(req) or
122 (req.request_headers and
123 http.HTTP_AUTHORIZATION in req.request_headers)):
126 realm = self.GetAuthRealm(req)
129 raise AssertionError("No authentication realm")
131 # Check "Authorization" header
132 if self._CheckAuthorization(req):
133 # User successfully authenticated
136 # Send 401 Unauthorized response
141 # TODO: Support for Digest authentication (RFC2617, section 3).
142 # TODO: Support for more than one WWW-Authenticate header with the same
143 # response (RFC2617, section 4.6).
145 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
148 raise http.HttpUnauthorized(headers=headers)
150 def _CheckAuthorization(self, req):
151 """Checks 'Authorization' header sent by client.
153 @type req: L{http.server._HttpServerRequest}
154 @param req: HTTP request context
156 @return: Whether user is allowed to execute request
159 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
164 parts = credentials.strip().split(None, 2)
169 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
170 # token to identify the authentication scheme [...]"
171 scheme = parts[0].lower()
173 if scheme == HTTP_BASIC_AUTH.lower():
174 # Do basic authentication
176 raise http.HttpBadRequest(message=("Basic authentication requires"
178 return self._CheckBasicAuthorization(req, parts[1])
180 elif scheme == HTTP_DIGEST_AUTH.lower():
181 # TODO: Implement digest authentication
182 # RFC2617, section 3.3: "Note that the HTTP server does not actually need
183 # to know the user's cleartext password. As long as H(A1) is available to
184 # the server, the validity of an Authorization header may be verified."
187 # Unsupported authentication scheme
190 def _CheckBasicAuthorization(self, req, in_data):
191 """Checks credentials sent for basic authentication.
193 @type req: L{http.server._HttpServerRequest}
194 @param req: HTTP request context
196 @param in_data: Username and password encoded as Base64
198 @return: Whether user is allowed to execute request
202 creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
203 except (TypeError, binascii.Error, UnicodeError):
204 logging.exception("Error when decoding Basic authentication credentials")
210 (user, password) = creds.split(":", 1)
212 return self.Authenticate(req, user, password)
214 def Authenticate(self, req, user, password):
215 """Checks the password for a user.
217 This function MUST be overridden by a subclass.
220 raise NotImplementedError()
222 def VerifyBasicAuthPassword(self, req, username, password, expected):
223 """Checks the password for basic authentication.
225 As long as they don't start with an opening brace ("E{lb}"), old passwords
226 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
227 consists of the username, the authentication realm and the actual password.
229 @type req: L{http.server._HttpServerRequest}
230 @param req: HTTP request context
231 @type username: string
232 @param username: Username from HTTP headers
233 @type password: string
234 @param password: Password from HTTP headers
235 @type expected: string
236 @param expected: Expected password with optional scheme prefix (e.g. from
240 # Backwards compatibility for old-style passwords without a scheme
241 if not expected.startswith("{"):
242 expected = self._CLEARTEXT_SCHEME + expected
244 # Check again, just to be sure
245 if not expected.startswith("{"):
246 raise AssertionError("Invalid scheme")
248 scheme_end_idx = expected.find("}", 1)
250 # Ensure scheme has a length of at least one character
251 if scheme_end_idx <= 1:
252 logging.warning("Invalid scheme in password for user '%s'", username)
255 scheme = expected[:scheme_end_idx + 1].upper()
256 expected_password = expected[scheme_end_idx + 1:]
258 # Good old plain text password
259 if scheme == self._CLEARTEXT_SCHEME:
260 return password == expected_password
262 # H(A1) as described in RFC2617
263 if scheme == self._HA1_SCHEME:
264 realm = self.GetAuthRealm(req)
266 # There can not be a valid password for this case
267 raise AssertionError("No authentication realm")
269 expha1 = compat.md5_hash()
270 expha1.update("%s:%s:%s" % (username, realm, password))
272 return (expected_password.lower() == expha1.hexdigest().lower())
274 logging.warning("Unknown scheme '%s' in password for user '%s'",
280 class PasswordFileUser(object):
281 """Data structure for users from password file.
284 def __init__(self, name, password, options):
286 self.password = password
287 self.options = options
290 def ReadPasswordFile(file_name):
291 """Reads a password file.
293 Lines in the password file are of the following format::
295 <username> <password> [options]
297 Fields are separated by whitespace. Username and password are mandatory,
298 options are optional and separated by comma (','). Empty lines and comments
302 @param file_name: Path to password file
304 @return: Dictionary containing L{PasswordFileUser} instances
309 for line in utils.ReadFile(file_name).splitlines():
312 # Ignore empty lines and comments
313 if not line or line.startswith("#"):
316 parts = line.split(None, 2)
327 for part in parts[2].split(","):
328 options.append(part.strip())
330 users[name] = PasswordFileUser(name, password, options)