Source

trac-ticketlinks / trac / web / session.py

Full commit
jonas 26623b3 
cmlenz d94313b 
jonas 7c60ab2 
cmlenz d94313b 
cmlenz 47c3427 
jonas d8313f6 
mgood 49540df 
cmlenz d6c17a5 
cmlenz d94313b 
cmlenz d6c17a5 

cmlenz 47c3427 
cmlenz d94313b 
cmlenz d6c17a5 

cmlenz 47c3427 
cmlenz d94313b 

cmlenz 505edc3 
cmlenz d94313b 


rblank 96ce8b0 
jhampton 1861531 
cboos 0242c59 
rblank 96ce8b0 
jhampton 1861531 
rblank 96ce8b0 

jhampton 1861531 
cmlenz d94313b 
rblank 9cfb00e 

cmlenz d94313b 

cboos 9a3f170 




cmlenz d94313b 
mgood 49540df 

cmlenz edb3d0e 
cmlenz d94313b 

mgood 49540df 



rblank c373af9 


mgood 49540df 
cboos a30d751 


mgood 49540df 







rblank c373af9 
mgood 49540df 
cboos 071bd11 


mgood 49540df 

rblank c373af9 


mgood 49540df 

rblank 98e629f 
mgood 49540df 
cboos 071bd11 



rblank c373af9 

mgood 49540df 

cboos 9a3f170 

mgood 49540df 



jonas 737a803 

mgood 49540df 
cboos 9a3f170 







cboos c5fc48f 
rblank c373af9 
cboos 6a74248 
cboos 9a3f170 




cboos 6a74248 




jonas 45e9db5 
cboos 071bd11 



cboos 6a74248 
cboos 58d565e 
jonas 45e9db5 
cboos 58d565e 
cboos 9a3f170 



cboos 6a74248 
cboos 9a3f170 




cboos 071bd11 
rblank 4261558 


cboos 6a74248 
rblank cc583a3 













cboos 9a3f170 






cboos 53cd640 
cboos 9a3f170 

cboos 53cd640 
cboos 9a3f170 


cboos 6a74248 
cboos 071bd11 



cboos 6a74248 
cboos 071bd11 






cboos 53cd640 





cboos 071bd11 



mgood 49540df 







cmlenz 47a8ba3 
cmlenz 2843e42 
cmlenz 47a8ba3 




cmlenz d94313b 
cmlenz 47a8ba3 


cmlenz 0fc2dcb 
cmlenz d94313b 
cmlenz 47a8ba3 
cmlenz 2843e42 
cmlenz d94313b 
jonas b489d0c 
cmlenz 47a8ba3 
rblank 10d4c1f 

cmlenz d94313b 
cmlenz 0fc2dcb 
cmlenz 7220fc1 




mgood 49540df 
cmlenz 7220fc1 


cboos 071bd11 
cmlenz 7220fc1 

cmlenz 47a8ba3 

cmlenz 0fc2dcb 


cmlenz 47a8ba3 
cmlenz d94313b 
cboos c5fc48f 
cboos 6a74248 

rblank 3ee54a4 

rblank 4261558 



cboos 071bd11 








cmlenz 47a8ba3 
cmlenz d94313b 

cmlenz 47a8ba3 
cmlenz 2843e42 

cmlenz 47a8ba3 



cboos c5fc48f 
cboos 6a74248 

cboos 071bd11 


cboos 6a74248 




cboos 071bd11 






cboos 6a74248 






cboos 071bd11 







cboos 6a74248 
rblank 4f70095 











jonas d8313f6 
cmlenz 2843e42 

cmlenz 47a8ba3 
jhampton 1861531 







rblank 96ce8b0 

jhampton 1861531 
rblank 96ce8b0 








jhampton 1861531 
rblank 96ce8b0 
jhampton 1861531 

rblank 96ce8b0 



jhampton 1861531 

rblank 96ce8b0 





jhampton 1861531 

rblank 96ce8b0 
jhampton 1861531 

rblank 96ce8b0 




jhampton 1861531 



rblank ddc2212 

jhampton 1861531 

rblank 96ce8b0 




jhampton 1861531 
rblank 96ce8b0 
jhampton 1861531 
rblank 96ce8b0 
































jhampton 1861531 




rblank 96ce8b0 
jhampton 1861531 
rblank 96ce8b0 


jhampton 1861531 
rblank 96ce8b0 
jhampton 1861531 
rblank 96ce8b0 









cboos c5fc48f 
jhampton 1861531 

rblank 96ce8b0 

rblank bec7c48 
rblank 96ce8b0 


