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