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