jhampton 1861531 
cboos 071bd11 
rblank 96ce8b0 

jhampton 1861531 
cboos 071bd11 
rblank 96ce8b0 

jhampton 1861531 
rblank 96ce8b0 




cboos c5fc48f 
jhampton 1861531 

cboos 071bd11 
rblank 96ce8b0 











jhampton 1861531 
rblank 96ce8b0 
cboos c5fc48f 
jhampton 1861531 

rblank 96ce8b0 



cboos 9a3f170 
rblank 96ce8b0 

cboos 9a3f170 
rblank 96ce8b0 


cboos 9a3f170 
rblank 96ce8b0 


cboos 9a3f170 
rblank 96ce8b0 

jhampton 1861531 
rblank 96ce8b0 

cboos c5fc48f 
jhampton 1861531 

rblank 96ce8b0 

cboos 9a3f170 



rblank 96ce8b0 

rblank 63fa855 

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
# -*- coding: utf-8 -*-
#
# Copyright (C) 2004-2009 Edgewall Software
# Copyright (C) 2004 Daniel Lundin <daniel@edgewall.com>
# Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
# Copyright (C) 2006 Jonas Borgström <jonas@edgewall.com>
# Copyright (C) 2008 Matt Good <matt@matt-good.net>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/log/.
#
# Author: Daniel Lundin <daniel@edgewall.com>
#         Christopher Lenz <cmlenz@gmx.de>

import time

from trac.admin.api import console_date_format
from trac.core import TracError, Component, implements
from trac.util import hex_entropy
from trac.util.text import print_table
from trac.util.translation import _
from trac.util.datefmt import format_date, parse_date, to_datetime, \
                              to_timestamp
from trac.admin.api import IAdminCommandProvider, AdminCommandError

UPDATE_INTERVAL = 3600 * 24 # Update session last_visit time stamp after 1 day
PURGE_AGE = 3600 * 24 * 90 # Purge session after 90 days idle
COOKIE_KEY = 'trac_session'

# Note: as we often manipulate both the `session` and the
#       `session_attribute` tables, there's a possibility of table
#       deadlocks (#9705). We try to prevent them to happen by always
#       accessing the tables in the same order within the transaction,
#       first `session`, then `session_attribute`.

class DetachedSession(dict):
    def __init__(self, env, sid):
        dict.__init__(self)
        self.env = env
        self.sid = None
        if sid:
            self.get_session(sid, authenticated=True)
        else:
            self.authenticated = False
            self.last_visit = 0
            self._new = True
            self._old = {}

    def __setitem__(self, key, value):
        dict.__setitem__(self, key, unicode(value))

    def get_session(self, sid, authenticated=False):
        self.env.log.debug('Retrieving session for ID %r', sid)

        db = self.env.get_db_cnx()
        cursor = db.cursor()

        self.sid = sid
        self.authenticated = authenticated
        self.clear()

        cursor.execute("""
            SELECT last_visit FROM session WHERE sid=%s AND authenticated=%s
            """, (sid, int(authenticated)))
        row = cursor.fetchone()
        if not row:
            self.last_visit = 0
            self._new = True
            self._old = {}
            return
        self._new = False
        self.last_visit = int(row[0] or 0)

        cursor.execute("""
            SELECT name,value FROM session_attribute
            WHERE sid=%s and authenticated=%s
            """, (sid, int(authenticated)))
        self.update(cursor)
        self._old = self.copy()

    def save(self):
        items = self.items()
        if not self._old and not items:
            # The session doesn't have associated data, so there's no need to
            # persist it
            return

        authenticated = int(self.authenticated)
        now = int(time.time())

        # We can't do the session management in one big transaction,
        # as the intertwined changes to both the session and
        # session_attribute tables are prone to deadlocks (#9705).
        # Therefore we first we save the current session, then we
        # eventually purge the tables.
        
        session_saved = [False]

        @self.env.with_transaction()
        def save_session(db):
            cursor = db.cursor()

            # Try to save the session if it's a new one. A failure to
            # do so is not critical but we nevertheless skip the
            # following steps.

            if self._new:
                self.last_visit = now
                self._new = False
                # The session might already exist even if _new is True since
                # it could have been created by a concurrent request (#3563).
                try:
                    cursor.execute("""
                        INSERT INTO session (sid,last_visit,authenticated)
                        VALUES (%s,%s,%s)
                        """, (self.sid, self.last_visit, authenticated))
                except Exception:
                    self.env.log.warning('Session %s already exists', self.sid)
                    db.rollback()
                    return

            # Remove former values for session_attribute and save the
            # new ones. The last concurrent request to do so "wins".

            if self._old != self:
                if not items and not authenticated:
                    # No need to keep around empty unauthenticated sessions
                    cursor.execute("""
                        DELETE FROM session WHERE sid=%s AND authenticated=0
                        """, (self.sid,))
                cursor.execute("""
                    DELETE FROM session_attribute
                    WHERE sid=%s AND authenticated=%s
                    """, (self.sid, authenticated))
                self._old = dict(self.items())
                # The session variables might already have been updated by a
                # concurrent request.
                try:
                    cursor.executemany("""
                        INSERT INTO session_attribute
                          (sid,authenticated,name,value)
                        VALUES (%s,%s,%s,%s)
                        """, [(self.sid, authenticated, k, v)
                              for k, v in items])
                except Exception:
                    self.env.log.warning('Attributes for session %s already '
                                         'updated', self.sid)
                    db.rollback()
                    return
                session_saved[0] = True

        # Purge expired sessions. We do this only when the session was
        # changed as to minimize the purging.

        if session_saved[0] and now - self.last_visit > UPDATE_INTERVAL:
            self.last_visit = now
            mintime = now - PURGE_AGE

            @self.env.with_transaction()
            def purge_session_attributes(db):
                cursor = db.cursor()
                # Update the session last visit time if it is over an
                # hour old, so that session doesn't get purged
                self.env.log.info("Refreshing session %s", self.sid)
                cursor.execute("""
                    UPDATE session SET last_visit=%s
                    WHERE sid=%s AND authenticated=%s
                    """, (self.last_visit, self.sid, authenticated))
                self.env.log.debug('Purging old, expired, sessions.')
                cursor.execute("""
                    DELETE FROM session_attribute
                    WHERE authenticated=0 AND sid IN (
                      SELECT sid FROM session 
                      WHERE authenticated=0 AND last_visit < %s
                    )
                    """, (mintime,))

            # Avoid holding locks on lot of rows on both session_attribute
            # and session tables
            @self.env.with_transaction()
            def purge_sessions(db):
                cursor = db.cursor()
                cursor.execute("""
                    DELETE FROM session
                    WHERE authenticated=0 AND last_visit < %s
                    """, (mintime,))


