/
session.py
518 lines (454 loc) · 21.8 KB
/
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
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
517
518
# -*- coding: utf-8 -*-
#
# Copyright (C) 2004-2014 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 re
import time
from trac.admin.api import AdminCommandError, IAdminCommandProvider, \
console_date_format, get_console_locale
from trac.core import Component, ExtensionPoint, TracError, implements
from trac.util import hex_entropy, lazy
from trac.util.datefmt import get_datetime_format_hint, format_date, \
parse_date, to_datetime, to_timestamp
from trac.util.text import print_table
from trac.util.translation import _
from trac.web.api import IRequestHandler, is_valid_default_handler
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 set(self, key, value, default=None):
"""Set a variable in the session, or remove it if it's equal to the
default value.
"""
value = unicode(value)
if default is not None:
default = unicode(default)
if value == default:
self.pop(key, None)
return
dict.__setitem__(self, key, 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 self._old.get('name') != self.get('name') or \
self._old.get('email') != self.get('email'):
self.env.invalidate_known_users_cache()
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 COOKIE_KEY not in req.incookie:
self.sid = hex_entropy(24)
self.bake_cookie()
else:
sid = req.incookie[COOKIE_KEY].value
self.get_session(sid)
else:
if COOKIE_KEY in req.incookie:
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
self.req.outcookie[COOKIE_KEY]['httponly'] = True
_valid_sid_re = re.compile(r'[_A-Za-z0-9]+\Z')
def get_session(self, sid, authenticated=False):
refresh_cookie = False
if not authenticated and not self._valid_sid_re.match(sid):
raise TracError(_("Session ID must be alphanumeric."))
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
if not self._valid_sid_re.match(new_sid):
raise TracError(_("Session ID must be alphanumeric."),
_("Error renaming session"))
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 anonymous 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)
request_handlers = ExtensionPoint(IRequestHandler)
def get_admin_commands(self):
hints = {
'datetime': get_datetime_format_hint(get_console_locale(self.env)),
'iso8601': get_datetime_format_hint('iso8601'),
}
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|default_handler> '
'<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 anonymous sessions older than the given age or date
Age may be specified as a relative time like "90 days ago", or
as a date in the "%(datetime)s" or "%(iso8601)s" (ISO 8601)
format.""" % hints,
None, self._do_purge)
@lazy
def _valid_default_handlers(self):
return sorted(handler.__class__.__name__
for handler in self.request_handlers
if is_valid_default_handler(handler))
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, h.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')
LEFT JOIN session_attribute AS h
ON (h.sid=s.sid AND h.authenticated=s.authenticated
AND h.name='default_handler')
ORDER BY s.sid, s.authenticated
""")
for sid, authenticated, last_visit, name, email, handler in rows:
if all_anon and not authenticated or all_auth and authenticated \
or (sid, authenticated) in sids:
yield (sid, authenticated,
format_date(to_datetime(last_visit),
console_date_format),
name, email, handler)
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 = ['*']
headers = (_("SID"), _("Auth"), _("Last Visit"), _("Name"),
_("Email"), _("Default Handler"))
print_table(self._get_list(sids), headers)
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))
self.env.invalidate_known_users_cache()
def _do_set(self, attr, sid, val):
if attr not in ('name', 'email', 'default_handler'):
raise AdminCommandError(_("Invalid attribute '%(attr)s'",
attr=attr))
if attr == 'default_handler':
if val and val not in self._valid_default_handlers:
raise AdminCommandError(_("Invalid default_handler '%(val)s'",
val=val))
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))
self.env.invalidate_known_users_cache()
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))
self.env.invalidate_known_users_cache()
def _do_purge(self, age):
when = parse_date(age, hint='datetime',
locale=get_console_locale(self.env))
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)
""")