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