From 56ff5d6848a9c7f0b162d159e16c7f647b7f3427 Mon Sep 17 00:00:00 2001 From: Patrick Tombez Date: Fri, 10 May 2019 11:13:05 +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 | 92 ++++++++++++++++++++++++++++++++ session_redis/models/__init__.py | 1 + session_redis/models/user.py | 24 +++++++++ session_redis/session.py | 78 +++++++++++++++++++++++++++ 8 files changed, 257 insertions(+) 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 index 9a957bb..b62181e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ boto==2.42.0 +redis==2.10.5 python-json-logger==0.1.5 python-swiftclient==3.4.0 python-keystoneclient==3.13.0 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..404f5e4 --- /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': '7.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..f0279e5 --- /dev/null +++ b/session_redis/http.py @@ -0,0 +1,92 @@ +# -*- 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.addons.web import http +from werkzeug.contrib.sessions import Session + +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') + + +def session_store(): + 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=Session) + + +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 '') + + store = session_store() + for handler in openerp.service.wsgi_server.module_handlers: + if hasattr(handler, 'session_store'): + handler.session_store = store + http.session_gc = session_gc + # clean the existing sessions on the file system + purge_fs_sessions(http.session_path()) 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..4597be4 --- /dev/null +++ b/session_redis/models/user.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from openerp.osv import osv +from openerp import tools + + +class User(osv.osv): + _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..701a300 --- /dev/null +++ b/session_redis/session.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from pickle import dumps, loads, HIGHEST_PROTOCOL +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) + + if _logger.isEnabledFor(logging.DEBUG): + _logger.debug("saving session with key '%s' and " + "expiration of %s seconds", + key, self.expiration) + + if self.redis.set(key, dumps(dict(session), HIGHEST_PROTOCOL)): + return self.redis.expire(key, self.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 = 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]