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  # All rights reserved. 
  6  # 
  7  # Redistribution and use in source and binary forms, with or without 
  8  # modification, are permitted provided that the following conditions are 
  9  # met: 
 10  # 
 11  # 1. Redistributions of source code must retain the above copyright notice, 
 12  # this list of conditions and the following disclaimer. 
 13  # 
 14  # 2. Redistributions in binary form must reproduce the above copyright 
 15  # notice, this list of conditions and the following disclaimer in the 
 16  # documentation and/or other materials provided with the distribution. 
 17  # 
 18  # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 
 19  # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 
 20  # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 
 21  # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 
 22  # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
 23  # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
 24  # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
 25  # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 
 26  # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 
 27  # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 
 28  # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 29   
 30  """HTTP authentication module. 
 31   
 32  """ 
 33   
 34  import logging 
 35  import re 
 36  import base64 
 37  import binascii 
 38   
 39  from ganeti import compat 
 40  from ganeti import http 
 41  from ganeti import utils 
 42   
 43  from cStringIO import StringIO 
 44   
 45  # Digest types from RFC2617 
 46  HTTP_BASIC_AUTH = "Basic" 
 47  HTTP_DIGEST_AUTH = "Digest" 
 48   
 49  # Not exactly as described in RFC2616, section 2.2, but good enough 
 50  _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I) 
 51   
 52   
