Epydoc fixes
[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 = None
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. Returning "None" means no authentication is needed for a
92     request.
93
94     @type req: L{http.server._HttpServerRequest}
95     @param req: HTTP request context
96     @rtype: str or None
97     @return: Authentication realm
98
99     """
100     return self.AUTH_REALM
101
102   def PreHandleRequest(self, req):
103     """Called before a request is handled.
104
105     @type req: L{http.server._HttpServerRequest}
106     @param req: HTTP request context
107
108     """
109     realm = self.GetAuthRealm(req)
110
111     # Authentication not required, and no credentials given?
112     if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
113       return
114
115     if realm is None: # in case we don't require auth but someone
116                       # passed the crendentials anyway
117       realm = "Unspecified"
118
119     # Check "Authorization" header
120     if self._CheckAuthorization(req):
121       # User successfully authenticated
122       return
123
124     # Send 401 Unauthorized response
125     params = {
126       "realm": realm,
127       }
128
129     # TODO: Support for Digest authentication (RFC2617, section 3).
130     # TODO: Support for more than one WWW-Authenticate header with the same
131     # response (RFC2617, section 4.6).
132     headers = {
133       http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
134       }
135
136     raise http.HttpUnauthorized(headers=headers)
137
138   def _CheckAuthorization(self, req):
139     """Checks 'Authorization' header sent by client.
140
141     @type req: L{http.server._HttpServerRequest}
142     @param req: HTTP request context
143     @rtype: bool
144     @return: Whether user is allowed to execute request
145
146     """
147     credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
148     if not credentials:
149       return False
150
151     # Extract scheme
152     parts = credentials.strip().split(None, 2)
153     if len(parts) < 1:
154       # Missing scheme
155       return False
156
157     # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
158     # token to identify the authentication scheme [...]"
159     scheme = parts[0].lower()
160
161     if scheme == HTTP_BASIC_AUTH.lower():
162       # Do basic authentication
163       if len(parts) < 2:
164         raise http.HttpBadRequest(message=("Basic authentication requires"
165                                            " credentials"))
166       return self._CheckBasicAuthorization(req, parts[1])
167
168     elif scheme == HTTP_DIGEST_AUTH.lower():
169       # TODO: Implement digest authentication
170       # RFC2617, section 3.3: "Note that the HTTP server does not actually need
171       # to know the user's cleartext password. As long as H(A1) is available to
172       # the server, the validity of an Authorization header may be verified."
173       pass
174
175     # Unsupported authentication scheme
176     return False
177
178   def _CheckBasicAuthorization(self, req, in_data):
179     """Checks credentials sent for basic authentication.
180
181     @type req: L{http.server._HttpServerRequest}
182     @param req: HTTP request context
183     @type in_data: str
184     @param in_data: Username and password encoded as Base64
185     @rtype: bool
186     @return: Whether user is allowed to execute request
187
188     """
189     try:
190       creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
191     except (TypeError, binascii.Error, UnicodeError):
192       logging.exception("Error when decoding Basic authentication credentials")
193       return False
194
195     if ":" not in creds:
196       return False
197
198     (user, password) = creds.split(":", 1)
199
200     return self.Authenticate(req, user, password)
201
202   def Authenticate(self, req, user, password):
203     """Checks the password for a user.
204
205     This function MUST be overridden by a subclass.
206
207     """
208     raise NotImplementedError()
209
210   def VerifyBasicAuthPassword(self, req, username, password, expected):
211     """Checks the password for basic authentication.
212
213     As long as they don't start with an opening brace ("E{lb}"), old passwords
214     are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
215     consists of the username, the authentication realm and the actual password.
216
217     @type req: L{http.server._HttpServerRequest}
218     @param req: HTTP request context
219     @type username: string
220     @param username: Username from HTTP headers
221     @type password: string
222     @param password: Password from HTTP headers
223     @type expected: string
224     @param expected: Expected password with optional scheme prefix (e.g. from
225                      users file)
226
227     """
228     # Backwards compatibility for old-style passwords without a scheme
229     if not expected.startswith("{"):
230       expected = self._CLEARTEXT_SCHEME + expected
231
232     # Check again, just to be sure
233     if not expected.startswith("{"):
234       raise AssertionError("Invalid scheme")
235
236     scheme_end_idx = expected.find("}", 1)
237
238     # Ensure scheme has a length of at least one character
239     if scheme_end_idx <= 1:
240       logging.warning("Invalid scheme in password for user '%s'", username)
241       return False
242
243     scheme = expected[:scheme_end_idx + 1].upper()
244     expected_password = expected[scheme_end_idx + 1:]
245
246     # Good old plain text password
247     if scheme == self._CLEARTEXT_SCHEME:
248       return password == expected_password
249
250     # H(A1) as described in RFC2617
251     if scheme == self._HA1_SCHEME:
252       realm = self.GetAuthRealm(req)
253       if not realm:
254         # There can not be a valid password for this case
255         return False
256
257       expha1 = md5()
258       expha1.update("%s:%s:%s" % (username, realm, password))
259
260       return (expected_password.lower() == expha1.hexdigest().lower())
261
262     logging.warning("Unknown scheme '%s' in password for user '%s'",
263                     scheme, username)
264
265     return False
266
267
268 class PasswordFileUser(object):
269   """Data structure for users from password file.
270
271   """
272   def __init__(self, name, password, options):
273     self.name = name
274     self.password = password
275     self.options = options
276
277
278 def ReadPasswordFile(file_name):
279   """Reads a password file.
280
281   Lines in the password file are of the following format::
282
283       <username> <password> [options]
284
285   Fields are separated by whitespace. Username and password are mandatory,
286   options are optional and separated by comma (','). Empty lines and comments
287   ('#') are ignored.
288
289   @type file_name: str
290   @param file_name: Path to password file
291   @rtype: dict
292   @return: Dictionary containing L{PasswordFileUser} instances
293
294   """
295   users = {}
296
297   for line in utils.ReadFile(file_name).splitlines():
298     line = line.strip()
299
300     # Ignore empty lines and comments
301     if not line or line.startswith("#"):
302       continue
303
304     parts = line.split(None, 2)
305     if len(parts) < 2:
306       # Invalid line
307       continue
308
309     name = parts[0]
310     password = parts[1]
311
312     # Extract options
313     options = []
314     if len(parts) >= 3:
315       for part in parts[2].split(","):
316         options.append(part.strip())
317
318     users[name] = PasswordFileUser(name, password, options)
319
320   return users