HTTP lib: When authentication cannot proceed, issue 403
[odoo/odoo.git] / bin / service / websrv_lib.py
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright P. Christeas <p_christ@hol.gr> 2008,2009
4 # A part of the code comes from the ganeti project:  http://www.mail-archive.com/ganeti-devel@googlegroups.com/msg00713.html#
5 #
6 # WARNING: This program as such is intended to be used by professional
7 # programmers who take the whole responsability of assessing all potential
8 # consequences resulting from its eventual inadequacies and bugs
9 # End users who are looking for a ready-to-use solution with commercial
10 # garantees and support are strongly adviced to contract a Free Software
11 # Service Company
12 #
13 # This program is Free Software; you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License
15 # as published by the Free Software Foundation; either version 2
16 # of the License, or (at your option) any later version.
17 #
18 # This program is distributed in the hope that it will be useful,
19 # but WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21 # GNU General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
26 ###############################################################################
27
28 """ Framework for generic http servers
29
30 """
31
32 import socket
33 import base64
34 import SocketServer
35 from BaseHTTPServer import *
36 from SimpleHTTPServer import SimpleHTTPRequestHandler
37
38 class AuthRequiredExc(Exception):
39     def __init__(self,atype,realm):
40         Exception.__init__(self)
41         self.atype = atype
42         self.realm = realm
43
44 class AuthRejectedExc(Exception):
45     pass
46
47 class AuthProvider:
48     def __init__(self,realm):
49         self.realm = realm
50
51     def setupAuth(self, multi,handler):
52         """ Attach an AuthProxy object to handler
53         """
54         pass
55
56     def authenticate(self, user, passwd, client_address):
57         return False
58
59     def log(self, msg):
60         print msg
61
62 class BasicAuthProvider(AuthProvider):
63     def setupAuth(self, multi, handler):
64         if not multi.sec_realms.has_key(self.realm):
65             multi.sec_realms[self.realm] = BasicAuthProxy(self)
66
67
68 class AuthProxy:
69     """ This class will hold authentication information for a handler,
70         i.e. a connection
71     """
72     def __init__(self, provider):
73         self.provider = provider
74
75     def checkRequest(self,handler,path = '/'):
76         """ Check if we are allowed to process that request
77         """
78         pass
79
80 class BasicAuthProxy(AuthProxy):
81     """ Require basic authentication..
82     """
83     def __init__(self,provider):
84         AuthProxy.__init__(self,provider)
85         self.auth_creds = None
86         self.auth_tries = 0
87
88     def checkRequest(self,handler,path = '/'):
89         if self.auth_creds:
90             return True
91         auth_str = handler.headers.get('Authorization',False)
92         if auth_str and auth_str.startswith('Basic '):
93             auth_str=auth_str[len('Basic '):]
94             (user,passwd) = base64.decodestring(auth_str).split(':')
95             self.provider.log("Found user=\"%s\", passwd=\"%s\"" %(user,passwd))
96             self.auth_creds = self.provider.authenticate(user,passwd,handler.client_address)
97             if self.auth_creds:
98                 return True
99         if self.auth_tries > 5:
100             self.provider.log("Failing authorization after 5 requests w/o password")
101             raise AuthRejectedExc("Authorization failed.")
102         self.auth_tries += 1
103         raise AuthRequiredExc(atype = 'Basic', realm=self.provider.realm)
104
105
106 class HTTPHandler(SimpleHTTPRequestHandler):
107     def __init__(self,request, client_address, server):
108         SimpleHTTPRequestHandler.__init__(self,request,client_address,server)
109         # print "Handler for %s inited" % str(client_address)
110         self.protocol_version = 'HTTP/1.1'
111         self.connection = dummyconn()
112
113     def handle(self):
114         """ Classes here should NOT handle inside their constructor
115         """
116         pass
117
118     def finish(self):
119         pass
120
121     def setup(self):
122         pass
123
124 class HTTPDir:
125     """ A dispatcher class, like a virtual folder in httpd
126     """
127     def __init__(self,path,handler, auth_provider = None):
128         self.path = path
129         self.handler = handler
130         self.auth_provider = auth_provider
131
132     def matches(self, request):
133         """ Test if some request matches us. If so, return
134             the matched path. """
135         if request.startswith(self.path):
136             return self.path
137         return False
138
139 class noconnection:
140     """ a class to use instead of the real connection
141     """
142     def makefile(self, mode, bufsize):
143         return None
144
145 class dummyconn:
146     def shutdown(self, tru):
147         pass
148
149 def _quote_html(html):
150     return html.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
151
152 class FixSendError:
153     #error_message_format = """ """
154     def send_error(self, code, message=None):
155         #overriden from BaseHTTPRequestHandler, we also send the content-length
156         try:
157             short, long = self.responses[code]
158         except KeyError:
159             short, long = '???', '???'
160         if message is None:
161             message = short
162         explain = long
163         self.log_error("code %d, message %s", code, message)
164         # using _quote_html to prevent Cross Site Scripting attacks (see bug #1100201)
165         content = (self.error_message_format %
166                    {'code': code, 'message': _quote_html(message), 'explain': explain})
167         self.send_response(code, message)
168         self.send_header("Content-Type", self.error_content_type)
169         self.send_header('Connection', 'close')
170         self.send_header('Content-Length', len(content) or 0)
171         self.end_headers()
172         if self.command != 'HEAD' and code >= 200 and code not in (204, 304):
173             self.wfile.write(content)
174
175 class MultiHTTPHandler(FixSendError,BaseHTTPRequestHandler):
176     """ this is a multiple handler, that will dispatch each request
177         to a nested handler, iff it matches
178
179         The handler will also have *one* dict of authentication proxies,
180         groupped by their realm.
181     """
182
183     protocol_version = "HTTP/1.1"
184     default_request_version = "HTTP/0.9"    # compatibility with py2.5
185
186     auth_required_msg = """ <html><head><title>Authorization required</title></head>
187     <body>You must authenticate to use this service</body><html>\r\r"""
188
189     def __init__(self, request, client_address, server):
190         self.in_handlers = {}
191         self.sec_realms = {}
192         SocketServer.StreamRequestHandler.__init__(self,request,client_address,server)
193         self.log_message("MultiHttpHandler init for %s" %(str(client_address)))
194
195     def _handle_one_foreign(self,fore, path, auth_provider):
196         """ This method overrides the handle_one_request for *children*
197             handlers. It is required, since the first line should not be
198             read again..
199
200         """
201         fore.raw_requestline = "%s %s %s\n" % (self.command, path, self.version)
202         if not fore.parse_request(): # An error code has been sent, just exit
203             return
204         self.request_version = fore.request_version
205         if auth_provider and auth_provider.realm:
206             try:
207                 self.sec_realms[auth_provider.realm].checkRequest(fore,path)
208             except AuthRequiredExc,ae:
209                 if self.request_version != 'HTTP/1.1':
210                     self.log_error("Cannot require auth at %s",self.request_version)
211                     self.send_error(403)
212                     return
213                 self._get_ignore_body(fore) # consume any body that came, not loose sync with input
214                 self.send_response(401,'Authorization required')
215                 self.send_header('WWW-Authenticate','%s realm="%s"' % (ae.atype,ae.realm))
216                 self.send_header('Connection', 'keep-alive')
217                 self.send_header('Content-Type','text/html')
218                 self.send_header('Content-Length',len(self.auth_required_msg))
219                 self.end_headers()
220                 self.wfile.write(self.auth_required_msg)
221                 return
222             except AuthRejectedExc,e:
223                 self.log_error("Rejected auth: %s" % e.args[0])
224                 self.send_error(403,e.args[0])
225                 self.close_connection = 1
226                 return
227         mname = 'do_' + fore.command
228         if not hasattr(fore, mname):
229             fore.send_error(501, "Unsupported method (%r)" % fore.command)
230             return
231         fore.close_connection = 0
232         method = getattr(fore, mname)
233         method()
234         if fore.close_connection:
235             # print "Closing connection because of handler"
236             self.close_connection = fore.close_connection
237         if hasattr(fore, '_flush'):
238             fore._flush()
239
240
241     def parse_rawline(self):
242         """Parse a request (internal).
243
244         The request should be stored in self.raw_requestline; the results
245         are in self.command, self.path, self.request_version and
246         self.headers.
247
248         Return True for success, False for failure; on failure, an
249         error is sent back.
250
251         """
252         self.command = None  # set in case of error on the first line
253         self.request_version = version = self.default_request_version
254         self.close_connection = 1
255         requestline = self.raw_requestline
256         if requestline[-2:] == '\r\n':
257             requestline = requestline[:-2]
258         elif requestline[-1:] == '\n':
259             requestline = requestline[:-1]
260         self.requestline = requestline
261         words = requestline.split()
262         if len(words) == 3:
263             [command, path, version] = words
264             if version[:5] != 'HTTP/':
265                 self.send_error(400, "Bad request version (%r)" % version)
266                 return False
267             try:
268                 base_version_number = version.split('/', 1)[1]
269                 version_number = base_version_number.split(".")
270                 # RFC 2145 section 3.1 says there can be only one "." and
271                 #   - major and minor numbers MUST be treated as
272                 #      separate integers;
273                 #   - HTTP/2.4 is a lower version than HTTP/2.13, which in
274                 #      turn is lower than HTTP/12.3;
275                 #   - Leading zeros MUST be ignored by recipients.
276                 if len(version_number) != 2:
277                     raise ValueError
278                 version_number = int(version_number[0]), int(version_number[1])
279             except (ValueError, IndexError):
280                 self.send_error(400, "Bad request version (%r)" % version)
281                 return False
282             if version_number >= (1, 1):
283                 self.close_connection = 0
284             if version_number >= (2, 0):
285                 self.send_error(505,
286                           "Invalid HTTP Version (%s)" % base_version_number)
287                 return False
288         elif len(words) == 2:
289             [command, path] = words
290             self.close_connection = 1
291             if command != 'GET':
292                 self.send_error(400,
293                                 "Bad HTTP/0.9 request type (%r)" % command)
294                 return False
295         elif not words:
296             return False
297         else:
298             #self.send_error(400, "Bad request syntax (%r)" % requestline)
299             return False
300         self.request_version = version
301         self.command, self.path, self.version = command, path, version
302         return True
303
304     def handle_one_request(self):
305         """Handle a single HTTP request.
306            Dispatch to the correct handler.
307         """
308         self.request.setblocking(True)
309         self.raw_requestline = self.rfile.readline()
310         if not self.raw_requestline:
311             self.close_connection = 1
312             # self.log_message("no requestline, connection closed?")
313             return
314         if not self.parse_rawline():
315             self.log_message("Could not parse rawline.")
316             return
317         # self.parse_request(): # Do NOT parse here. the first line should be the only
318         for vdir in self.server.vdirs:
319             p = vdir.matches(self.path)
320             if p == False:
321                 continue
322             npath = self.path[len(p):]
323             if not npath.startswith('/'):
324                 npath = '/' + npath
325
326             if not self.in_handlers.has_key(p):
327                 self.in_handlers[p] = vdir.handler(noconnection(),self.client_address,self.server)
328                 if vdir.auth_provider:
329                     vdir.auth_provider.setupAuth(self, self.in_handlers[p])
330             hnd = self.in_handlers[p]
331             hnd.rfile = self.rfile
332             hnd.wfile = self.wfile
333             self.rlpath = self.raw_requestline
334             self._handle_one_foreign(hnd,npath, vdir.auth_provider)
335             # print "Handled, closing = ", self.close_connection
336             return
337         # if no match:
338         self.send_error(404, "Path not found: %s" % self.path)
339         return
340
341     def _get_ignore_body(self,fore):
342         if not fore.headers.has_key("content-length"):
343             return
344         max_chunk_size = 10*1024*1024
345         size_remaining = int(fore.headers["content-length"])
346         got = ''
347         while size_remaining:
348             chunk_size = min(size_remaining, max_chunk_size)
349             got = fore.rfile.read(chunk_size)
350             size_remaining -= len(got)
351
352
353 class SecureMultiHTTPHandler(MultiHTTPHandler):
354     def getcert_fnames(self):
355         """ Return a pair with the filenames of ssl cert,key
356
357             Override this to direct to other filenames
358         """
359         return ('server.cert','server.key')
360
361     def setup(self):
362         import ssl
363         certfile, keyfile = self.getcert_fnames()
364         try:
365             self.connection = ssl.wrap_socket(self.request,
366                                 server_side=True,
367                                 certfile=certfile,
368                                 keyfile=keyfile,
369                                 ssl_version=ssl.PROTOCOL_SSLv23)
370             self.rfile = self.connection.makefile('rb', self.rbufsize)
371             self.wfile = self.connection.makefile('wb', self.wbufsize)
372             self.log_message("Secure %s connection from %s",self.connection.cipher(),self.client_address)
373         except:
374             self.request.shutdown(socket.SHUT_RDWR)
375             raise
376
377     def finish(self):
378         # With ssl connections, closing the filehandlers alone may not
379         # work because of ref counting. We explicitly tell the socket
380         # to shutdown.
381         MultiHTTPHandler.finish(self)
382         try:
383             self.connection.shutdown(socket.SHUT_RDWR)
384         except:
385             pass
386
387 import threading
388 class ConnThreadingMixIn:
389     """Mix-in class to handle each _connection_ in a new thread.
390
391        This is necessary for persistent connections, where multiple
392        requests should be handled synchronously at each connection, but
393        multiple connections can run in parallel.
394     """
395
396     # Decides how threads will act upon termination of the
397     # main process
398     daemon_threads = False
399
400     def _handle_request_noblock(self):
401         """Start a new thread to process the request."""
402         t = threading.Thread(target = self._handle_request2)
403         if self.daemon_threads:
404             t.setDaemon (1)
405         t.start()
406
407     def _handle_request2(self):
408         """Handle one request, without blocking.
409
410         I assume that select.select has returned that the socket is
411         readable before this function was called, so there should be
412         no risk of blocking in get_request().
413         """
414         try:
415             request, client_address = self.get_request()
416         except socket.error:
417             return
418         if self.verify_request(request, client_address):
419             try:
420                 self.process_request(request, client_address)
421             except:
422                 self.handle_error(request, client_address)
423                 self.close_request(request)
424
425 #eof