OLD | NEW |
| (Empty) |
1 # -*- test-case-name: twisted.web.test.test_woven -*- | |
2 | |
3 # Copyright (c) 2001-2004 Twisted Matrix Laboratories. | |
4 # See LICENSE for details. | |
5 | |
6 # | |
7 | |
8 """Resource protection for Woven. If you wish to use twisted.cred to protect | |
9 your Woven application, you are probably most interested in | |
10 L{UsernamePasswordWrapper}. | |
11 """ | |
12 | |
13 from __future__ import nested_scopes | |
14 | |
15 __version__ = "$Revision: 1.34 $"[11:-2] | |
16 | |
17 import random | |
18 import time | |
19 import md5 | |
20 import urllib | |
21 | |
22 # Twisted Imports | |
23 | |
24 from twisted.python import log, components | |
25 from twisted.web.resource import Resource, IResource | |
26 from twisted.web.util import redirectTo, Redirect, DeferredResource | |
27 from twisted.web.static import addSlash | |
28 from twisted.internet import reactor | |
29 from twisted.cred.error import LoginFailed, UnauthorizedLogin | |
30 | |
31 def _sessionCookie(): | |
32 return md5.new("%s_%s" % (str(random.random()) , str(time.time()))).hexdiges
t() | |
33 | |
34 class GuardSession(components.Componentized): | |
35 """A user's session with a system. | |
36 | |
37 This utility class contains no functionality, but is used to | |
38 represent a session. | |
39 """ | |
40 def __init__(self, guard, uid): | |
41 """Initialize a session with a unique ID for that session. | |
42 """ | |
43 components.Componentized.__init__(self) | |
44 self.guard = guard | |
45 self.uid = uid | |
46 self.expireCallbacks = [] | |
47 self.checkExpiredID = None | |
48 self.setLifetime(60) | |
49 self.services = {} | |
50 self.portals = {} | |
51 self.touch() | |
52 | |
53 def _getSelf(self, interface=None): | |
54 self.touch() | |
55 if interface is None: | |
56 return self | |
57 else: | |
58 return self.getComponent(interface) | |
59 | |
60 # Old Guard interfaces | |
61 | |
62 def clientForService(self, service): | |
63 x = self.services.get(service) | |
64 if x: | |
65 return x[1] | |
66 else: | |
67 return x | |
68 | |
69 def setClientForService(self, ident, perspective, client, service): | |
70 if self.services.has_key(service): | |
71 p, c, i = self.services[service] | |
72 p.detached(c, ident) | |
73 del self.services[service] | |
74 else: | |
75 self.services[service] = perspective, client, ident | |
76 perspective.attached(client, ident) | |
77 # this return value is useful for services that need to do asynchronous | |
78 # stuff. | |
79 return client | |
80 | |
81 # New Guard Interfaces | |
82 | |
83 def resourceForPortal(self, port): | |
84 return self.portals.get(port) | |
85 | |
86 def setResourceForPortal(self, rsrc, port, logout): | |
87 self.portalLogout(port) | |
88 self.portals[port] = rsrc, logout | |
89 return rsrc | |
90 | |
91 def portalLogout(self, port): | |
92 p = self.portals.get(port) | |
93 if p: | |
94 r, l = p | |
95 try: l() | |
96 except: log.err() | |
97 del self.portals[port] | |
98 | |
99 # timeouts and expiration | |
100 | |
101 def setLifetime(self, lifetime): | |
102 """Set the approximate lifetime of this session, in seconds. | |
103 | |
104 This is highly imprecise, but it allows you to set some general | |
105 parameters about when this session will expire. A callback will be | |
106 scheduled each 'lifetime' seconds, and if I have not been 'touch()'ed | |
107 in half a lifetime, I will be immediately expired. | |
108 """ | |
109 self.lifetime = lifetime | |
110 | |
111 def notifyOnExpire(self, callback): | |
112 """Call this callback when the session expires or logs out. | |
113 """ | |
114 self.expireCallbacks.append(callback) | |
115 | |
116 def expire(self): | |
117 """Expire/logout of the session. | |
118 """ | |
119 log.msg("expired session %s" % self.uid) | |
120 del self.guard.sessions[self.uid] | |
121 for c in self.expireCallbacks: | |
122 try: | |
123 c() | |
124 except: | |
125 log.err() | |
126 self.expireCallbacks = [] | |
127 if self.checkExpiredID: | |
128 self.checkExpiredID.cancel() | |
129 self.checkExpiredID = None | |
130 | |
131 def touch(self): | |
132 self.lastModified = time.time() | |
133 | |
134 def checkExpired(self): | |
135 self.checkExpiredID = None | |
136 # If I haven't been touched in 15 minutes: | |
137 if time.time() - self.lastModified > self.lifetime / 2: | |
138 if self.guard.sessions.has_key(self.uid): | |
139 self.expire() | |
140 else: | |
141 log.msg("no session to expire: %s" % self.uid) | |
142 else: | |
143 log.msg("session given the will to live for %s more seconds" % self.
lifetime) | |
144 self.checkExpiredID = reactor.callLater(self.lifetime, | |
145 self.checkExpired) | |
146 def __getstate__(self): | |
147 d = self.__dict__.copy() | |
148 if d.has_key('checkExpiredID'): | |
149 del d['checkExpiredID'] | |
150 return d | |
151 | |
152 def __setstate__(self, d): | |
153 self.__dict__.update(d) | |
154 self.touch() | |
155 self.checkExpired() | |
156 | |
157 INIT_SESSION = 'session-init' | |
158 | |
159 def _setSession(wrap, req, cook): | |
160 req.session = wrap.sessions[cook] | |
161 req.getSession = req.session._getSelf | |
162 | |
163 def urlToChild(request, *ar, **kw): | |
164 pp = request.prepath.pop() | |
165 orig = request.prePathURL() | |
166 request.prepath.append(pp) | |
167 c = '/'.join(ar) | |
168 if orig[-1] == '/': | |
169 # this SHOULD only happen in the case where the URL is just the hostname | |
170 ret = orig + c | |
171 else: | |
172 ret = orig + '/' + c | |
173 args = request.args.copy() | |
174 args.update(kw) | |
175 if args: | |
176 ret += '?'+urllib.urlencode(args) | |
177 return ret | |
178 | |
179 def redirectToSession(request, garbage): | |
180 rd = Redirect(urlToChild(request, *request.postpath, **{garbage:1})) | |
181 rd.isLeaf = 1 | |
182 return rd | |
183 | |
184 SESSION_KEY='__session_key__' | |
185 | |
186 class SessionWrapper(Resource): | |
187 | |
188 sessionLifetime = 1800 | |
189 | |
190 def __init__(self, rsrc, cookieKey=None): | |
191 Resource.__init__(self) | |
192 self.resource = rsrc | |
193 if cookieKey is None: | |
194 cookieKey = "woven_session_" + _sessionCookie() | |
195 self.cookieKey = cookieKey | |
196 self.sessions = {} | |
197 | |
198 def render(self, request): | |
199 return redirectTo(addSlash(request), request) | |
200 | |
201 def getChild(self, path, request): | |
202 if not request.prepath: | |
203 return None | |
204 cookie = request.getCookie(self.cookieKey) | |
205 setupURL = urlToChild(request, INIT_SESSION, *([path]+request.postpath)) | |
206 request.setupSessionURL = setupURL | |
207 request.setupSession = lambda: Redirect(setupURL) | |
208 if path.startswith(SESSION_KEY): | |
209 key = path[len(SESSION_KEY):] | |
210 if key not in self.sessions: | |
211 return redirectToSession(request, '__start_session__') | |
212 self.sessions[key].setLifetime(self.sessionLifetime) | |
213 if cookie == key: | |
214 # /sessionized-url/${SESSION_KEY}aef9c34aecc3d9148/foo | |
215 # ^ | |
216 # we are this getChild | |
217 # with a matching cookie | |
218 return redirectToSession(request, '__session_just_started__') | |
219 else: | |
220 # We attempted to negotiate the session but failed (the user | |
221 # probably has cookies disabled): now we're going to return the | |
222 # resource we contain. In general the getChild shouldn't stop | |
223 # there. | |
224 # /sessionized-url/${SESSION_KEY}aef9c34aecc3d9148/foo | |
225 # ^ we are this getChild | |
226 # without a cookie (or with a mismatched cookie) | |
227 _setSession(self, request, key) | |
228 return self.resource | |
229 elif cookie in self.sessions: | |
230 # /sessionized-url/foo | |
231 # ^ we are this getChild | |
232 # with a session | |
233 _setSession(self, request, cookie) | |
234 return getResource(self.resource, path, request) | |
235 elif path == INIT_SESSION: | |
236 # initialize the session | |
237 # /sessionized-url/session-init | |
238 # ^ this getChild | |
239 # without a session | |
240 newCookie = _sessionCookie() | |
241 request.addCookie(self.cookieKey, newCookie, path="/") | |
242 sz = self.sessions[newCookie] = GuardSession(self, newCookie) | |
243 sz.checkExpired() | |
244 rd = Redirect(urlToChild(request, SESSION_KEY+newCookie, | |
245 *request.postpath)) | |
246 rd.isLeaf = 1 | |
247 return rd | |
248 else: | |
249 # /sessionized-url/foo | |
250 # ^ we are this getChild | |
251 # without a session | |
252 request.getSession = lambda interface=None: None | |
253 return getResource(self.resource, path, request) | |
254 | |
255 def getResource(resource, path, request): | |
256 if resource.isLeaf: | |
257 request.postpath.insert(0, request.prepath.pop()) | |
258 return resource | |
259 else: | |
260 return resource.getChildWithDefault(path, request) | |
261 | |
262 INIT_PERSPECTIVE = 'perspective-init' | |
263 DESTROY_PERSPECTIVE = 'perspective-destroy' | |
264 | |
265 from twisted.python import formmethod as fm | |
266 from twisted.web.woven import form | |
267 | |
268 | |
269 newLoginSignature = fm.MethodSignature( | |
270 fm.String("username", "", | |
271 "Username", "Your user name."), | |
272 fm.Password("password", "", | |
273 "Password", "Your password."), | |
274 fm.Submit("submit", choices=[("Login", "", "")], allowNone=1), | |
275 ) | |
276 | |
277 from twisted.cred.credentials import UsernamePassword, Anonymous | |
278 | |
279 class UsernamePasswordWrapper(Resource): | |
280 """I bring a C{twisted.cred} Portal to the web. Use me to provide different
Resources | |
281 (usually entire pages) based on a user's authentication details. | |
282 | |
283 A C{UsernamePasswordWrapper} is a | |
284 L{Resource<twisted.web.resource.Resource>}, and is usually wrapped in a | |
285 L{SessionWrapper} before being inserted into the site tree. | |
286 | |
287 The L{Realm<twisted.cred.portal.IRealm>} associated with your | |
288 L{Portal<twisted.cred.portal.Portal>} should be prepared to accept a | |
289 request for an avatar that implements the L{twisted.web.resource.IResource} | |
290 interface. This avatar should probably be something like a Woven | |
291 L{Page<twisted.web.woven.page.Page>}. That is, it should represent a whole | |
292 web page. Once you return this avatar, requests for it's children do not go | |
293 through guard. | |
294 | |
295 If you want to determine what unauthenticated users see, make sure your | |
296 L{Portal<twisted.cred.portal.Portal>} has a checker associated that allows | |
297 anonymous access. (See L{twisted.cred.checkers.AllowAnonymousAccess}) | |
298 | |
299 """ | |
300 | |
301 def __init__(self, portal, callback=None, errback=None): | |
302 """Constructs a UsernamePasswordWrapper around the given portal. | |
303 | |
304 @param portal: A cred portal for your web application. The checkers | |
305 associated with this portal must be able to accept username/password | |
306 credentials. | |
307 @type portal: L{twisted.cred.portal.Portal} | |
308 | |
309 @param callback: Gets called after a successful login attempt. | |
310 A resource that redirects to "." will display the avatar resource. | |
311 If this parameter isn't provided, defaults to a standard Woven | |
312 "Thank You" page. | |
313 @type callback: A callable that accepts a Woven | |
314 L{model<twisted.web.woven.interfaces.IModel>} and returns a | |
315 L{IResource<twisted.web.resource.Resource>}. | |
316 | |
317 @param errback: Gets called after a failed login attempt. | |
318 If this parameter is not provided, defaults to a the standard Woven | |
319 form error (i.e. The original form on a page of its own, with | |
320 errors noted.) | |
321 @type errback: A callable that accepts a Woven | |
322 L{model<twisted.web.woven.interfaces.IModel>} and returns a | |
323 L{IResource<twisted.web.resource.Resource>}. | |
324 """ | |
325 Resource.__init__(self) | |
326 self.portal = portal | |
327 self.callback = callback | |
328 self.errback = errback | |
329 | |
330 def _ebFilter(self, f): | |
331 f.trap(LoginFailed, UnauthorizedLogin) | |
332 raise fm.FormException(password="Login failed, please enter correct user
name and password.") | |
333 | |
334 def getChild(self, path, request): | |
335 s = request.getSession() | |
336 if s is None: | |
337 return request.setupSession() | |
338 if path == INIT_PERSPECTIVE: | |
339 def loginSuccess(result): | |
340 interface, avatarAspect, logout = result | |
341 s.setResourceForPortal(avatarAspect, self.portal, logout) | |
342 | |
343 def triggerLogin(username, password, submit=None): | |
344 return self.portal.login( | |
345 UsernamePassword(username, password), | |
346 None, | |
347 IResource | |
348 ).addCallback( | |
349 loginSuccess | |
350 ).addErrback( | |
351 self._ebFilter | |
352 ) | |
353 | |
354 return form.FormProcessor( | |
355 newLoginSignature.method( | |
356 triggerLogin | |
357 ), | |
358 callback=self.callback, | |
359 errback=self.errback | |
360 ) | |
361 elif path == DESTROY_PERSPECTIVE: | |
362 s.portalLogout(self.portal) | |
363 return Redirect(".") | |
364 else: | |
365 r = s.resourceForPortal(self.portal) | |
366 if r: | |
367 ## Delegate our getChild to the resource our portal says is the
right one. | |
368 return getResource(r[0], path, request) | |
369 else: | |
370 return DeferredResource( | |
371 self.portal.login(Anonymous(), None, IResource | |
372 ).addCallback( | |
373 lambda (interface, avatarAspect, logout): | |
374 getResource(s.setResourceForPortal(avatarAspect, | |
375 self.portal, logout), | |
376 path, request))) | |
377 | |
378 | |
379 | |
380 from twisted.web.woven import interfaces, utils | |
381 ## Dumb hack until we have an ISession and use interface-to-interface adaption | |
382 components.registerAdapter(utils.WovenLivePage, GuardSession, interfaces.IWovenL
ivePage) | |
383 | |
OLD | NEW |