Package ganeti :: Package http :: Module auth
[hide private]
[frames] | no frames]

Source Code for Module ganeti.http.auth

  1  # 
  2  # 
  3   
  4  # Copyright (C) 2007, 2008 Google Inc. 
  5  # 
  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. 
 10  # 
 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. 
 15  # 
 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 
 19  # 02110-1301, USA. 
 20   
 21  """HTTP authentication module. 
 22   
 23  """ 
 24   
 25  import logging 
 26  import re 
 27  import base64 
 28  import binascii 
 29   
 30  from ganeti import compat 
 31  from ganeti import http 
 32   
 33  from cStringIO import StringIO 
 34   
 35  # Digest types from RFC2617 
 36  HTTP_BASIC_AUTH = "Basic" 
 37  HTTP_DIGEST_AUTH = "Digest" 
 38   
 39  # Not exactly as described in RFC2616, section 2.2, but good enough 
 40  _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I) 
 41   
 42   
43 -def _FormatAuthHeader(scheme, params):
44 """Formats WWW-Authentication header value as per RFC2617, section 1.2 45 46 @type scheme: str 47 @param scheme: Authentication scheme 48 @type params: dict 49 @param params: Additional parameters 50 @rtype: str 51 @return: Formatted header value 52 53 """ 54 buf = StringIO() 55 56 buf.write(scheme) 57 58 for name, value in params.iteritems(): 59 buf.write(" ") 60 buf.write(name) 61 buf.write("=") 62 if _NOQUOTE.match(value): 63 buf.write(value) 64 else: 65 buf.write("\"") 66 # TODO: Better quoting 67 buf.write(value.replace("\"", "\\\"")) 68 buf.write("\"") 69 70 return buf.getvalue()
71 72
73 -class HttpServerRequestAuthentication(object):
74 # Default authentication realm 75 AUTH_REALM = "Unspecified" 76 77 # Schemes for passwords 78 _CLEARTEXT_SCHEME = "{CLEARTEXT}" 79 _HA1_SCHEME = "{HA1}" 80
81 - def GetAuthRealm(self, req):
82 """Returns the authentication realm for a request. 83 84 May be overridden by a subclass, which then can return different realms for 85 different paths. 86 87 @type req: L{http.server._HttpServerRequest} 88 @param req: HTTP request context 89 @rtype: string 90 @return: Authentication realm 91 92 """ 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
97
98 - def AuthenticationRequired(self, req):
99 """Determines whether authentication is required for a request. 100 101 To enable authentication, override this function in a subclass and return 102 C{True}. L{AUTH_REALM} must be set. 103 104 @type req: L{http.server._HttpServerRequest} 105 @param req: HTTP request context 106 107 """ 108 # Unused argument, method could be a function 109 # pylint: disable=W0613,R0201 110 return False
111
112 - def PreHandleRequest(self, req):
113 """Called before a request is handled. 114 115 @type req: L{http.server._HttpServerRequest} 116 @param req: HTTP request context 117 118 """ 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)): 123 return 124 125 realm = self.GetAuthRealm(req) 126 127 if not realm: 128 raise AssertionError("No authentication realm") 129 130 # Check "Authorization" header 131 if self._CheckAuthorization(req): 132 # User successfully authenticated 133 return 134 135 # Send 401 Unauthorized response 136 params = { 137 "realm": realm, 138 } 139 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). 143 headers = { 144 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params), 145 } 146 147 raise http.HttpUnauthorized(headers=headers)
148
149 - def _CheckAuthorization(self, req):
150 """Checks 'Authorization' header sent by client. 151 152 @type req: L{http.server._HttpServerRequest} 153 @param req: HTTP request context 154 @rtype: bool 155 @return: Whether user is allowed to execute request 156 157 """ 158 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None) 159 if not credentials: 160 return False 161 162 # Extract scheme 163 parts = credentials.strip().split(None, 2) 164 if len(parts) < 1: 165 # Missing scheme 166 return False 167 168 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive 169 # token to identify the authentication scheme [...]" 170 scheme = parts[0].lower() 171 172 if scheme == HTTP_BASIC_AUTH.lower(): 173 # Do basic authentication 174 if len(parts) < 2: 175 raise http.HttpBadRequest(message=("Basic authentication requires" 176 " credentials")) 177 return self._CheckBasicAuthorization(req, parts[1]) 178 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." 184 pass 185 186 # Unsupported authentication scheme 187 return False
188
189 - def _CheckBasicAuthorization(self, req, in_data):
190 """Checks credentials sent for basic authentication. 191 192 @type req: L{http.server._HttpServerRequest} 193 @param req: HTTP request context 194 @type in_data: str 195 @param in_data: Username and password encoded as Base64 196 @rtype: bool 197 @return: Whether user is allowed to execute request 198 199 """ 200 try: 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") 204 return False 205 206 if ":" not in creds: 207 return False 208 209 (user, password) = creds.split(":", 1) 210 211 return self.Authenticate(req, user, password)
212
213 - def Authenticate(self, req, user, password):
214 """Checks the password for a user. 215 216 This function MUST be overridden by a subclass. 217 218 """ 219 raise NotImplementedError()
220
221 - def VerifyBasicAuthPassword(self, req, username, password, expected):
222 """Checks the password for basic authentication. 223 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. 227 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 236 users file) 237 238 """ 239 # Backwards compatibility for old-style passwords without a scheme 240 if not expected.startswith("{"): 241 expected = self._CLEARTEXT_SCHEME + expected 242 243 # Check again, just to be sure 244 if not expected.startswith("{"): 245 raise AssertionError("Invalid scheme") 246 247 scheme_end_idx = expected.find("}", 1) 248 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) 252 return False 253 254 scheme = expected[:scheme_end_idx + 1].upper() 255 expected_password = expected[scheme_end_idx + 1:] 256 257 # Good old plain text password 258 if scheme == self._CLEARTEXT_SCHEME: 259 return password == expected_password 260 261 # H(A1) as described in RFC2617 262 if scheme == self._HA1_SCHEME: 263 realm = self.GetAuthRealm(req) 264 if not realm: 265 # There can not be a valid password for this case 266 raise AssertionError("No authentication realm") 267 268 expha1 = compat.md5_hash() 269 expha1.update("%s:%s:%s" % (username, realm, password)) 270 271 return (expected_password.lower() == expha1.hexdigest().lower()) 272 273 logging.warning("Unknown scheme '%s' in password for user '%s'", 274 scheme, username) 275 276 return False
277 278
279 -class PasswordFileUser(object):
280 """Data structure for users from password file. 281 282 """
283 - def __init__(self, name, password, options):
284 self.name = name 285 self.password = password 286 self.options = options
287 288
289 -def ParsePasswordFile(contents):
290 """Parses the contents of a password file. 291 292 Lines in the password file are of the following format:: 293 294 <username> <password> [options] 295 296 Fields are separated by whitespace. Username and password are mandatory, 297 options are optional and separated by comma (','). Empty lines and comments 298 ('#') are ignored. 299 300 @type contents: str 301 @param contents: Contents of password file 302 @rtype: dict 303 @return: Dictionary containing L{PasswordFileUser} instances 304 305 """ 306 users = {} 307 308 for line in contents.splitlines(): 309 line = line.strip() 310 311 # Ignore empty lines and comments 312 if not line or line.startswith("#"): 313 continue 314 315 parts = line.split(None, 2) 316 if len(parts) < 2: 317 # Invalid line 318 continue 319 320 name = parts[0] 321 password = parts[1] 322 323 # Extract options 324 options = [] 325 if len(parts) >= 3: 326 for part in parts[2].split(","): 327 options.append(part.strip()) 328 329 users[name] = PasswordFileUser(name, password, options) 330 331 return users
332