class Session(DetachedSession):
    """Basic session handling and per-session storage."""

    def __init__(self, env, req):
        super(Session, self).__init__(env, None)
        self.req = req
        if req.authname == 'anonymous':
            if not req.incookie.has_key(COOKIE_KEY):
                self.sid = hex_entropy(24)
                self.bake_cookie()
            else:
                sid = req.incookie[COOKIE_KEY].value
                self.get_session(sid)
        else:
            if req.incookie.has_key(COOKIE_KEY):
                sid = req.incookie[COOKIE_KEY].value
                self.promote_session(sid)
            self.get_session(req.authname, authenticated=True)

    def bake_cookie(self, expires=PURGE_AGE):
        assert self.sid, 'Session ID not set'
        self.req.outcookie[COOKIE_KEY] = self.sid
        self.req.outcookie[COOKIE_KEY]['path'] = self.req.base_path or '/'
        self.req.outcookie[COOKIE_KEY]['expires'] = expires
        if self.env.secure_cookies:
            self.req.outcookie[COOKIE_KEY]['secure'] = True

    def get_session(self, sid, authenticated=False):
        refresh_cookie = False

        if self.sid and sid != self.sid:
            refresh_cookie = True

        super(Session, self).get_session(sid, authenticated)
        if self.last_visit and time.time() - self.last_visit > UPDATE_INTERVAL:
            refresh_cookie = True

        # Refresh the session cookie if this is the first visit after a day
        if not authenticated and refresh_cookie:
            self.bake_cookie()

    def change_sid(self, new_sid):
        assert self.req.authname == 'anonymous', \
               'Cannot change ID of authenticated session'
        assert new_sid, 'Session ID cannot be empty'
        if new_sid == self.sid:
            return
        @self.env.with_transaction()
        def update_session_id(db):
            cursor = db.cursor()
            cursor.execute("SELECT sid FROM session WHERE sid=%s", (new_sid,))
            if cursor.fetchone():
                raise TracError(_("Session '%(id)s' already exists. "
                                  "Please choose a different session ID.",
                                  id=new_sid),
                                _("Error renaming session"))
            self.env.log.debug('Changing session ID %s to %s', self.sid,
                               new_sid)
            cursor.execute("""
                UPDATE session SET sid=%s WHERE sid=%s AND authenticated=0
                  """, (new_sid, self.sid))
            cursor.execute("""
                UPDATE session_attribute SET sid=%s 
                WHERE sid=%s and authenticated=0
                """, (new_sid, self.sid))
        self.sid = new_sid
        self.bake_cookie()

    def promote_session(self, sid):
        """Promotes an anonymous session to an authenticated session, if there
        is no preexisting session data for that user name.
        """
        assert self.req.authname != 'anonymous', \
               'Cannot promote session of anonymous user'

        @self.env.with_transaction()
        def update_session_id(db):
            cursor = db.cursor()
            cursor.execute("""
                SELECT authenticated FROM session WHERE sid=%s OR sid=%s
                """, (sid, self.req.authname))
            authenticated_flags = [row[0] for row in cursor.fetchall()]
            
            if len(authenticated_flags) == 2:
                # There's already an authenticated session for the user,
                # we simply delete the anonymous session
                cursor.execute("""
                    DELETE FROM session WHERE sid=%s AND authenticated=0
                    """, (sid,))
                cursor.execute("""
                    DELETE FROM session_attribute
                    WHERE sid=%s AND authenticated=0
                    """, (sid,))
            elif len(authenticated_flags) == 1:
                if not authenticated_flags[0]:
                    # Update the anomymous session records so the session ID
                    # becomes the user name, and set the authenticated flag.
                    self.env.log.debug('Promoting anonymous session %s to '
                                       'authenticated session for user %s',
                                       sid, self.req.authname)
                    cursor.execute("""
                        UPDATE session SET sid=%s,authenticated=1
                        WHERE sid=%s AND authenticated=0
                        """, (self.req.authname, sid))
                    cursor.execute("""
                        UPDATE session_attribute SET sid=%s,authenticated=1
                        WHERE sid=%s
                        """, (self.req.authname, sid))
            else:
                # We didn't have an anonymous session for this sid. The
                # authenticated session might have been inserted between the
                # SELECT above and here, so we catch the error.
                try:
                    cursor.execute("""
                        INSERT INTO session (sid,last_visit,authenticated)
                        VALUES (%s,%s,1)
                        """, (self.req.authname, int(time.time())))
                except Exception:
                    self.env.log.warning('Authenticated session for %s '
                                         'already exists', self.req.authname)
                    db.rollback()
        self._new = False

        self.sid = sid
        self.bake_cookie(0) # expire the cookie


