Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
Size: Mime:
# -*- coding: utf-8 -*-
# stdlib
import functools
import time

# pypi
from pyramid.exceptions import ConfigurationError

from redis import VERSION as redis_version  # since at least the 2.x branch

from webob.cookies import SignedSerializer

# local
from .compat import pickle
from .connection import get_default_connection
from .exceptions import InvalidSession, InvalidSession_NoSessionCookie
from .session import RedisSession
from .util import LAZYCREATE_SESSION
from .util import NotSpecified
from .util import _NullSerializer
from .util import _generate_session_id
from .util import _parse_settings
from .util import configs_bool  # not used here, but included for legacy
from .util import configs_dotable
from .util import create_unique_session_id
from .util import empty_session_payload
from .util import warn_future


__VERSION__ = "1.6.3"


# ==============================================================================


def check_response_allow_cookies(response):
    """
    reference implementation for ``func_check_response_allow_cookies``
    If view has set any of these response headers, do not add a session
    cookie on this response. This way views generating cacheable content,
    like images, can signal the downstream web server that this content
    is safe. Otherwise if we set a cookie on these responses it could
    result to user session leakage.
    """
    # The view signals this is cacheable response
    # and we should not stamp a session cookie on it
    cookieless_headers = ["expires", "cache-control"]
    for header in cookieless_headers:
        if header in response.headers:
            return False
    return True


def includeme(config):
    """
    This function is detected by Pyramid so that you can easily include
    `pyramid_session_redis` in your `main` method like so::

        config.include('pyramid_session_redis')

    Parameters:

    ``config``
    A Pyramid ``config.Configurator``
    """
    settings = config.registry.settings

    # special rule for converting dotted python paths to callables
    for option in configs_dotable:
        key = "redis.sessions.%s" % option
        if key in settings:
            settings[key] = config.maybe_dotted(settings[key])
    session_factory = session_factory_from_settings(settings)
    config.set_session_factory(session_factory)


def session_factory_from_settings(settings):
    """
    Convenience method to construct a ``RedisSessionFactory`` from Paste config
    settings. Only settings prefixed with "redis.sessions" will be inspected
    and, if needed, coerced to their appropriate types (for example, casting
    the ``timeout`` value as an `int`).

    Parameters:

    ``settings``
    A dict of Pyramid application settings
    """
    options = _parse_settings(settings)
    return RedisSessionFactory(**options)


