|
1 """CubicWeb web client application object |
|
2 |
|
3 :organization: Logilab |
|
4 :copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
|
5 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr |
|
6 """ |
|
7 __docformat__ = "restructuredtext en" |
|
8 |
|
9 import sys |
|
10 from time import clock, time |
|
11 |
|
12 from rql import BadRQLQuery |
|
13 |
|
14 from cubicweb import set_log_methods |
|
15 from cubicweb import (ValidationError, Unauthorized, AuthenticationError, |
|
16 NoSelectableObject, RepositoryError) |
|
17 from cubicweb.cwconfig import CubicWebConfiguration |
|
18 from cubicweb.cwvreg import CubicWebRegistry |
|
19 from cubicweb.web import (LOGGER, StatusResponse, DirectResponse, Redirect, NotFound, |
|
20 RemoteCallFailed, ExplicitLogin, InvalidSession) |
|
21 from cubicweb.web.component import SingletonComponent |
|
22 |
|
23 # make session manager available through a global variable so the debug view can |
|
24 # print information about web session |
|
25 SESSION_MANAGER = None |
|
26 |
|
27 class AbstractSessionManager(SingletonComponent): |
|
28 """manage session data associated to a session identifier""" |
|
29 id = 'sessionmanager' |
|
30 |
|
31 def __init__(self): |
|
32 self.session_time = self.vreg.config['http-session-time'] or None |
|
33 assert self.session_time is None or self.session_time > 0 |
|
34 self.cleanup_session_time = self.vreg.config['cleanup-session-time'] or 120 |
|
35 assert self.cleanup_session_time > 0 |
|
36 self.cleanup_anon_session_time = self.vreg.config['cleanup-anonymous-session-time'] or 720 |
|
37 assert self.cleanup_anon_session_time > 0 |
|
38 if self.session_time: |
|
39 assert self.cleanup_session_time < self.session_time |
|
40 assert self.cleanup_anon_session_time < self.session_time |
|
41 self.authmanager = self.vreg.select_component('authmanager') |
|
42 assert self.authmanager, 'no authentication manager found' |
|
43 |
|
44 def clean_sessions(self): |
|
45 """cleanup sessions which has not been unused since a given amount of |
|
46 time. Return the number of sessions which have been closed. |
|
47 """ |
|
48 self.debug('cleaning http sessions') |
|
49 closed, total = 0, 0 |
|
50 for session in self.current_sessions(): |
|
51 no_use_time = (time() - session.last_usage_time) |
|
52 total += 1 |
|
53 if session.anonymous_connection: |
|
54 if no_use_time >= self.cleanup_anon_session_time: |
|
55 self.close_session(session) |
|
56 closed += 1 |
|
57 elif no_use_time >= self.cleanup_session_time: |
|
58 self.close_session(session) |
|
59 closed += 1 |
|
60 return closed, total - closed |
|
61 |
|
62 def has_expired(self, session): |
|
63 """return True if the web session associated to the session is expired |
|
64 """ |
|
65 return not (self.session_time is None or |
|
66 time() < session.last_usage_time + self.session_time) |
|
67 |
|
68 def current_sessions(self): |
|
69 """return currently open sessions""" |
|
70 raise NotImplementedError() |
|
71 |
|
72 def get_session(self, req, sessionid): |
|
73 """return existing session for the given session identifier""" |
|
74 raise NotImplementedError() |
|
75 |
|
76 def open_session(self, req): |
|
77 """open and return a new session for the given request |
|
78 |
|
79 :raise ExplicitLogin: if authentication is required |
|
80 """ |
|
81 raise NotImplementedError() |
|
82 |
|
83 def close_session(self, session): |
|
84 """close session on logout or on invalid session detected (expired out, |
|
85 corrupted...) |
|
86 """ |
|
87 raise NotImplementedError() |
|
88 |
|
89 |
|
90 class AbstractAuthenticationManager(SingletonComponent): |
|
91 """authenticate user associated to a request and check session validity""" |
|
92 id = 'authmanager' |
|
93 |
|
94 def authenticate(self, req): |
|
95 """authenticate user and return corresponding user object |
|
96 |
|
97 :raise ExplicitLogin: if authentication is required (no authentication |
|
98 info found or wrong user/password) |
|
99 """ |
|
100 raise NotImplementedError() |
|
101 |
|
102 |
|
103 class CookieSessionHandler(object): |
|
104 """a session handler using a cookie to store the session identifier |
|
105 |
|
106 :cvar SESSION_VAR: |
|
107 string giving the name of the variable used to store the session |
|
108 identifier |
|
109 """ |
|
110 SESSION_VAR = '__session' |
|
111 |
|
112 def __init__(self, appli): |
|
113 self.session_manager = appli.vreg.select_component('sessionmanager') |
|
114 assert self.session_manager, 'no session manager found' |
|
115 global SESSION_MANAGER |
|
116 SESSION_MANAGER = self.session_manager |
|
117 if not 'last_login_time' in appli.vreg.schema: |
|
118 self._update_last_login_time = lambda x: None |
|
119 |
|
120 def clean_sessions(self): |
|
121 """cleanup sessions which has not been unused since a given amount of |
|
122 time |
|
123 """ |
|
124 self.session_manager.clean_sessions() |
|
125 |
|
126 def set_session(self, req): |
|
127 """associate a session to the request |
|
128 |
|
129 Session id is searched from : |
|
130 - # form variable |
|
131 - cookie |
|
132 |
|
133 if no session id is found, open a new session for the connected user |
|
134 or request authentification as needed |
|
135 |
|
136 :raise Redirect: if authentication has occured and succeed |
|
137 """ |
|
138 assert req.cnx is None # at this point no cnx should be set on the request |
|
139 cookie = req.get_cookie() |
|
140 try: |
|
141 sessionid = str(cookie[self.SESSION_VAR].value) |
|
142 except KeyError: # no session cookie |
|
143 session = self.open_session(req) |
|
144 else: |
|
145 try: |
|
146 session = self.get_session(req, sessionid) |
|
147 except InvalidSession: |
|
148 try: |
|
149 session = self.open_session(req) |
|
150 except ExplicitLogin: |
|
151 req.remove_cookie(cookie, self.SESSION_VAR) |
|
152 raise |
|
153 # remember last usage time for web session tracking |
|
154 session.last_usage_time = time() |
|
155 |
|
156 def get_session(self, req, sessionid): |
|
157 return self.session_manager.get_session(req, sessionid) |
|
158 |
|
159 def open_session(self, req): |
|
160 session = self.session_manager.open_session(req) |
|
161 cookie = req.get_cookie() |
|
162 cookie[self.SESSION_VAR] = session.sessionid |
|
163 req.set_cookie(cookie, self.SESSION_VAR, maxage=None) |
|
164 # remember last usage time for web session tracking |
|
165 session.last_usage_time = time() |
|
166 if not session.anonymous_connection: |
|
167 self._postlogin(req) |
|
168 return session |
|
169 |
|
170 def _update_last_login_time(self, req): |
|
171 try: |
|
172 req.execute('SET X last_login_time NOW WHERE X eid %(x)s', |
|
173 {'x' : req.user.eid}, 'x') |
|
174 req.cnx.commit() |
|
175 except (RepositoryError, Unauthorized): |
|
176 # ldap user are not writeable for instance |
|
177 req.cnx.rollback() |
|
178 except: |
|
179 req.cnx.rollback() |
|
180 raise |
|
181 |
|
182 def _postlogin(self, req): |
|
183 """postlogin: the user has been authenticated, redirect to the original |
|
184 page (index by default) with a welcome message |
|
185 """ |
|
186 # Update last connection date |
|
187 # XXX: this should be in a post login hook in the repository, but there |
|
188 # we can't differentiate actual login of automatic session |
|
189 # reopening. Is it actually a problem? |
|
190 self._update_last_login_time(req) |
|
191 args = req.form |
|
192 args['__message'] = req._('welcome %s !') % req.user.login |
|
193 if 'vid' in req.form: |
|
194 args['vid'] = req.form['vid'] |
|
195 if 'rql' in req.form: |
|
196 args['rql'] = req.form['rql'] |
|
197 path = req.relative_path(False) |
|
198 if path == 'login': |
|
199 path = 'view' |
|
200 raise Redirect(req.build_url(path, **args)) |
|
201 |
|
202 def logout(self, req): |
|
203 """logout from the application by cleaning the session and raising |
|
204 `AuthenticationError` |
|
205 """ |
|
206 self.session_manager.close_session(req.cnx) |
|
207 req.remove_cookie(req.get_cookie(), self.SESSION_VAR) |
|
208 raise AuthenticationError() |
|
209 |
|
210 |
|
211 class CubicWebPublisher(object): |
|
212 """Central registry for the web application. This is one of the central |
|
213 object in the web application, coupling dynamically loaded objects with |
|
214 the application's schema and the application's configuration objects. |
|
215 |
|
216 It specializes the VRegistry by adding some convenience methods to |
|
217 access to stored objects. Currently we have the following registries |
|
218 of objects known by the web application (library may use some others |
|
219 additional registries): |
|
220 * controllers, which are directly plugged into the application |
|
221 object to handle request publishing |
|
222 * views |
|
223 * templates |
|
224 * components |
|
225 * actions |
|
226 """ |
|
227 |
|
228 def __init__(self, config, debug=None, |
|
229 session_handler_fact=CookieSessionHandler, |
|
230 vreg=None): |
|
231 super(CubicWebPublisher, self).__init__() |
|
232 # connect to the repository and get application's schema |
|
233 if vreg is None: |
|
234 vreg = CubicWebRegistry(config, debug=debug) |
|
235 self.vreg = vreg |
|
236 self.info('starting web application from %s', config.apphome) |
|
237 self.repo = config.repository(vreg) |
|
238 if not vreg.initialized: |
|
239 self.config.init_cubes(self.repo.get_cubes()) |
|
240 vreg.init_properties(self.repo.properties()) |
|
241 vreg.set_schema(self.repo.get_schema()) |
|
242 # set the correct publish method |
|
243 if config['query-log-file']: |
|
244 from threading import Lock |
|
245 self._query_log = open(config['query-log-file'], 'a') |
|
246 self.publish = self.log_publish |
|
247 self._logfile_lock = Lock() |
|
248 else: |
|
249 self._query_log = None |
|
250 self.publish = self.main_publish |
|
251 # instantiate session and url resolving helpers |
|
252 self.session_handler = session_handler_fact(self) |
|
253 self.url_resolver = vreg.select_component('urlpublisher') |
|
254 |
|
255 def connect(self, req): |
|
256 """return a connection for a logged user object according to existing |
|
257 sessions (i.e. a new connection may be created or an already existing |
|
258 one may be reused |
|
259 """ |
|
260 self.session_handler.set_session(req) |
|
261 |
|
262 def select_controller(self, oid, req): |
|
263 """return the most specific view according to the resultset""" |
|
264 vreg = self.vreg |
|
265 try: |
|
266 return vreg.select(vreg.registry_objects('controllers', oid), |
|
267 req=req, appli=self) |
|
268 except NoSelectableObject: |
|
269 raise Unauthorized(req._('not authorized')) |
|
270 |
|
271 # publish methods ######################################################### |
|
272 |
|
273 def log_publish(self, path, req): |
|
274 """wrapper around _publish to log all queries executed for a given |
|
275 accessed path |
|
276 """ |
|
277 try: |
|
278 return self.main_publish(path, req) |
|
279 finally: |
|
280 cnx = req.cnx |
|
281 self._logfile_lock.acquire() |
|
282 try: |
|
283 try: |
|
284 result = ['\n'+'*'*80] |
|
285 result.append(req.url()) |
|
286 result += ['%s %s -- (%.3f sec, %.3f CPU sec)' % q for q in cnx.executed_queries] |
|
287 cnx.executed_queries = [] |
|
288 self._query_log.write('\n'.join(result).encode(req.encoding)) |
|
289 self._query_log.flush() |
|
290 except Exception: |
|
291 self.exception('error while logging queries') |
|
292 finally: |
|
293 self._logfile_lock.release() |
|
294 |
|
295 def main_publish(self, path, req): |
|
296 """method called by the main publisher to process <path> |
|
297 |
|
298 should return a string containing the resulting page or raise a |
|
299 `NotFound` exception |
|
300 |
|
301 :type path: str |
|
302 :param path: the path part of the url to publish |
|
303 |
|
304 :type req: `web.Request` |
|
305 :param req: the request object |
|
306 |
|
307 :rtype: str |
|
308 :return: the result of the pusblished url |
|
309 """ |
|
310 path = path or 'view' |
|
311 # don't log form values they may contains sensitive information |
|
312 self.info('publish "%s" (form params: %s)', path, req.form.keys()) |
|
313 # remove user callbacks on a new request (except for json controllers |
|
314 # to avoid callbacks being unregistered before they could be called) |
|
315 tstart = clock() |
|
316 try: |
|
317 try: |
|
318 ctrlid, rset = self.url_resolver.process(req, path) |
|
319 controller = self.select_controller(ctrlid, req) |
|
320 result = controller.publish(rset=rset) |
|
321 if req.cnx is not None: |
|
322 # req.cnx is None if anonymous aren't allowed and we are |
|
323 # displaying the cookie authentication form |
|
324 req.cnx.commit() |
|
325 except (StatusResponse, DirectResponse): |
|
326 req.cnx.commit() |
|
327 raise |
|
328 except Redirect: |
|
329 # redirect is raised by edit controller when everything went fine, |
|
330 # so try to commit |
|
331 try: |
|
332 req.cnx.commit() |
|
333 except ValidationError, ex: |
|
334 self.validation_error_handler(req, ex) |
|
335 except Unauthorized, ex: |
|
336 req.data['errmsg'] = req._('You\'re not authorized to access this page. ' |
|
337 'If you think you should, please contact the site administrator.') |
|
338 self.error_handler(req, ex, tb=False) |
|
339 except Exception, ex: |
|
340 self.error_handler(req, ex, tb=True) |
|
341 else: |
|
342 # delete validation errors which may have been previously set |
|
343 if '__errorurl' in req.form: |
|
344 req.del_session_data(req.form['__errorurl']) |
|
345 raise |
|
346 except (AuthenticationError, NotFound, RemoteCallFailed): |
|
347 raise |
|
348 except ValidationError, ex: |
|
349 self.validation_error_handler(req, ex) |
|
350 except (Unauthorized, BadRQLQuery), ex: |
|
351 self.error_handler(req, ex, tb=False) |
|
352 except Exception, ex: |
|
353 self.error_handler(req, ex, tb=True) |
|
354 finally: |
|
355 if req.cnx is not None: |
|
356 try: |
|
357 req.cnx.rollback() |
|
358 except: |
|
359 pass # ignore rollback error at this point |
|
360 self.info('query %s executed in %s sec', req.relative_path(), clock() - tstart) |
|
361 return result |
|
362 |
|
363 def validation_error_handler(self, req, ex): |
|
364 ex.errors = dict((k, v) for k, v in ex.errors.items()) |
|
365 if '__errorurl' in req.form: |
|
366 forminfo = {'errors': ex, |
|
367 'values': req.form, |
|
368 'eidmap': req.data.get('eidmap', {}) |
|
369 } |
|
370 req.set_session_data(req.form['__errorurl'], forminfo) |
|
371 raise Redirect(req.form['__errorurl']) |
|
372 self.error_handler(req, ex, tb=False) |
|
373 |
|
374 def error_handler(self, req, ex, tb=False): |
|
375 excinfo = sys.exc_info() |
|
376 self.exception(repr(ex)) |
|
377 req.set_header('Cache-Control', 'no-cache') |
|
378 req.remove_header('Etag') |
|
379 req.message = None |
|
380 req.reset_headers() |
|
381 try: |
|
382 req.data['ex'] = ex |
|
383 if tb: |
|
384 req.data['excinfo'] = excinfo |
|
385 req.form['vid'] = 'error' |
|
386 content = self.vreg.main_template(req, 'main') |
|
387 except: |
|
388 content = self.vreg.main_template(req, 'error') |
|
389 raise StatusResponse(500, content) |
|
390 |
|
391 def need_login_content(self, req): |
|
392 return self.vreg.main_template(req, 'login') |
|
393 |
|
394 def loggedout_content(self, req): |
|
395 return self.vreg.main_template(req, 'loggedout') |
|
396 |
|
397 def notfound_content(self, req): |
|
398 template = req.property_value('ui.main-template') or 'main' |
|
399 req.form['vid'] = '404' |
|
400 return self.vreg.main_template(req, template) |
|
401 |
|
402 |
|
403 set_log_methods(CubicWebPublisher, LOGGER) |
|
404 set_log_methods(CookieSessionHandler, LOGGER) |