class SessionAdmin(Component):
    """trac-admin command provider for session management"""

    implements(IAdminCommandProvider)

    def get_admin_commands(self):
        yield ('session list', '[sid[:0|1]] [...]',
               """List the name and email for the given sids

               Specifying the sid 'anonymous' lists all unauthenticated
               sessions, and 'authenticated' all authenticated sessions.
               '*' lists all sessions, and is the default if no sids are
               given.
               
               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default).""",
               self._complete_list, self._do_list)

        yield ('session add', '<sid[:0|1]> [name] [email]',
               """Create a session for the given sid

               Populates the name and email attributes for the given session.
               Adding a suffix ':0' to the sid makes the session
               unauthenticated, and a suffix ':1' makes it authenticated (the
               default if no suffix is specified).""",
               None, self._do_add)

        yield ('session set', '<name|email> <sid[:0|1]> <value>',
               """Set the name or email attribute of the given sid
               
               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default).""",
               self._complete_set, self._do_set)

        yield ('session delete', '<sid[:0|1]> [...]',
               """Delete the session of the specified sid

               An sid suffix ':0' operates on an unauthenticated session with
               the given sid, and a suffix ':1' on an authenticated session
               (the default). Specifying the sid 'anonymous' will delete all
               anonymous sessions.""",
               self._complete_delete, self._do_delete)

        yield ('session purge', '<age>',
               """Purge all anonymous sessions older than the given age

               Age may be specified as a relative time like "90 days ago", or
               in YYYYMMDD format.""",
               None, self._do_purge)

    def _split_sid(self, sid):
        if sid.endswith(':0'):
            return (sid[:-2], 0)
        elif sid.endswith(':1'):
            return (sid[:-2], 1)
        else:
            return (sid, 1)

    def _get_sids(self):
        db = self.env.get_db_cnx()
        cursor = db.cursor()
        cursor.execute("SELECT sid, authenticated FROM session")
        return ['%s:%d' % (sid, auth) for sid, auth in cursor]

    def _get_list(self, sids):
        all_anon = 'anonymous' in sids or '*' in sids
        all_auth = 'authenticated' in sids or '*' in sids
        sids = set(self._split_sid(sid) for sid in sids
                   if sid not in ('anonymous', 'authenticated', '*'))
        db = self.env.get_db_cnx()
        cursor = db.cursor()
        cursor.execute("""
            SELECT DISTINCT s.sid, s.authenticated, s.last_visit,
                            n.value, e.value
            FROM session AS s
              LEFT JOIN session_attribute AS n
                ON (n.sid=s.sid AND n.authenticated=s.authenticated
                    AND n.name='name')
              LEFT JOIN session_attribute AS e
                ON (e.sid=s.sid AND e.authenticated=s.authenticated
                    AND e.name='email')
            ORDER BY s.sid, s.authenticated
            """)
        for sid, authenticated, last_visit, name, email in cursor:
            if all_anon and not authenticated or all_auth and authenticated \
                    or (sid, authenticated) in sids:
                yield (sid, authenticated, last_visit, name, email)

    def _complete_list(self, args):
        all_sids = self._get_sids() + ['*', 'anonymous', 'authenticated']
        return set(all_sids) - set(args)

    def _complete_set(self, args):
        if len(args) == 1:
            return ['name', 'email']
        elif len(args) == 2:
            return self._get_sids()

    def _complete_delete(self, args):
        all_sids = self._get_sids() + ['anonymous']
        return set(all_sids) - set(args)

    def _do_list(self, *sids):
        if not sids:
            sids = ['*']
        print_table([(r[0], r[1], format_date(to_datetime(r[2]),
                                              console_date_format),
                      r[3], r[4])
                     for r in self._get_list(sids)],
                    [_('SID'), _('Auth'), _('Last Visit'), _('Name'),
                     _('Email')])
        
    def _do_add(self, sid, name=None, email=None):
        sid, authenticated = self._split_sid(sid)
        @self.env.with_transaction()
        def add_session(db):
            cursor = db.cursor()
            try:
                cursor.execute("INSERT INTO session VALUES (%s, %s, %s)",
                               (sid, authenticated, int(time.time())))
            except Exception:
                raise AdminCommandError(_("Session '%(sid)s' already exists",
                                          sid=sid))
            if name is not None:
                cursor.execute("""
                    INSERT INTO session_attribute VALUES (%s, %s, 'name', %s)
                    """, (sid, authenticated, name))
            if email is not None:
                cursor.execute("""
                    INSERT INTO session_attribute VALUES (%s, %s, 'email', %s)
                    """, (sid, authenticated, email))

    def _do_set(self, attr, sid, val):
        if attr not in ('name', 'email'):
            raise AdminCommandError(_("Invalid attribute '%(attr)s'",
                                      attr=attr))
        sid, authenticated = self._split_sid(sid)
        @self.env.with_transaction()
        def set_attr(db):
            cursor = db.cursor()
            cursor.execute("""
                SELECT sid FROM session WHERE sid=%s AND authenticated=%s
                """, (sid, authenticated))
            if not cursor.fetchone():
                raise AdminCommandError(_("Session '%(sid)s' not found",
                                          sid=sid))
            cursor.execute("""
                DELETE FROM session_attribute
                WHERE sid=%s AND authenticated=%s AND name=%s
                """, (sid, authenticated, attr))
            cursor.execute("""
                INSERT INTO session_attribute VALUES (%s, %s, %s, %s)
                """, (sid, authenticated, attr, val))

    def _do_delete(self, *sids):
        @self.env.with_transaction()
        def delete_session(db):
            cursor = db.cursor()
            for sid in sids:
                sid, authenticated = self._split_sid(sid)
                if sid == 'anonymous':
                    cursor.execute("""
                        DELETE FROM session WHERE authenticated=0
                        """)
                    cursor.execute("""
                        DELETE FROM session_attribute WHERE authenticated=0
                        """)
                else:
                    cursor.execute("""
                        DELETE FROM session
                        WHERE sid=%s AND authenticated=%s
                        """, (sid, authenticated))
                    cursor.execute("""
                        DELETE FROM session_attribute
                        WHERE sid=%s AND authenticated=%s
                        """, (sid, authenticated))

    def _do_purge(self, age):
        when = parse_date(age)
        @self.env.with_transaction()
        def purge_session(db):
            cursor = db.cursor()
            ts = to_timestamp(when)
            cursor.execute("""
                DELETE FROM session
                WHERE authenticated=0 AND last_visit<%s
                """, (ts,))
            cursor.execute("""
                DELETE FROM session_attribute
                WHERE authenticated=0
                      AND sid NOT IN (SELECT sid FROM session
                                      WHERE authenticated=0)
                """)