Add targetted pylint disables
[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
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 = None
77
78   def GetAuthRealm(self, req):
79     """Returns the authentication realm for a request.
80
81     MAY be overridden by a subclass, which then can return different realms for
82     different paths. Returning "None" means no authentication is needed for a
83     request.
84
85     @type req: L{http.server._HttpServerRequest}
86     @param req: HTTP request context
87     @rtype: str or None
88     @return: Authentication realm
89
90     """
91     return self.AUTH_REALM
92
93   def PreHandleRequest(self, req):
94     """Called before a request is handled.
95
96     @type req: L{http.server._HttpServerRequest}
97     @param req: HTTP request context
98
99     """
100     realm = self.GetAuthRealm(req)
101
102     # Authentication not required, and no credentials given?
103     if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers:
104       return
105
106     if realm is None: # in case we don't require auth but someone
107                       # passed the crendentials anyway
108       realm = "Unspecified"
109
110     # Check "Authorization" header
111     if self._CheckAuthorization(req):
112       # User successfully authenticated
113       return
114
115     # Send 401 Unauthorized response
116     params = {
117       "realm": realm,
118       }
119
120     # TODO: Support for Digest authentication (RFC2617, section 3).
121     # TODO: Support for more than one WWW-Authenticate header with the same
122     # response (RFC2617, section 4.6).
123     headers = {
124       http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params),
125       }
126
127     raise http.HttpUnauthorized(headers=headers)
128
129   def _CheckAuthorization(self, req):
130     """Checks 'Authorization' header sent by client.
131
132     @type req: L{http.server._HttpServerRequest}
133     @param req: HTTP request context
134     @rtype: bool
135     @return: Whether user is allowed to execute request
136
137     """
138     credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
139     if not credentials:
140       return False
141
142     # Extract scheme
143     parts = credentials.strip().split(None, 2)
144     if len(parts) < 1:
145       # Missing scheme
146       return False
147
148     # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
149     # token to identify the authentication scheme [...]"
150     scheme = parts[0].lower()
151
152     if scheme == HTTP_BASIC_AUTH.lower():
153       # Do basic authentication
154       if len(parts) < 2:
155         raise http.HttpBadRequest(message=("Basic authentication requires"
156                                            " credentials"))
157       return self._CheckBasicAuthorization(req, parts[1])
158
159     elif scheme == HTTP_DIGEST_AUTH.lower():
160       # TODO: Implement digest authentication
161       # RFC2617, section 3.3: "Note that the HTTP server does not actually need
162       # to know the user's cleartext password. As long as H(A1) is available to
163       # the server, the validity of an Authorization header may be verified."
164       pass
165
166     # Unsupported authentication scheme
167     return False
168
169   def _CheckBasicAuthorization(self, req, in_data):
170     """Checks credentials sent for basic authentication.
171
172     @type req: L{http.server._HttpServerRequest}
173     @param req: HTTP request context
174     @type in_data: str
175     @param in_data: Username and password encoded as Base64
176     @rtype: bool
177     @return: Whether user is allowed to execute request
178
179     """
180     try:
181       creds = base64.b64decode(in_data.encode('ascii')).decode('ascii')
182     except (TypeError, binascii.Error, UnicodeError):
183       logging.exception("Error when decoding Basic authentication credentials")
184       return False
185
186     if ":" not in creds:
187       return False
188
189     (user, password) = creds.split(":", 1)
190
191     return self.Authenticate(req, user, password)
192
193   def Authenticate(self, req, user, password):
194     """Checks the password for a user.
195
196     This function MUST be overridden by a subclass.
197
198     """
199     raise NotImplementedError()
200
201
202 class PasswordFileUser(object):
203   """Data structure for users from password file.
204
205   """
206   def __init__(self, name, password, options):
207     self.name = name
208     self.password = password
209     self.options = options
210
211
212 def ReadPasswordFile(file_name):
213   """Reads a password file.
214
215   Lines in the password file are of the following format::
216
217       <username> <password> [options]
218
219   Fields are separated by whitespace. Username and password are mandatory,
220   options are optional and separated by comma (','). Empty lines and comments
221   ('#') are ignored.
222
223   @type file_name: str
224   @param file_name: Path to password file
225   @rtype: dict
226   @return: Dictionary containing L{PasswordFileUser} instances
227
228   """
229   users = {}
230
231   for line in utils.ReadFile(file_name).splitlines():
232     line = line.strip()
233
234     # Ignore empty lines and comments
235     if not line or line.startswith("#"):
236       continue
237
238     parts = line.split(None, 2)
239     if len(parts) < 2:
240       # Invalid line
241       continue
242
243     name = parts[0]
244     password = parts[1]
245
246     # Extract options
247     options = []
248     if len(parts) >= 3:
249       for part in parts[2].split(","):
250         options.append(part.strip())
251
252     users[name] = PasswordFileUser(name, password, options)
253
254   return users