Source

bloodhound-trac / trac / web / session.py

  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
# -*- 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>

from __future__ import with_statement

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)

        with self.env.db_query as db:
            self.sid = sid
            self.authenticated = authenticated
            self.clear()

            for last_visit, in db("""
                    SELECT last_visit FROM session
                    WHERE sid=%s AND authenticated=%s
                    """, (sid, int(authenticated))):
                self._new = False
                self.last_visit = int(last_visit or 0)
                self.update(db("""
                    SELECT name, value FROM session_attribute
                    WHERE sid=%s and authenticated=%s
                    """, (sid, int(authenticated))))
                self._old = self.copy()
                break
            else:
                self.last_visit = 0
                self._new = True
                self._old = {}

    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

        with self.env.db_transaction as db:
            # 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:
                    db("""INSERT INTO session (sid, last_visit, authenticated)
                          VALUES (%s,%s,%s)
                          """, (self.sid, self.last_visit, authenticated))
                except self.env.db_exc.IntegrityError:
                    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
                    db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                       (self.sid,))
                db("""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:
                    db.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 self.env.db_exc.IntegrityError:
                    self.env.log.warning('Attributes for session %s already '
                                         'updated', self.sid)
                    db.rollback()
                    return
                session_saved = True

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

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

            with self.env.db_transaction as db:
                # 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)
                db("""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.')
                db("""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
            with self.env.db_transaction as db:
                db("""
                    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
        with self.env.db_transaction as db:
            if db("SELECT sid FROM session WHERE sid=%s", (new_sid,)):
                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)
            db("UPDATE session SET sid=%s WHERE sid=%s AND authenticated=0",
               (new_sid, self.sid))
            db("""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"

        with self.env.db_transaction as db:
            authenticated_flags = [authenticated for authenticated, in db(
                "SELECT authenticated FROM session WHERE sid=%s OR sid=%s",
                (sid, self.req.authname))]
            
            if len(authenticated_flags) == 2:
                # There's already an authenticated session for the user,
                # we simply delete the anonymous session
                db("DELETE FROM session WHERE sid=%s AND authenticated=0",
                   (sid,))
                db("""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)
                    db("""UPDATE session SET sid=%s, authenticated=1
                          WHERE sid=%s AND authenticated=0
                          """, (self.req.authname, sid))
                    db("""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:
                    db("""INSERT INTO session (sid, last_visit, authenticated)
                          VALUES (%s, %s, 1)
                          """, (self.req.authname, int(time.time())))
                except self.env.db_exc.IntegrityError:
                    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):
        rows = self.env.db_query("SELECT sid, authenticated FROM session")
        return ['%s:%d' % (sid, auth) for sid, auth in rows]

    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', '*'))
        rows = self.env.db_query("""
            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 rows:
            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)
        with self.env.db_transaction as db:
            try:
                db("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:
                db("INSERT INTO session_attribute VALUES (%s,%s,'name',%s)",
                    (sid, authenticated, name))
            if email is not None:
                db("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)
        with self.env.db_transaction as db:
            if not db("""SELECT sid FROM session
                         WHERE sid=%s AND authenticated=%s""",
                         (sid, authenticated)):
                raise AdminCommandError(_("Session '%(sid)s' not found",
                                          sid=sid))
            db("""
                DELETE FROM session_attribute
                WHERE sid=%s AND authenticated=%s AND name=%s
                """, (sid, authenticated, attr))
            db("INSERT INTO session_attribute VALUES (%s, %s, %s, %s)",
               (sid, authenticated, attr, val))

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

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