1# -*- coding: utf-8 -*-
2r"""
3    werkzeug.contrib.sessions
4    ~~~~~~~~~~~~~~~~~~~~~~~~~
5
6    This module contains some helper classes that help one to add session
7    support to a python WSGI application.  For full client-side session
8    storage see :mod:`~werkzeug.contrib.securecookie` which implements a
9    secure, client-side session storage.
10
11
12    Application Integration
13    =======================
14
15    ::
16
17        from werkzeug.contrib.sessions import SessionMiddleware, \
18             FilesystemSessionStore
19
20        app = SessionMiddleware(app, FilesystemSessionStore())
21
22    The current session will then appear in the WSGI environment as
23    `werkzeug.session`.  However it's recommended to not use the middleware
24    but the stores directly in the application.  However for very simple
25    scripts a middleware for sessions could be sufficient.
26
27    This module does not implement methods or ways to check if a session is
28    expired.  That should be done by a cronjob and storage specific.  For
29    example to prune unused filesystem sessions one could check the modified
30    time of the files.  If sessions are stored in the database the new()
31    method should add an expiration timestamp for the session.
32
33    For better flexibility it's recommended to not use the middleware but the
34    store and session object directly in the application dispatching::
35
36        session_store = FilesystemSessionStore()
37
38        def application(environ, start_response):
39            request = Request(environ)
40            sid = request.cookies.get('cookie_name')
41            if sid is None:
42                request.session = session_store.new()
43            else:
44                request.session = session_store.get(sid)
45            response = get_the_response_object(request)
46            if request.session.should_save:
47                session_store.save(request.session)
48                response.set_cookie('cookie_name', request.session.sid)
49            return response(environ, start_response)
50
51    :copyright: 2007 Pallets
52    :license: BSD-3-Clause
53"""
54import os
55import re
56import tempfile
57import warnings
58from hashlib import sha1
59from os import path
60from pickle import dump
61from pickle import HIGHEST_PROTOCOL
62from pickle import load
63from random import random
64from time import time
65
66from .._compat import PY2
67from .._compat import text_type
68from ..datastructures import CallbackDict
69from ..filesystem import get_filesystem_encoding
70from ..http import dump_cookie
71from ..http import parse_cookie
72from ..posixemulation import rename
73from ..wsgi import ClosingIterator
74
75warnings.warn(
76    "'werkzeug.contrib.sessions' is deprecated as of version 0.15 and"
77    " will be removed in version 1.0. It has moved to"
78    " https://github.com/pallets/secure-cookie.",
79    DeprecationWarning,
80    stacklevel=2,
81)
82
83_sha1_re = re.compile(r"^[a-f0-9]{40}$")
84
85
86def _urandom():
87    if hasattr(os, "urandom"):
88        return os.urandom(30)
89    return text_type(random()).encode("ascii")
90
91
92def generate_key(salt=None):
93    if salt is None:
94        salt = repr(salt).encode("ascii")
95    return sha1(b"".join([salt, str(time()).encode("ascii"), _urandom()])).hexdigest()
96
97
98class ModificationTrackingDict(CallbackDict):
99    __slots__ = ("modified",)
100
101    def __init__(self, *args, **kwargs):
102        def on_update(self):
103            self.modified = True
104
105        self.modified = False
106        CallbackDict.__init__(self, on_update=on_update)
107        dict.update(self, *args, **kwargs)
108
109    def copy(self):
110        """Create a flat copy of the dict."""
111        missing = object()
112        result = object.__new__(self.__class__)
113        for name in self.__slots__:
114            val = getattr(self, name, missing)
115            if val is not missing:
116                setattr(result, name, val)
117        return result
118
119    def __copy__(self):
120        return self.copy()
121
122
123class Session(ModificationTrackingDict):
124    """Subclass of a dict that keeps track of direct object changes.  Changes
125    in mutable structures are not tracked, for those you have to set
126    `modified` to `True` by hand.
127    """
128
129    __slots__ = ModificationTrackingDict.__slots__ + ("sid", "new")
130
131    def __init__(self, data, sid, new=False):
132        ModificationTrackingDict.__init__(self, data)
133        self.sid = sid
134        self.new = new
135
136    def __repr__(self):
137        return "<%s %s%s>" % (
138            self.__class__.__name__,
139            dict.__repr__(self),
140            "*" if self.should_save else "",
141        )
142
143    @property
144    def should_save(self):
145        """True if the session should be saved.
146
147        .. versionchanged:: 0.6
148           By default the session is now only saved if the session is
149           modified, not if it is new like it was before.
150        """
151        return self.modified
152
153
154class SessionStore(object):
155    """Baseclass for all session stores.  The Werkzeug contrib module does not
156    implement any useful stores besides the filesystem store, application
157    developers are encouraged to create their own stores.
158
159    :param session_class: The session class to use.  Defaults to
160                          :class:`Session`.
161    """
162
163    def __init__(self, session_class=None):
164        if session_class is None:
165            session_class = Session
166        self.session_class = session_class
167
168    def is_valid_key(self, key):
169        """Check if a key has the correct format."""
170        return _sha1_re.match(key) is not None
171
172    def generate_key(self, salt=None):
173        """Simple function that generates a new session key."""
174        return generate_key(salt)
175
176    def new(self):
177        """Generate a new session."""
178        return self.session_class({}, self.generate_key(), True)
179
180    def save(self, session):
181        """Save a session."""
182
183    def save_if_modified(self, session):
184        """Save if a session class wants an update."""
185        if session.should_save:
186            self.save(session)
187
188    def delete(self, session):
189        """Delete a session."""
190
191    def get(self, sid):
192        """Get a session for this sid or a new session object.  This method
193        has to check if the session key is valid and create a new session if
194        that wasn't the case.
195        """
196        return self.session_class({}, sid, True)
197
198
199#: used for temporary files by the filesystem session store
200_fs_transaction_suffix = ".__wz_sess"
201
202
203class FilesystemSessionStore(SessionStore):
204    """Simple example session store that saves sessions on the filesystem.
205    This store works best on POSIX systems and Windows Vista / Windows
206    Server 2008 and newer.
207
208    .. versionchanged:: 0.6
209       `renew_missing` was added.  Previously this was considered `True`,
210       now the default changed to `False` and it can be explicitly
211       deactivated.
212
213    :param path: the path to the folder used for storing the sessions.
214                 If not provided the default temporary directory is used.
215    :param filename_template: a string template used to give the session
216                              a filename.  ``%s`` is replaced with the
217                              session id.
218    :param session_class: The session class to use.  Defaults to
219                          :class:`Session`.
220    :param renew_missing: set to `True` if you want the store to
221                          give the user a new sid if the session was
222                          not yet saved.
223    """
224
225    def __init__(
226        self,
227        path=None,
228        filename_template="werkzeug_%s.sess",
229        session_class=None,
230        renew_missing=False,
231        mode=0o644,
232    ):
233        SessionStore.__init__(self, session_class)
234        if path is None:
235            path = tempfile.gettempdir()
236        self.path = path
237        if isinstance(filename_template, text_type) and PY2:
238            filename_template = filename_template.encode(get_filesystem_encoding())
239        assert not filename_template.endswith(_fs_transaction_suffix), (
240            "filename templates may not end with %s" % _fs_transaction_suffix
241        )
242        self.filename_template = filename_template
243        self.renew_missing = renew_missing
244        self.mode = mode
245
246    def get_session_filename(self, sid):
247        # out of the box, this should be a strict ASCII subset but
248        # you might reconfigure the session object to have a more
249        # arbitrary string.
250        if isinstance(sid, text_type) and PY2:
251            sid = sid.encode(get_filesystem_encoding())
252        return path.join(self.path, self.filename_template % sid)
253
254    def save(self, session):
255        fn = self.get_session_filename(session.sid)
256        fd, tmp = tempfile.mkstemp(suffix=_fs_transaction_suffix, dir=self.path)
257        f = os.fdopen(fd, "wb")
258        try:
259            dump(dict(session), f, HIGHEST_PROTOCOL)
260        finally:
261            f.close()
262        try:
263            rename(tmp, fn)
264            os.chmod(fn, self.mode)
265        except (IOError, OSError):
266            pass
267
268    def delete(self, session):
269        fn = self.get_session_filename(session.sid)
270        try:
271            os.unlink(fn)
272        except OSError:
273            pass
274
275    def get(self, sid):
276        if not self.is_valid_key(sid):
277            return self.new()
278        try:
279            f = open(self.get_session_filename(sid), "rb")
280        except IOError:
281            if self.renew_missing:
282                return self.new()
283            data = {}
284        else:
285            try:
286                try:
287                    data = load(f)
288                except Exception:
289                    data = {}
290            finally:
291                f.close()
292        return self.session_class(data, sid, False)
293
294    def list(self):
295        """Lists all sessions in the store.
296
297        .. versionadded:: 0.6
298        """
299        before, after = self.filename_template.split("%s", 1)
300        filename_re = re.compile(
301            r"%s(.{5,})%s$" % (re.escape(before), re.escape(after))
302        )
303        result = []
304        for filename in os.listdir(self.path):
305            #: this is a session that is still being saved.
306            if filename.endswith(_fs_transaction_suffix):
307                continue
308            match = filename_re.match(filename)
309            if match is not None:
310                result.append(match.group(1))
311        return result
312
313
314class SessionMiddleware(object):
315    """A simple middleware that puts the session object of a store provided
316    into the WSGI environ.  It automatically sets cookies and restores
317    sessions.
318
319    However a middleware is not the preferred solution because it won't be as
320    fast as sessions managed by the application itself and will put a key into
321    the WSGI environment only relevant for the application which is against
322    the concept of WSGI.
323
324    The cookie parameters are the same as for the :func:`~dump_cookie`
325    function just prefixed with ``cookie_``.  Additionally `max_age` is
326    called `cookie_age` and not `cookie_max_age` because of backwards
327    compatibility.
328    """
329
330    def __init__(
331        self,
332        app,
333        store,
334        cookie_name="session_id",
335        cookie_age=None,
336        cookie_expires=None,
337        cookie_path="/",
338        cookie_domain=None,
339        cookie_secure=None,
340        cookie_httponly=False,
341        cookie_samesite="Lax",
342        environ_key="werkzeug.session",
343    ):
344        self.app = app
345        self.store = store
346        self.cookie_name = cookie_name
347        self.cookie_age = cookie_age
348        self.cookie_expires = cookie_expires
349        self.cookie_path = cookie_path
350        self.cookie_domain = cookie_domain
351        self.cookie_secure = cookie_secure
352        self.cookie_httponly = cookie_httponly
353        self.cookie_samesite = cookie_samesite
354        self.environ_key = environ_key
355
356    def __call__(self, environ, start_response):
357        cookie = parse_cookie(environ.get("HTTP_COOKIE", ""))
358        sid = cookie.get(self.cookie_name, None)
359        if sid is None:
360            session = self.store.new()
361        else:
362            session = self.store.get(sid)
363        environ[self.environ_key] = session
364
365        def injecting_start_response(status, headers, exc_info=None):
366            if session.should_save:
367                self.store.save(session)
368                headers.append(
369                    (
370                        "Set-Cookie",
371                        dump_cookie(
372                            self.cookie_name,
373                            session.sid,
374                            self.cookie_age,
375                            self.cookie_expires,
376                            self.cookie_path,
377                            self.cookie_domain,
378                            self.cookie_secure,
379                            self.cookie_httponly,
380                            samesite=self.cookie_samesite,
381                        ),
382                    )
383                )
384            return start_response(status, headers, exc_info)
385
386        return ClosingIterator(
387            self.app(environ, injecting_start_response),
388            lambda: self.store.save_if_modified(session),
389        )
390