def RedisSessionFactory(
    secret,
    timeout=1200,
    cookie_name="session",
    cookie_max_age=None,
    cookie_path="/",
    cookie_domain=None,
    cookie_secure=False,
    cookie_httponly=True,
    cookie_expires=None,
    cookie_comment=None,
    cookie_samesite=None,
    cookie_on_exception=True,
    url=None,
    host="localhost",
    port=6379,
    db=0,
    password=None,
    client_callable=None,
    serialize=pickle.dumps,
    deserialize=pickle.loads,
    id_generator=_generate_session_id,
    set_redis_ttl=True,
    set_redis_ttl_readheavy=None,
    detect_changes=True,
    deserialized_fails_new=None,
    func_check_response_allow_cookies=None,
    func_invalid_logger=None,
    timeout_trigger=None,
    python_expires=True,
    cookie_signer=None,
    socket_timeout=None,  # redis, deprecated
    connection_pool=None,  # redis, deprecated
    charset=None,  # redis, deprecated
    errors=None,  # redis, deprecated
    unix_socket_path=None,  # redis, deprecated
    redis_socket_timeout=None,
    redis_connection_pool=None,
    redis_encoding=None,
    redis_encoding_errors=None,
    redis_unix_socket_path=None,
):
    """
    Constructs and returns a session factory that will provide session data
    from a Redis server. The returned factory can be supplied as the
    ``session_factory`` argument of a :class:`pyramid.config.Configurator`
    constructor, or used as the ``session_factory`` argument of the
    :meth:`pyramid.config.Configurator.set_session_factory` method.

    Parameters:

    ``secret``
    A string which is used to sign the cookie.  As an alternate, you can set this
    to ``None`` and provide a ``cookie_signer`` argument.

    ``timeout``
    A number of seconds of inactivity before a session times out.
    If set to 0 or None, no timeout will occur or be managed in Redis or Python.

    ``cookie_name``
    The name of the cookie used for sessioning. Default: ``session``.
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``name``.

    ``cookie_max_age``
    The maximum age of the cookie used for sessioning (in seconds).
    Default: ``None`` (browser scope).
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``max_age``.

    ``cookie_path``
    The path used for the session cookie. Default: ``/``.
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``path``.

    ``cookie_domain``
    The domain used for the session cookie. Default: ``None`` (no domain).
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``domain``.

    ``cookie_secure``
    Boolean value; Default: ``False``.
    The 'secure' flag of the session cookie.
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``secure``.

    ``cookie_httponly``
    Boolean value; Default: ``True``.
    The 'httpOnly' flag of the session cookie.
    This is passed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``httponly``.

    ``cookie_expires``
    Default: ``None``.
    Passed to `WebOb.response.Response.set_cookie` as ``expires``.
    BEWARE: WebOb may be removing this in 1.9.

    ``cookie_comment``
    Default: ``None``.
    The 'comment' attribute of the session cookie.
    This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``comment``.
    If set to ``None`` or not specified, it will not be passed on.

    ``cookie_samesite``
    Default: ``None``.
    The 'SameSite' attribute of the session cookie.
    This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
    framework as ``samesite`` and **requires WebOb 1.8.0 or higher**.
    If set to ``None`` or not specified, it will not be passed on.
    Should only be ``"Strict"`` or ``"Lax"``.

    ``cookie_on_exception``
    Boolean value; Default: ``True``.
    If ``True``, set a session cookie even if an exception occurs
    while rendering a view.

    ``url``
    Default: ``None``.
    A connection string for a Redis server, in the format:
    redis://username:password@localhost:6379/0

    ``host``
    Default: ``localhost``.
    A string representing the IP of your Redis server.

    ``port``
    Default: ``6379``.
    An integer representing the port of your Redis server.

    ``db``
    Integer value; Default: ``0``
    An integer to select a specific database on your Redis server.

    ``password``
    Default: ``None``.
    A string password to connect to your Redis server/database if
    required.

    ``client_callable``
    Default: ``None``.
    A python callable that accepts a Pyramid `request` and Redis config options
    and returns a Redis client such as redis-py's `StrictRedis`.

    ``serialize``
    Default: ``pickle.dumps``. PY2=cPickle
    A function to serialize the session dict for storage in Redis.

    ``deserialize``
    Default: ``pickle.loads``. PY2=cPickle
    A function to deserialize the stored session data in Redis.

    ``id_generator``
    Default: private function that uses sha1 with the time and random elements
    to create a 40 character unique ID.
    A function to create a unique ID to be used as the session key when a
    session is first created.

    ``set_redis_ttl``
    Boolean value;  Default `True`.
    If set to ``True``, will set a TTL. If
    ``False`` will not set a TTL and assumes that Redis is configured as a
    least-recently-used cache [http://redis.io/topics/lru-cache] and will NOT
    send EXPIRY data of sessions to Redis (the value of `timeout` will be
    ignored if set). This does not require or imply that no ``timeout`` data is
    handled within the Python payload, it just determines if Redis will be
    involved with timeout logic.

    ``set_redis_ttl_readheavy``
     Boolean value; Default: ``None``.
     If ``True``, sets TTL data in Redis within
     a PIPELINE via GET+EXPIRE and supresses automatic TTL refresh during the deferred
     cleanup phase. If not ``True``, an EXPIRE is sent as a separate action during
     the deferred cleanup phase.  The optimized behavior improves performance on
     read-heavy operations, but may degrade performance on write-heavy operations.
     This requires a ``timeout`` and ``set_redis_ttl`` to be True; it is not
     compatible with ``timeout_trigger`` or ``python_expires``.

    ``detect_changes``
    Boolean value; Default: ``True``.
    If set to ``True``, will calculate nested changes after
    serialization to ensure persistence of nested data.

    ``deserialized_fails_new``
    Boolean value; Default: ``None``.
    If ``True`` will handle deserializtion errors
    by creating a new session.

    ``func_check_response_allow_cookies``
    Default: ``None``.
    A callable function that accepts a response, returning ``True`` if the
    cookie can be sent and ``False`` if it should not.
     An example callable is available in
    ``check_response_allow_cookies``, which checks for `expires` and
    `cache-control` cookies.

    ``func_invalid_logger``
    Default: ``None``.
    A callable function that expects a single argument of a raised
    `InvalidSession` exception. If not ``None``, this will be called so your
    application can monitor.

    ``timeout_trigger``
    Integer value; Default ``None``.
    If unset or ``0``, timeouts will be updated on
    every access by setting an EXPIRY in Redis and/or updating the ``expires``
    value in the  session payload.  If set to an INT, the updates will only be
    set once the threshold is crossed.

    ``python_expires``
    Boolean value; Default ``True``.
    If ``True``, allows for timeout logic to be
    tracked in Python.

    ``cookie_signer``
    Default: ``None``
    If specified,  ``secret`` must be ``None``.
    An object with two methods, ``loads`` and ``dumps``.
    The ``loads`` method should accept bytes and return a Python object.
    The ``dumps`` method should accept a Python object and return bytes.
    A ``ValueError`` should be raised for malformed inputs.

    ``redis_socket_timeout``
    Default: ``None``.
    Passthrough argument to the `StrictRedis` constructor.

    ``redis_connection_pool``
    Default: ``None``.
    Passthrough argument to the `StrictRedis` constructor.

    ``redis_encoding``
    Default: ``utf-8``.
    Passthrough argument to the `StrictRedis` constructor.

    ``redis_encoding_errors``
    Default: ``strict``.
    Passthrough argument to the `StrictRedis` constructor.

    ``redis_unix_socket_path``
    Default: ``None``.
    Passthrough argument to the `StrictRedis` constructor.

    ``socket_timeout``
    Default: ``None``.
    Deprecated passthrough argument to the `StrictRedis` constructor.
    Please upgrade to ``redis_socket_timeout``.

    ``connection_pool``
    Default: ``None``.
    Deprecated passthrough argument to the `StrictRedis` constructor.
    Please upgrade to ``redis_connection_pool``.

    ``charset``
    Default: ``utf-8``.
    Deprecated passthrough argument to the `StrictRedis` constructor.
    Please upgrade to ``redis_encoding``.

    ``errors``
    Default: ``strict``.
    Deprecated passthrough argument to the `StrictRedis` constructor.
    Please upgrade to ``redis_encoding_errors``.

    ``unix_socket_path``
    Default: ``None``.
    Deprecated passthrough argument to the `StrictRedis` constructor.
    Please upgrade to ``redis_unix_socket_path``.

    Given this example:

        timeout = 1200
        timeout_trigger = 600

    The following timeline would occur

        time | action | timeout | next threshold
        -----+--------+---------+--------------
           0 | CREATE | 1200    | 600
         599 |        | 1200    | 600
         600 | UPDATE | 1800    | 1200
         599 |        | 1800    | 1200
        1200 | UPDATE | 2400    | 1800

    The feature has the ability to significantly lower the amount of processing
    on Redis, however it means a session timeout expires after the last trigger
    and not the last usage.

    For example, with a timeout trigger of 10 minutes on a 1 hour session, if a
    user leaves the site at 49 minutes and returns at 61 minutes, the trigger
    will not have been made and the session will have expired.

    The following arguments are passed straight to the ``StrictRedis``
    constructor and allow you to further configure the Redis client::

        modern                 | deprecated
        -----------------------+--------------------
        redis_socket_timeout   | socket_timeout
        redis_connection_pool  | connection_pool
        redis_encoding         | charset
        redis_encoding_errors  | errors
        redis_unix_socket_path | unix_socket_path

    Users are encouraged to use the modern `redis_` namespace and not the
    deprecated legacy kwargs. Warnings will be emitted when deprecated kwargs
    are used. Submitting two equivalent kwargs will result in a ValueError being
    raised.
    """
    if timeout == 0:
        timeout = None

    if timeout_trigger and not python_expires:  # fix this
        python_expires = True

    # optimize a `TTL refresh` under certain conditions
    if set_redis_ttl_readheavy:
        if (not timeout) or (not set_redis_ttl):
            raise ValueError(
                "`set_redis_ttl_readheavy` requires a `timeout` and `set_redis_ttl`"
            )
        if timeout_trigger or python_expires:
            raise ValueError(
                "`set_redis_ttl_readheavy` is not compatible with `timeout_trigger` and `python_expires`"
            )
    optimize_redis_ttl = False

    _set_redis_ttl_onexit = False
    if (timeout and set_redis_ttl) and (
        not timeout_trigger and not python_expires and not set_redis_ttl_readheavy
    ):
        _set_redis_ttl_onexit = True

    # good for all factory() requests
    set_cookie_kwargs = {
        "max_age": cookie_max_age,
        "path": cookie_path,
        "domain": cookie_domain,
        "secure": cookie_secure,
        "httponly": cookie_httponly,
    }
    if cookie_comment is not None:
        set_cookie_kwargs["comment"] = cookie_comment
    if cookie_expires is not None:
        set_cookie_kwargs["expires"] = cookie_expires
    if cookie_samesite is not None:
        set_cookie_kwargs["samesite"] = cookie_samesite

    # handle redis deprecations
    if socket_timeout is not None:
        warn_future(
            "`socket_timeout` has been deprecated in favor of `redis_socket_timeout`"
        )
        if redis_socket_timeout:
            raise ValueError(
                "Submit only one of `redis_socket_timeout`, `socket_timeout`"
            )
    if connection_pool is not None:
        warn_future(
            "`connection_pool` has been deprecated in favor of `redis_connection_pool`"
        )
        if redis_connection_pool:
            raise ValueError(
                "Submit only one of `redis_connection_pool`, `connection_pool`"
            )
    if charset is not None:
        warn_future("`charset` has been deprecated in favor of `redis_encoding`")
        warn_future(
            "Redis removed the `charset` kwarg in release 4.0.0, in favor of "
            "the `encoding` kwarg. If needed, this library will attempt to "
            "invoke Redis with the specified `charset` as the `encoding` "
            "value, but this support is deprecated by this library as well. "
            "Please update your code to use `redis_encoding` instead of "
            "`charset`, which will passed to Redis as the `encoding` kwarg."
        )
        if redis_encoding:
            raise ValueError("Submit only one of `redis_encoding`, `charset`")
    if errors is not None:
        warn_future("`errors` has been deprecated in favor of `redis_encoding_errors`")
        warn_future(
            "Redis removed the `errors` kwarg in release 4.0.0, in favor of "
            "the `encoding_errors` kwarg. If needed, this library will attempt "
            "to invoke Redis with the specified `errors` as the "
            "`encoding_errors` value, but this support is deprecated by this "
            "library as well. Please update your code to use "
            "`redis_encoding_errors` instead of `errors`, which will passed to "
            "Redis as the `encoding_errors` kwarg."
        )
        if redis_encoding_errors:
            raise ValueError("Submit only one of `redis_encoding_errors`, `errors`")
    if unix_socket_path is not None:
        warn_future(
            "`unix_socket_path` has been deprecated in favor of `redis_unix_socket_path`"
        )
        if redis_unix_socket_path:
            raise ValueError(
                "Submit only one of `redis_unix_socket_path`, `unix_socket_path`"
            )

    # favor the new terms to the old.
    # black formats this horribly within the dict, so calculate here for legibility
    redis_socket_timeout = (
        redis_socket_timeout if redis_socket_timeout is not None else socket_timeout
    )
    redis_connection_pool = (
        redis_connection_pool if redis_connection_pool is not None else connection_pool
    )
    redis_unix_socket_path = (
        redis_unix_socket_path
        if redis_unix_socket_path is not None
        else unix_socket_path
    )

    # good for all factory() requests
    redis_options = dict(
        host=host,
        port=port,
        db=db,
        password=password,
        socket_timeout=redis_socket_timeout,
        connection_pool=redis_connection_pool,
        unix_socket_path=redis_unix_socket_path,
    )

    # accept newer encoding and encoding_errors args while
    # retaining backwards compatibility
    if redis_encoding is not None:
        redis_options["encoding"] = redis_encoding
    else:
        if redis_version[0] < 4:
            # legacy kwarg still supported; will trigger Redis warnings/errors
            redis_options["charset"] = "utf-8" if charset is None else charset
        else:
            # modern deprecation
            if charset is not None:
                redis_options["encoding"] = charset
    if redis_encoding_errors is not None:
        redis_options["encoding_errors"] = redis_encoding_errors
    else:
        if redis_version[0] < 4:
            # legacy kwarg still supported; will trigger Redis warnings/errors
            redis_options["errors"] = "strict" if errors is None else errors
        else:
            # modern deprecation
            if errors is not None:
                redis_options["encoding_errors"] = errors

    # good for all factory() requests
    new_payload_func = functools.partial(
        empty_session_payload, timeout=timeout, python_expires=python_expires
    )

    # good for all factory() requests
    delete_cookie_func = functools.partial(
        _delete_cookie,
        cookie_name=cookie_name,
        cookie_path=cookie_path,
        cookie_domain=cookie_domain,
    )

    _secret_cookiesigner = (secret, cookie_signer)
    if all(_secret_cookiesigner) or not any(_secret_cookiesigner):
        raise ValueError(
            "One, and only one, of `secret` and `cookie_signer` must be provided."
        )
    if secret is not None:
        # the second argument is the salt. customizing this would needlessly complicate integration
        cookie_signer = SignedSerializer(
            secret, "pyramid_session_redis.", "sha512", serializer=_NullSerializer()
        )

    def factory(request, new_session_id_func=create_unique_session_id):

        # an explicit client callable gets priority over the default
        redis_conn = (
            client_callable(request, **redis_options)
            if client_callable is not None
            else get_default_connection(request, url=url, **redis_options)
        )

        new_session_func = functools.partial(
            new_session_id_func,
            redis=redis_conn,
            timeout=timeout,
            serialize=serialize,
            generator=id_generator,
            set_redis_ttl=set_redis_ttl,
            # set_redis_ttl_readheavy=set_redis_ttl_readheavy,  # not needed on NEW
            # _set_redis_ttl_onexit=_set_redis_ttl_onexit,  # not needed on NEW
            new_payload_func=new_payload_func,
            python_expires=python_expires,
        )

        try:
            # attempt to retrieve a session_id from the cookie
            session_id = _get_session_id_from_cookie(
                request=request, cookie_name=cookie_name, cookie_signer=cookie_signer
            )
            if not session_id:
                raise InvalidSession_NoSessionCookie("No `session_id` in cookie.")
            session_cookie_was_valid = True
            session = RedisSession(
                redis=redis_conn,
                session_id=session_id,
                new=False,
                new_session=new_session_func,
                new_payload_func=new_payload_func,
                serialize=serialize,
                deserialize=deserialize,
                set_redis_ttl=set_redis_ttl,
                set_redis_ttl_readheavy=set_redis_ttl_readheavy,
                _set_redis_ttl_onexit=_set_redis_ttl_onexit,
                detect_changes=detect_changes,
                deserialized_fails_new=deserialized_fails_new,
                timeout_trigger=timeout_trigger,
                timeout=timeout,
                python_expires=python_expires,
            )
        except InvalidSession as e:
            if func_invalid_logger is not None:
                # send the instance for logging
                func_invalid_logger(request, e)
            session_id = LAZYCREATE_SESSION
            session_cookie_was_valid = False
            session = RedisSession(
                redis=redis_conn,
                session_id=session_id,
                new=True,
                new_session=new_session_func,
                new_payload_func=new_payload_func,
                serialize=serialize,
                deserialize=deserialize,
                set_redis_ttl=set_redis_ttl,
                # set_redis_ttl_readheavy=set_redis_ttl_readheavy,  # not needed on NEW
                # _set_redis_ttl_onexit=_set_redis_ttl_onexit,  # not needed on NEW
                detect_changes=detect_changes,
                timeout_trigger=timeout_trigger,
                timeout=timeout,
                python_expires=python_expires,
            )

        set_cookie_func = functools.partial(
            _set_cookie,
            session,
            cookie_signer=cookie_signer,
            cookie_name=cookie_name,
            **set_cookie_kwargs
        )
        cookie_callback = functools.partial(
            _cookie_callback,
            session,
            session_cookie_was_valid=session_cookie_was_valid,
            cookie_on_exception=cookie_on_exception,
            set_cookie_func=set_cookie_func,
            delete_cookie_func=delete_cookie_func,
            func_check_response_allow_cookies=func_check_response_allow_cookies,
        )
        request.add_response_callback(cookie_callback)
        request.add_finished_callback(session._deferred_callback)
        return session

    return factory


