Plain is headed towards 1.0! Subscribe for development updates →

  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