From 09459eed561d98b66eba1a1b73a1e8ce5722dba0 Mon Sep 17 00:00:00 2001 From: Patrick Tombez Date: Mon, 6 May 2019 13:55:46 +0200 Subject: [PATCH] Add session_redis --- requirements.txt | 1 + session_redis/README.rst | 37 +++++++++++++ session_redis/__init__.py | 5 ++ session_redis/__openerp__.py | 19 +++++++ session_redis/http.py | 90 ++++++++++++++++++++++++++++++++ session_redis/models/__init__.py | 1 + session_redis/models/user.py | 19 +++++++ session_redis/session.py | 86 ++++++++++++++++++++++++++++++ 8 files changed, 258 insertions(+) create mode 100644 requirements.txt create mode 100644 session_redis/README.rst create mode 100644 session_redis/__init__.py create mode 100644 session_redis/__openerp__.py create mode 100644 session_redis/http.py create mode 100644 session_redis/models/__init__.py create mode 100644 session_redis/models/user.py create mode 100644 session_redis/session.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2318a9f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +redis==2.10.5 diff --git a/session_redis/README.rst b/session_redis/README.rst new file mode 100644 index 0000000..8a05b14 --- /dev/null +++ b/session_redis/README.rst @@ -0,0 +1,37 @@ +Sessions in Redis +================= + +This addon allows to store the web sessions in Redis. + +Configuration +------------- + +The storage of sessions in Redis is activated using environment variables. + +* ``ODOO_SESSION_REDIS`` has to be ``1`` or ``true`` +* ``ODOO_SESSION_REDIS_HOST`` is the redis hostname (default is ``localhost``) +* ``ODOO_SESSION_REDIS_PORT`` is the redis port (default is ``6379``) +* ``ODOO_SESSION_REDIS_PASSWORD`` is the password for the AUTH command + (optional) +* ``ODOO_SESSION_REDIS_PREFIX`` is the prefix for the session keys (optional) +* ``ODOO_SESSION_REDIS_EXPIRATION`` is the time in seconds before expiration of + the sessions (default is 7 days) + + +The keys are set to ``session:``. +When a prefix is defined, the keys are ``session::`` + +This addon must be added in the server wide addons with (``--load`` option): + +``--load=web,web_kanban,session_redis`` + +Limitations +----------- + +* The server has to be restarted in order for the sessions to be stored in + Redis. +* All the users will have to login again as their previous session will be + dropped. +* The addon monkey-patch ``openerp.http.Root.session_store`` with a custom + method when the Redis mode is active, so incompatibilities with other addons + is possible if they do the same. diff --git a/session_redis/__init__.py b/session_redis/__init__.py new file mode 100644 index 0000000..d3b5bfe --- /dev/null +++ b/session_redis/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import http +from . import session +from . import models diff --git a/session_redis/__openerp__.py b/session_redis/__openerp__.py new file mode 100644 index 0000000..eb040dc --- /dev/null +++ b/session_redis/__openerp__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + + +{'name': 'Sessions in Redis', + 'summary': 'Store web sessions in Redis', + 'version': '8.0.1.0.0', + 'author': 'Camptocamp,Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'category': 'Extra Tools', + 'depends': ['base'], + 'external_dependencies': { + 'python': ['redis'], + }, + 'website': 'http://www.camptocamp.com', + 'data': [], + 'installable': True, + } diff --git a/session_redis/http.py b/session_redis/http.py new file mode 100644 index 0000000..912fb92 --- /dev/null +++ b/session_redis/http.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import logging +import os + +from distutils.util import strtobool + +import openerp +from openerp import http +from openerp.tools.func import lazy_property + +from .session import RedisSessionStore + +_logger = logging.getLogger(__name__) + +try: + import redis + from redis.sentinel import Sentinel +except ImportError: + redis = None # noqa + _logger.debug("Cannot 'import redis'.") + + +def is_true(strval): + return bool(strtobool(strval or '0'.lower())) + + +sentinel_host = os.environ.get('ODOO_SESSION_REDIS_SENTINEL_HOST') +sentinel_master_name = os.environ.get( + 'ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME' +) +if sentinel_host and not sentinel_master_name: + raise Exception( + "ODOO_SESSION_REDIS_SENTINEL_MASTER_NAME must be defined " + "when using session_redis" + ) +sentinel_port = int(os.environ.get('ODOO_SESSION_REDIS_SENTINEL_PORT', 26379)) +host = os.environ.get('ODOO_SESSION_REDIS_HOST', 'localhost') +port = int(os.environ.get('ODOO_SESSION_REDIS_PORT', 6379)) +prefix = os.environ.get('ODOO_SESSION_REDIS_PREFIX') +password = os.environ.get('ODOO_SESSION_REDIS_PASSWORD') +expiration = os.environ.get('ODOO_SESSION_REDIS_EXPIRATION') + + +@lazy_property +def session_store(self): + if sentinel_host: + sentinel = Sentinel([(sentinel_host, sentinel_port)], + password=password) + redis_client = sentinel.master_for(sentinel_master_name) + else: + redis_client = redis.Redis(host=host, port=port, password=password) + return RedisSessionStore(redis=redis_client, prefix=prefix, + expiration=expiration, + session_class=http.OpenERPSession) + + +def session_gc(session_store): + """ Do not garbage collect the sessions + + Redis keys are automatically cleaned at the end of their + expiration. + """ + return + + +def purge_fs_sessions(path): + for fname in os.listdir(path): + path = os.path.join(path, fname) + try: + os.unlink(path) + except OSError: + pass + + +if is_true(os.environ.get('ODOO_SESSION_REDIS')): + if sentinel_host: + _logger.debug("HTTP sessions stored in Redis with prefix '%s'. " + "Using Sentinel on %s:%s", + sentinel_host, sentinel_port, prefix or '') + else: + _logger.debug("HTTP sessions stored in Redis with prefix '%s' on " + "%s:%s", host, port, prefix or '') + + http.Root.session_store = session_store + http.session_gc = session_gc + # clean the existing sessions on the file system + purge_fs_sessions(openerp.tools.config.session_dir) diff --git a/session_redis/models/__init__.py b/session_redis/models/__init__.py new file mode 100644 index 0000000..f9b61db --- /dev/null +++ b/session_redis/models/__init__.py @@ -0,0 +1 @@ +from . import user diff --git a/session_redis/models/user.py b/session_redis/models/user.py new file mode 100644 index 0000000..e652fd7 --- /dev/null +++ b/session_redis/models/user.py @@ -0,0 +1,19 @@ +from openerp import models, tools + + +class User(models.Model): + _inherit = 'res.users' + + @tools.ormcache('sid') + def _compute_session_token(self, sid): + """Make sure to return an unicode string. + + Odoo creates a session token using hexdigest Session which is str + but with redis we set the token from a dictionary of values passing + it in json format. When dumping values from json, we always get unicode + thus both are incompatible. + + The shortest path is to fix the output of the computed session by Odoo. + + """ + return unicode(super(User, self)._compute_session_token(sid)) diff --git a/session_redis/session.py b/session_redis/session.py new file mode 100644 index 0000000..dce8189 --- /dev/null +++ b/session_redis/session.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import json +import logging + +from werkzeug.contrib.sessions import SessionStore + +# this is equal to the duration of the session garbage collector in +# openerp.http.session_gc() +DEFAULT_SESSION_TIMEOUT = 60 * 60 * 24 * 7 # 7 days in seconds + +_logger = logging.getLogger(__name__) + + +class RedisSessionStore(SessionStore): + """ SessionStore that saves session to redis """ + + def __init__(self, redis, session_class=None, + prefix='', expiration=None): + super(RedisSessionStore, self).__init__(session_class=session_class) + self.redis = redis + if expiration is None: + self.expiration = DEFAULT_SESSION_TIMEOUT + else: + self.expiration = expiration + self.prefix = u'session:' + if prefix: + self.prefix = u'%s:%s:' % ( + self.prefix, prefix + ) + + def build_key(self, sid): + if isinstance(sid, unicode): + sid = sid.encode('utf-8') + return '%s%s' % (self.prefix, sid) + + def save(self, session): + key = self.build_key(session.sid) + + # allow to set a custom expiration for a session + # such as a very short one for monitoring requests + expiration = session.expiration or self.expiration + if _logger.isEnabledFor(logging.DEBUG): + if session.uid: + user_msg = "user '%s' (id: %s)" % ( + session.login, session.uid) + else: + user_msg = "anonymous user" + _logger.debug("saving session with key '%s' and " + "expiration of %s seconds for %s", + key, expiration, user_msg) + + if self.redis.set(key, json.dumps(dict(session))): + return self.redis.expire(key, expiration) + + def delete(self, session): + key = self.build_key(session.sid) + _logger.debug('deleting session with key %s', key) + return self.redis.delete(key) + + def get(self, sid): + if not self.is_valid_key(sid): + _logger.debug("session with invalid sid '%s' has been asked, " + "returning a new one", sid) + return self.new() + + key = self.build_key(sid) + saved = self.redis.get(key) + if not saved: + _logger.debug("session with non-existent key '%s' has been asked, " + "returning a new one", key) + return self.new() + try: + data = json.loads(saved) + except ValueError: + _logger.debug("session for key '%s' has been asked but its json " + "content could not be read, it has been reset", key) + data = {} + return self.session_class(data, sid, False) + + def list(self): + keys = self.redis.keys('%s*' % self.prefix) + _logger.debug("a listing redis keys has been called") + return [key[len(self.prefix):] for key in keys]