1import logging
2import string
3from datetime import timedelta
4
5from plain import signing
6from plain.exceptions import SuspiciousOperation
7from plain.models import DatabaseError, IntegrityError, router, transaction
8from plain.runtime import settings
9from plain.utils import timezone
10from plain.utils.crypto import get_random_string
11
12
13class CreateError(Exception):
14 """
15 Used internally as a consistent exception type to catch from save (see the
16 docstring for SessionBase.save() for details).
17 """
18
19 pass
20
21
22class UpdateError(Exception):
23 """
24 Occurs if Plain tries to update a session that was deleted.
25 """
26
27 pass
28
29
30class SessionStore:
31 """
32 The actual session object that gets attached to a request,
33 backed by the underlying Session model for the storage.
34 """
35
36 __not_given = object()
37
38 def __init__(self, session_key=None):
39 self.session_key = session_key
40 self.accessed = False
41 self.modified = False
42
43 # Lazy import
44 from .models import Session
45
46 self._model = Session
47
48 def __contains__(self, key):
49 return key in self._session
50
51 def __getitem__(self, key):
52 return self._session[key]
53
54 def __setitem__(self, key, value):
55 self._session[key] = value
56 self.modified = True
57
58 def __delitem__(self, key):
59 del self._session[key]
60 self.modified = True
61
62 @property
63 def key_salt(self):
64 return "plain.sessions." + self.__class__.__qualname__
65
66 def get(self, key, default=None):
67 return self._session.get(key, default)
68
69 def pop(self, key, default=__not_given):
70 self.modified = self.modified or key in self._session
71 args = () if default is self.__not_given else (default,)
72 return self._session.pop(key, *args)
73
74 def setdefault(self, key, value):
75 if key in self._session:
76 return self._session[key]
77 else:
78 self.modified = True
79 self._session[key] = value
80 return value
81
82 def _encode(self, session_dict):
83 "Return the given session dictionary serialized and encoded as a string."
84 return signing.dumps(
85 session_dict,
86 salt=self.key_salt,
87 compress=True,
88 )
89
90 def _decode(self, session_data):
91 try:
92 return signing.loads(session_data, salt=self.key_salt)
93 except signing.BadSignature:
94 logger = logging.getLogger("plain.security.SuspiciousSession")
95 logger.warning("Session data corrupted")
96 except Exception:
97 # ValueError, unpickling exceptions. If any of these happen, just
98 # return an empty dictionary (an empty session).
99 pass
100 return {}
101
102 def update(self, dict_):
103 self._session.update(dict_)
104 self.modified = True
105
106 def has_key(self, key):
107 return key in self._session
108
109 def keys(self):
110 return self._session.keys()
111
112 def values(self):
113 return self._session.values()
114
115 def items(self):
116 return self._session.items()
117
118 def clear(self):
119 # To avoid unnecessary persistent storage accesses, we set up the
120 # internals directly (loading data wastes time, since we are going to
121 # set it to an empty dict anyway).
122 self._session_cache = {}
123 self.accessed = True
124 self.modified = True
125
126 def is_empty(self):
127 "Return True when there is no session_key and the session is empty."
128 try:
129 return not self.session_key and not self._session_cache
130 except AttributeError:
131 return True
132
133 def _get_new_session_key(self):
134 "Return session key that isn't being used."
135 while True:
136 session_key = get_random_string(32, string.ascii_lowercase + string.digits)
137 if not self._model.objects.filter(session_key=session_key).exists():
138 return session_key
139
140 def _get_or_create_session_key(self):
141 if self.session_key is None:
142 self.session_key = self._get_new_session_key()
143 return self.session_key
144
145 def _get_session(self, no_load=False):
146 """
147 Lazily load session from storage (unless "no_load" is True, when only
148 an empty dict is stored) and store it in the current instance.
149 """
150 self.accessed = True
151 try:
152 return self._session_cache
153 except AttributeError:
154 if self.session_key is None or no_load:
155 self._session_cache = {}
156 else:
157 self._session_cache = self._load()
158 return self._session_cache
159
160 _session = property(_get_session)
161
162 def flush(self):
163 """
164 Remove the current session data from the database and regenerate the
165 key.
166 """
167 self.clear()
168 try:
169 self._model.objects.get(session_key=self.session_key).delete()
170 except self._model.DoesNotExist:
171 pass
172 self.session_key = None
173
174 def cycle_key(self):
175 """
176 Create a new session key, while retaining the current session data.
177 """
178 data = self._session
179 key = self.session_key
180 self.create()
181 self._session_cache = data
182 if key:
183 try:
184 self._model.objects.get(session_key=key).delete()
185 except self._model.DoesNotExist:
186 pass
187
188 def _load(self):
189 try:
190 session = self._model.objects.get(
191 session_key=self.session_key, expires_at__gt=timezone.now()
192 )
193 except (self._model.DoesNotExist, SuspiciousOperation) as e:
194 if isinstance(e, SuspiciousOperation):
195 logger = logging.getLogger(f"plain.security.{e.__class__.__name__}")
196 logger.warning(str(e))
197 self.session_key = None
198 session = None
199
200 return self._decode(session.session_data) if session else {}
201
202 def create(self):
203 while True:
204 self.session_key = self._get_new_session_key()
205 try:
206 # Save immediately to ensure we have a unique entry in the
207 # database.
208 self.save(must_create=True)
209 except CreateError:
210 # Key wasn't unique. Try again.
211 continue
212 self.modified = True
213 return
214
215 def save(self, must_create=False):
216 """
217 Save the current session data to the database. If 'must_create' is
218 True, raise a database error if the saving operation doesn't create a
219 new entry (as opposed to possibly updating an existing entry).
220 """
221 if self.session_key is None:
222 return self.create()
223 data = self._get_session(no_load=must_create)
224
225 obj = self._model(
226 session_key=self._get_or_create_session_key(),
227 session_data=self._encode(data),
228 expires_at=timezone.now() + timedelta(seconds=settings.SESSION_COOKIE_AGE),
229 )
230
231 using = router.db_for_write(self._model, instance=obj)
232 try:
233 with transaction.atomic(using=using):
234 obj.save(
235 clean_and_validate=False,
236 force_insert=must_create,
237 force_update=not must_create,
238 using=using,
239 )
240 except IntegrityError:
241 if must_create:
242 raise CreateError
243 raise
244 except DatabaseError:
245 if not must_create:
246 raise UpdateError
247 raise