def _get_session_id_from_cookie(request, cookie_name, cookie_signer):
    """
    Attempts to retrieve and return a session ID from a session cookie in the
    current request. Returns None if the cookie isn't found or the value cannot
    be deserialized for any reason.
    """
    cookieval = request.cookies.get(cookie_name)
    if cookieval is not None:
        try:
            session_id = cookie_signer.loads(cookieval)
            return session_id
        except ValueError:
            pass

    return None


def _set_cookie(session, request, response, cookie_signer, cookie_name, **kwargs):
    """
    `session` is via functools.partial
    `request` and `response` are appended by add_response_callback
    """
    cookieval = cookie_signer.dumps(session.session_id)
    response.set_cookie(cookie_name, cookieval, **kwargs)


def _delete_cookie(response, cookie_name, cookie_path, cookie_domain):
    response.delete_cookie(cookie_name, path=cookie_path, domain=cookie_domain)


def _cookie_callback(
    session,
    request,
    response,
    session_cookie_was_valid,
    cookie_on_exception,
    set_cookie_func,
    delete_cookie_func,
    func_check_response_allow_cookies,
):
    """
    Response callback to set the appropriate Set-Cookie header.
    `session` is via functools.partial
    `request` and `response` are appended by add_response_callback
    """
    # `session._session_state` will not exist after `invalidate` and other methods
    # so we will sextract some info from it...
    please_recookie = None
    _override_args = {}
    if "_session_state" in session.__dict__:
        if session._session_state.please_recookie:
            please_recookie = True
            if session._session_state.cookie_expires != NotSpecified:
                _override_args["expires"] = session._session_state.cookie_expires
            if session._session_state.cookie_max_age != NotSpecified:
                _override_args["max_age"] = session._session_state.cookie_max_age
    if func_check_response_allow_cookies is not None:
        if not func_check_response_allow_cookies(response):
            # if we don't want to send cookies on this response,
            # we might not want to persist or refresh
            # session._session_state.dont_persist = True
            # session._session_state.dont_refresh = True
            return
    if session._invalidated:
        if session_cookie_was_valid:
            delete_cookie_func(response=response)
        return

    # helper function for multiple contexts
    def _set_cookie_and_response():
        set_cookie_func(request=request, response=response, **_override_args)

        # If we set a cookie we need to make sure that downstream
        # web servicess do not serve this response from a cache
        # for requests coming in with a different session cookie.
        # Otherwise we might leak sessions between users.
        varies = ("Cookie",)
        vary = set(response.vary if response.vary is not None else [])
        vary |= set(varies)
        response.vary = vary

    if session.new:
        if not session.session_id_safecheck:
            return
        if request.exception is None or cookie_on_exception is True:
            _set_cookie_and_response()
        elif session_cookie_was_valid:
            # We don't set a cookie for the new session here (as
            # cookie_on_exception is False and an exception was raised), but we
            # still need to delete the existing cookie for the session that the
            # request started with (as the session has now been invalidated).
            delete_cookie_func(response=response)
    else:
        if please_recookie:
            if request.exception is None or cookie_on_exception is True:
                _set_cookie_and_response()