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