53 -def _FormatAuthHeader(scheme, params):
54 """Formats WWW-Authentication header value as per RFC2617, section 1.2 55 56 @type scheme: str 57 @param scheme: Authentication scheme 58 @type params: dict 59 @param params: Additional parameters 60 @rtype: str 61 @return: Formatted header value 62 63 """ 64 buf = StringIO() 65 66 buf.write(scheme) 67 68 for name, value in params.iteritems(): 69 buf.write(" ") 70 buf.write(name) 71 buf.write("=") 72 if _NOQUOTE.match(value): 73 buf.write(value) 74 else: 75 buf.write("\"") 76 # TODO: Better quoting 77 buf.write(value.replace("\"", "\\\"")) 78 buf.write("\"") 79 80 return buf.getvalue()
81 82
83 -class HttpServerRequestAuthentication(object):
84 # Default authentication realm 85 AUTH_REALM = "Unspecified" 86 87 # Schemes for passwords 88 _CLEARTEXT_SCHEME = "{CLEARTEXT}" 89 _HA1_SCHEME = "{HA1}" 90
91 - def GetAuthRealm(self, req):
92 """Returns the authentication realm for a request. 93 94 May be overridden by a subclass, which then can return different realms for 95 different paths. 96 97 @type req: L{http.server._HttpServerRequest} 98 @param req: HTTP request context 99 @rtype: string 100 @return: Authentication realm 101 102 """ 103 # today we don't have per-request filtering, but we might want to 104 # add it in the future 105 # pylint: disable=W0613 106 return self.AUTH_REALM
107
108 - def AuthenticationRequired(self, req):
109 """Determines whether authentication is required for a request. 110 111 To enable authentication, override this function in a subclass and return 112 C{True}. L{AUTH_REALM} must be set. 113 114 @type req: L{http.server._HttpServerRequest} 115 @param req: HTTP request context 116 117 """ 118 # Unused argument, method could be a function 119 # pylint: disable=W0613,R0201 120 return False
121
122 - def PreHandleRequest(self, req):
123 """Called before a request is handled. 124 125 @type req: L{http.server._HttpServerRequest} 126 @param req: HTTP request context 127 128 """ 129 # Authentication not required, and no credentials given? 130 if not (self.AuthenticationRequired(req) or 131 (req.request_headers and 132 http.HTTP_AUTHORIZATION in req.request_headers)): 133 return 134 135 realm = self.GetAuthRealm(req) 136 137 if not realm: 138 raise AssertionError("No authentication realm") 139 140 # Check "Authorization" header 141 if self._CheckAuthorization(req): 142 # User successfully authenticated 143 return 144 145 # Send 401 Unauthorized response 146 params = { 147 "realm": realm, 148 } 149 150 # TODO: Support for Digest authentication (RFC2617, section 3). 151 # TODO: Support for more than one WWW-Authenticate header with the same 152 # response (RFC2617, section 4.6). 153 headers = { 154 http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params), 155 } 156 157 raise http.HttpUnauthorized(headers=headers)
158
159 - def _CheckAuthorization(self, req):
160 """Checks 'Authorization' header sent by client. 161 162 @type req: L{http.server._HttpServerRequest} 163 @param req: HTTP request context 164 @rtype: bool 165 @return: Whether user is allowed to execute request 166 167 """ 168 credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None) 169 if not credentials: 170 return False 171 172 # Extract scheme 173 parts = credentials.strip().split(None, 2) 174 if len(parts) < 1: 175 # Missing scheme 176 return False 177 178 # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive 179 # token to identify the authentication scheme [...]" 180 scheme = parts[0].lower() 181 182 if scheme == HTTP_BASIC_AUTH.lower(): 183 # Do basic authentication 184 if len(parts) < 2: 185 raise http.HttpBadRequest(message=("Basic authentication requires" 186 " credentials")) 187 return self._CheckBasicAuthorization(req, parts[1]) 188 189 elif scheme == HTTP_DIGEST_AUTH.lower(): 190 # TODO: Implement digest authentication 191 # RFC2617, section 3.3: "Note that the HTTP server does not actually need 192 # to know the user's cleartext password. As long as H(A1) is available to 193 # the server, the validity of an Authorization header may be verified." 194 pass 195 196 # Unsupported authentication scheme 197 return False
198
199 - def _CheckBasicAuthorization(self, req, in_data):
200 """Checks credentials sent for basic authentication. 201 202 @type req: L{http.server._HttpServerRequest} 203 @param req: HTTP request context 204 @type in_data: str 205 @param in_data: Username and password encoded as Base64 206 @rtype: bool 207 @return: Whether user is allowed to execute request 208 209 """ 210 try: 211 creds = base64.b64decode(in_data.encode("ascii")).decode("ascii") 212 except (TypeError, binascii.Error, UnicodeError): 213 logging.exception("Error when decoding Basic authentication credentials") 214 return False 215 216 if ":" not in creds: 217 return False 218 219 (user, password) = creds.split(":", 1) 220 221 return self.Authenticate(req, user, password)
222
223 - def Authenticate(self, req, user, password):
224 """Checks the password for a user. 225 226 This function MUST be overridden by a subclass. 227 228 """ 229 raise NotImplementedError()
230
231 - def VerifyBasicAuthPassword(self, req, username, password, expected):
232 """Checks the password for basic authentication. 233 234 As long as they don't start with an opening brace ("E{lb}"), old passwords 235 are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1 236 consists of the username, the authentication realm and the actual password. 237 238 @type req: L{http.server._HttpServerRequest} 239 @param req: HTTP request context 240 @type username: string 241 @param username: Username from HTTP headers 242 @type password: string 243 @param password: Password from HTTP headers 244 @type expected: string 245 @param expected: Expected password with optional scheme prefix (e.g. from 246 users file) 247 248 """ 249 # Backwards compatibility for old-style passwords without a scheme 250 if not expected.startswith("{"): 251 expected = self._CLEARTEXT_SCHEME + expected 252 253 # Check again, just to be sure 254 if not expected.startswith("{"): 255 raise AssertionError("Invalid scheme") 256 257 scheme_end_idx = expected.find("}", 1) 258 259 # Ensure scheme has a length of at least one character 260 if scheme_end_idx <= 1: 261 logging.warning("Invalid scheme in password for user '%s'", username) 262 return False 263 264 scheme = expected[:scheme_end_idx + 1].upper() 265 expected_password = expected[scheme_end_idx + 1:] 266 267 # Good old plain text password 268 if scheme == self._CLEARTEXT_SCHEME: 269 return password == expected_password 270 271 # H(A1) as described in RFC2617 272 if scheme == self._HA1_SCHEME: 273 realm = self.GetAuthRealm(req) 274 if not realm: 275 # There can not be a valid password for this case 276 raise AssertionError("No authentication realm") 277 278 expha1 = compat.md5_hash() 279 expha1.update("%s:%s:%s" % (username, realm, password)) 280 281 return (expected_password.lower() == expha1.hexdigest().lower()) 282 283 logging.warning("Unknown scheme '%s' in password for user '%s'", 284 scheme, username) 285 286 return False
287 288
289 -class PasswordFileUser(object):
290 """Data structure for users from password file. 291 292 """
293 - def __init__(self, name, password, options):
294 self.name = name 295 self.password = password 296 self.options = options
297 298
299 -def ParsePasswordFile(contents):
300 """Parses the contents of a password file. 301 302 Lines in the password file are of the following format:: 303 304 <username> <password> [options] 305 306 Fields are separated by whitespace. Username and password are mandatory, 307 options are optional and separated by comma (','). Empty lines and comments 308 ('#') are ignored. 309 310 @type contents: str 311 @param contents: Contents of password file 312 @rtype: dict 313 @return: Dictionary containing L{PasswordFileUser} instances 314 315 """ 316 users = {} 317 318 for line in utils.FilterEmptyLinesAndComments(contents): 319 parts = line.split(None, 2) 320 if len(parts) < 2: 321 # Invalid line 322 # TODO: Return line number from FilterEmptyLinesAndComments 323 logging.warning("Ignoring non-comment line with less than two fields") 324 continue 325 326 name = parts[0] 327 password = parts[1] 328 329 # Extract options 330 options = [] 331 if len(parts) >= 3: 332 for part in parts[2].split(","): 333 options.append(part.strip()) 334 else: 335 logging.warning("Ignoring values for user '%s': %s", name, parts[3:]) 336 337 users[name] = PasswordFileUser(name, password, options) 338 339 return users
340