details: https://code.tryton.org/tryton/commit/375fb083e95a
branch: default
user: Nicolas Évrard <[email protected]>
date: Mon Mar 30 16:24:44 2026 +0200
description:
Use a cookie to store the session token
Closes #14412
diffstat:
modules/authentication_saml/routes.py | 17 ++-
sao/CHANGELOG | 1 +
sao/src/bus.js | 3 -
sao/src/rpc.js | 3 -
sao/src/sao.js | 3 +-
sao/src/session.js | 48 ++--------
trytond/CHANGELOG | 1 +
trytond/doc/topics/configuration.rst | 14 +++
trytond/trytond/protocols/dispatcher.py | 40 +++++++-
trytond/trytond/protocols/wrappers.py | 69 ++++++++++++++-
trytond/trytond/tests/test_wsgi.py | 145 +++++++++++++++++++++++++++++++-
trytond/trytond/wsgi.py | 8 +-
12 files changed, 284 insertions(+), 68 deletions(-)
diffs (663 lines):
diff -r e6004c09cc94 -r 375fb083e95a modules/authentication_saml/routes.py
--- a/modules/authentication_saml/routes.py Wed Mar 18 17:18:42 2026 +0100
+++ b/modules/authentication_saml/routes.py Mon Mar 30 16:24:44 2026 +0200
@@ -12,8 +12,8 @@
import trytond.config as config
from trytond.protocols.dispatcher import register_authentication_service
from trytond.protocols.wrappers import (
- Response, abort, allow_null_origin, exceptions, redirect, with_pool,
- with_transaction)
+ Response, abort, add_auth_cookies, allow_null_origin, exceptions, redirect,
+ with_pool, with_transaction)
from trytond.transaction import Transaction
from trytond.url import http_host
from trytond.wsgi import app
@@ -171,8 +171,17 @@
query.append(('database', pool.database_name))
query.append(('login', login))
query.append(('user_id', user_id))
- query.append(('session', session))
+ tryton_client = redirect_url.startswith('http://localhost:')
+ if tryton_client:
+ # Add the session as a parameter
+ # such that the Tryton client can retrieve it
+ query.append(('session', session))
query.append(('bus_url_host', bus_url_host if allow_subscribe else ''))
parts = list(parts)
parts[3] = urllib.parse.urlencode(query)
- return redirect(urllib.parse.urlunsplit(parts))
+ response = redirect(urllib.parse.urlunsplit(parts))
+ if not tryton_client:
+ # Do not set cookies for Tryton client
+ add_auth_cookies(
+ response, pool.database_name, login, str(user_id), session)
+ return response
diff -r e6004c09cc94 -r 375fb083e95a sao/CHANGELOG
--- a/sao/CHANGELOG Wed Mar 18 17:18:42 2026 +0100
+++ b/sao/CHANGELOG Mon Mar 30 16:24:44 2026 +0200
@@ -1,3 +1,4 @@
+* Use cookie to store session
* Move the logout entry and add a help entry to the notification menu
* Add visual hint on widget of modified field
* Add support for Python 3.14
diff -r e6004c09cc94 -r 375fb083e95a sao/src/bus.js
--- a/sao/src/bus.js Wed Mar 18 17:18:42 2026 +0100
+++ b/sao/src/bus.js Mon Mar 30 16:24:44 2026 +0200
@@ -25,9 +25,6 @@
let url = new URL(`${session.database}/bus`, session.bus_url_host);
Sao.Bus.last_message = last_message;
Sao.Bus.request = jQuery.ajax({
- headers: {
- Authorization: 'Session ' + session.get_auth(),
- },
contentType: 'application/json',
data: JSON.stringify({
last_message: last_message,
diff -r e6004c09cc94 -r 375fb083e95a sao/src/rpc.js
--- a/sao/src/rpc.js Wed Mar 18 17:18:42 2026 +0100
+++ b/sao/src/rpc.js Mon Mar 30 16:24:44 2026 +0200
@@ -171,9 +171,6 @@
jQuery.ajax({
'async': async,
- 'headers': {
- 'Authorization': 'Session ' + session.get_auth()
- },
'contentType': 'application/json',
'data': JSON.stringify(Sao.rpc.prepareObject({
'id': id_,
diff -r e6004c09cc94 -r 375fb083e95a sao/src/sao.js
--- a/sao/src/sao.js Wed Mar 18 17:18:42 2026 +0100
+++ b/sao/src/sao.js Mon Mar 30 16:24:44 2026 +0200
@@ -1201,13 +1201,12 @@
jQuery(document).ready(function() {
var url = new URL(window.location);
- if (url.searchParams.has('session')) {
+ if (url.searchParams.has('login_service')) {
var database = url.searchParams.get('database');
var session = {
login_service: url.searchParams.get('login_service'),
login: url.searchParams.get('login'),
user_id: parseInt(url.searchParams.get('user_id'), 10),
- session: url.searchParams.get('session'),
bus_url_host: url.searchParams.get('bus_url_host'),
};
if (url.searchParams.has('renew')) {
diff -r e6004c09cc94 -r 375fb083e95a sao/src/session.js
--- a/sao/src/session.js Wed Mar 18 17:18:42 2026 +0100
+++ b/sao/src/session.js Mon Mar 30 16:24:44 2026 +0200
@@ -3,16 +3,10 @@
(function() {
'use strict';
- //
https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa#Unicode_strings
- function utoa(str) {
- return window.btoa(unescape(encodeURIComponent(str)));
- }
-
Sao.Session = Sao.class_(Object, {
init: function(database, login) {
this.login_service = null;
this.user_id = null;
- this.session = null;
this.bus_url_host = null;
this.cache = new Cache();
this.prm = jQuery.when(); // renew promise
@@ -25,9 +19,6 @@
Sao.Session.current_session = this;
}
},
- get_auth: function() {
- return utoa(this.login + ':' + this.user_id + ':' + this.session);
- },
do_login: function(parameters) {
var dfd = jQuery.Deferred();
var login = this.login;
@@ -47,14 +38,12 @@
new Sao.Login(func, this).run().then(result => {
this.login = login;
this.user_id = result[0];
- this.session = result[1];
- this.bus_url_host = result[2];
+ this.bus_url_host = result[1];
this.store();
this.renew_device_cookie();
dfd.resolve();
}, () => {
this.user_id = null;
- this.session = null;
this.bus_url_host = null;
this.store();
dfd.reject();
@@ -62,29 +51,20 @@
return dfd.promise();
},
do_logout: function() {
- if (!(this.user_id && this.session)) {
+ if (!this.user_id) {
return jQuery.when();
}
- var args = {
- 'id': 0,
- 'method': 'common.db.logout',
- 'params': []
- };
var prm = jQuery.ajax({
- 'headers': {
- 'Authorization': 'Session ' + this.get_auth()
- },
'contentType': 'application/json',
- 'data': JSON.stringify(args),
+ 'data': JSON.stringify({}),
'dataType': 'json',
- 'url': '/' + this.database + '/',
+ 'url': `/${this.database}/session/logout`,
'type': 'post',
});
this.unstore();
this.database = null;
this.login = null;
this.user_id = null;
- this.session = null;
if (Sao.Session.current_session === this) {
Sao.Session.current_session = null;
}
@@ -141,7 +121,7 @@
sessionStorage.setItem('sao_context_' + this.database, context);
},
restore: function() {
- if (this.database && !this.session) {
+ if (this.database) {
var session_data = localStorage.getItem(
'sao_session_' + this.database);
if (session_data !== null) {
@@ -150,7 +130,6 @@
this.login_service = session_data.login_service;
this.login = session_data.login;
this.user_id = session_data.user_id;
- this.session = session_data.session;
this.bus_url_host = session_data.bus_url_host;
}
}
@@ -160,7 +139,6 @@
var session = {
'login': this.login,
'user_id': this.user_id,
- 'session': this.session,
'bus_url_host': this.bus_url_host,
};
session = JSON.stringify(session);
@@ -280,7 +258,7 @@
var database = database_url();
var session = new Sao.Session(database, null);
- if (session.session) {
+ if (session.user_id) {
dfd.resolve(session);
return dfd;
}
@@ -311,7 +289,7 @@
session.database = database;
session.login = login;
session.restore();
- (session.session ? jQuery.when() : session.do_login())
+ (session.user_id ? jQuery.when() : session.do_login())
.then(function() {
dialog.modal.modal('hide');
dfd.resolve(session);
@@ -348,7 +326,7 @@
session.database = database;
session.login = null;
session.restore();
- if (session.session) {
+ if (session.user_id) {
dfd.resolve(session);
dialog.modal.remove();
if (database_url() != database) {
@@ -421,7 +399,6 @@
return session.prm;
}
var dfd = jQuery.Deferred();
- session.session = null;
session.prm = dfd.promise();
if (!session.login_service) {
session.do_login().then(dfd.resolve, function() {
@@ -441,7 +418,7 @@
if (service_window.closed) {
window.clearInterval(timer);
session.restore();
- if (session.session) {
+ if (session.user_id) {
dfd.resolve();
} else {
Sao.logout();
@@ -472,17 +449,12 @@
'contentType': 'application/json',
'data': JSON.stringify(data),
'dataType': 'json',
- 'url': '/' + this.session.database + '/',
+ 'url': `/${this.session.database}/session/login`,
'type': 'post',
'complete': [function() {
Sao.common.processing.hide(timeoutID);
}]
};
- if (this.session.user_id && this.session.session) {
- args.headers = {
- 'Authorization': 'Session ' + this.session.get_auth()
- };
- }
var ajax_prm = jQuery.ajax(args);
var ajax_success = function(data) {
diff -r e6004c09cc94 -r 375fb083e95a trytond/CHANGELOG
--- a/trytond/CHANGELOG Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/CHANGELOG Mon Mar 30 16:24:44 2026 +0200
@@ -1,3 +1,4 @@
+* Add route for login / logout with cookie
* Add contextual ``_log`` to force logging events
* Add notify_user to ModelStorage
* Check button states when testing access
diff -r e6004c09cc94 -r 375fb083e95a trytond/doc/topics/configuration.rst
--- a/trytond/doc/topics/configuration.rst Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/doc/topics/configuration.rst Mon Mar 30 16:24:44 2026 +0200
@@ -668,6 +668,19 @@
Default: ``''``
+.. _config-session.cookie_domain:
+
+cookie_domain
+~~~~~~~~~~~~~
+
+The cookie `Domain attribute`_ set when the authentication completes.
+
+Default: ``''``
+
+.. note::
+ When accessing the server through ``localhost`` this option should be left
+ empty as cookies with the domain ``localhost`` are not strored by browsers.
+
.. _config-session.max_age:
max_age
@@ -952,3 +965,4 @@
.. _STARTTLS: http://en.wikipedia.org/wiki/STARTTLS
.. _WSGI middleware:
https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface#Specification_overview
.. _`multi-factor authentication`:
https://en.wikipedia.org/wiki/Multi-factor_authentication
+.. _`Domain attribute`:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#domaindomain-value
diff -r e6004c09cc94 -r 375fb083e95a trytond/trytond/protocols/dispatcher.py
--- a/trytond/trytond/protocols/dispatcher.py Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/trytond/protocols/dispatcher.py Mon Mar 30 16:24:44 2026 +0200
@@ -16,7 +16,9 @@
from trytond.worker import run_task
from trytond.wsgi import app
-from .wrappers import HTTPStatus, Response, abort, with_pool
+from .wrappers import (
+ TRYTON_SESSION_COOKIE, HTTPStatus, Response, abort, add_auth_cookies,
+ decode_session_cookie, remove_auth_cookies, with_pool)
__all__ = ['register_authentication_service']
@@ -68,6 +70,28 @@
context={'_request': request.context})
[email protected]('/<string:database_name>/session/login', methods=['POST'])
+def login_w_cookies(request, database_name):
+ user_id, token, bus_url_host = login(
+ request, database_name, *request.json['params'])
+ response = app.make_response(request, (user_id, bus_url_host))
+ add_auth_cookies(
+ response, database_name, request.json['params'][0], str(user_id),
+ token)
+ return response
+
+
[email protected]('/<string:database_name>/session/logout', methods=['POST'])
+def logout_w_cookies(request, database_name):
+ cookie = request.cookies.get(TRYTON_SESSION_COOKIE)
+ _, user_id, token = decode_session_cookie(cookie)
+ security.logout(
+ database_name, user_id, token, context={'_request': request.context})
+ response = app.make_response(request, None)
+ remove_auth_cookies(response, database_name)
+ return response
+
+
def reset_password(request, database_name, user, language=None):
authentications = config.get(
'session', 'authentications', default='password').split(',')
@@ -168,9 +192,14 @@
abort(HTTPStatus.FORBIDDEN)
user = request.user_id
- session = None
- if request.authorization.type == 'session':
- session = request.authorization.get('session')
+ if request.session:
+ username = request.session.username
+ session = request.session.token
+ elif request.authorization:
+ username = request.authorization.username
+ session = None
+ if isinstance(username, bytes):
+ username = username.decode('utf-8')
if rpc.fresh_session and session:
context = {'_request': request.context}
@@ -179,9 +208,6 @@
abort(HTTPStatus.UNAUTHORIZED)
log_message = '%s.%s%s from %s@%s%s in %i ms'
- username = request.authorization.username
- if isinstance(username, bytes):
- username = username.decode('utf-8')
log_args = (
obj.__name__, method,
format_args(args, kwargs, logger.isEnabledFor(logging.DEBUG)),
diff -r e6004c09cc94 -r 375fb083e95a trytond/trytond/protocols/wrappers.py
--- a/trytond/trytond/protocols/wrappers.py Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/trytond/protocols/wrappers.py Mon Mar 30 16:24:44 2026 +0200
@@ -1,6 +1,7 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import base64
+import collections
import gzip
import logging
import time
@@ -38,11 +39,51 @@
'user_application',
'with_pool',
'with_transaction',
+ 'encode_session_cookie',
+ 'decode_session_cookie',
+ 'add_auth_cookies',
+ 'remove_auth_cookies',
+ 'add_cookie',
+ 'remove_cookie',
+ 'TRYTON_SESSION_COOKIE',
]
+TRYTON_SESSION_COOKIE = 'tryton_session'
logger = logging.getLogger(__name__)
+def encode_session_cookie(login, userid, token):
+ return ':'.join((login, userid, token))
+
+
+def decode_session_cookie(cookie):
+ return cookie.rsplit(':', 2)
+
+
+def add_cookie(response, database, name, value):
+ response.set_cookie(name, value,
+ max_age=config.getint('session', 'max_age'),
+ path=f'/{database}',
+ domain=config.get('session', 'cookie_domain'),
+ secure=True, httponly=True, samesite='Strict')
+
+
+def add_auth_cookies(response, database, username, userid, token):
+ session_cookie = encode_session_cookie(username, userid, token)
+ add_cookie(response, database, TRYTON_SESSION_COOKIE, session_cookie)
+
+
+def remove_cookie(response, database, name):
+ response.set_cookie(
+ name, '', expires=0, path=f'/{database}',
+ domain=config.get('session', 'cookie_domain'),
+ secure=True, httponly=True, samesite='Strict')
+
+
+def remove_auth_cookies(response, database):
+ remove_cookie(response, database, TRYTON_SESSION_COOKIE)
+
+
class Request(_Request):
view_args = None
@@ -92,6 +133,25 @@
return
@cached_property
+ def session(self):
+ cookie = self.cookies.get(TRYTON_SESSION_COOKIE)
+ if cookie:
+ try:
+ username, userid, token = decode_session_cookie(cookie)
+ session = Session('cookie', username, int(userid), token)
+ except ValueError:
+ session = None
+ elif self.authorization and self.authorization.type == 'session':
+ session = Session(
+ 'authorization',
+ self.authorization.username,
+ int(self.authorization.get('userid')),
+ self.authorization.get('session'))
+ else:
+ session = None
+ return session
+
+ @cached_property
def authorization(self):
authorization = super().authorization
if authorization is None:
@@ -114,12 +174,12 @@
if not database_name:
return None
auth = self.authorization
- if not auth:
+ if not self.session and not auth:
return None
context = {'_request': self.context}
- if auth.type == 'session':
+ if self.session:
user_id = security.check(
- database_name, auth.get('userid'), auth.get('session'),
+ database_name, self.session.userid, self.session.token,
context=context)
elif auth.username:
parameters = getattr(auth, 'parameters', auth)
@@ -143,6 +203,9 @@
}
+Session = collections.namedtuple('Session', 'type username userid token')
+
+
def parse_authorization_header(value):
if not value:
return
diff -r e6004c09cc94 -r 375fb083e95a trytond/trytond/tests/test_wsgi.py
--- a/trytond/trytond/tests/test_wsgi.py Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/trytond/tests/test_wsgi.py Mon Mar 30 16:24:44 2026 +0200
@@ -10,9 +10,12 @@
from trytond import security
from trytond.exceptions import TrytonException
from trytond.pool import Pool
-from trytond.protocols.wrappers import Response
+from trytond.protocols.wrappers import (
+ TRYTON_SESSION_COOKIE, Response, decode_session_cookie,
+ encode_session_cookie)
from trytond.tests.test_tryton import Client, RouteTestCase, TestCase
-from trytond.wsgi import Base64Converter, TrytondWSGI
+from trytond.transaction import Transaction
+from trytond.wsgi import Base64Converter, TrytondWSGI, app
class WSGIAppTestCase(TestCase):
@@ -152,6 +155,32 @@
'password': '12345678',
}])
+ def test_basic_good_auth(self):
+ "Test that auth_required works with basic auth"
+ @app.route('/<database_name>/auth_required')
+ @app.auth_required
+ def _route(request, database_name):
+ return Response(b'')
+
+ basic_auth = 'Basic ' + base64.b64encode(b"user:12345678").decode()
+ response = self.client().get(
+ f'/{self.db_name}/auth_required',
+ headers=[('Authorization', basic_auth)])
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+
+ def test_basic_bad_auth(self):
+ "Test that auth_required don't accept wrong password with basic auth"
+ @app.route('/<database_name>/auth_required')
+ @app.auth_required
+ def _route(request, database_name):
+ return Response(b'')
+
+ basic_auth = 'Basic ' + base64.b64encode(b"1:Wrong Password").decode()
+ response = self.client().get(
+ f'/{self.db_name}/auth_required',
+ headers=[('Authorization', basic_auth)])
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+
def test_session_valid_good_auth(self):
"Test that session_valid correctly authenticates"
app = TrytondWSGI()
@@ -221,3 +250,115 @@
client = Client(app, Response)
response = client.get(f'/{self.db_name}/session_required')
self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+
+ def test_cookie_authentication_good_auth(self):
+ "Test that session_valid authenticates with the cookie"
+ @app.route('/<database_name>/session_required')
+ @app.session_valid
+ def _route(request, database_name):
+ return Response(b'')
+
+ user_id, key = security.login(
+ self.db_name, 'user', {'password': '12345678'})
+
+ client = self.client()
+ client.set_cookie(
+ TRYTON_SESSION_COOKIE,
+ encode_session_cookie('user', str(user_id), key),
+ path=f'/{self.db_name}')
+ response = client.get(f'/{self.db_name}/session_required')
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+
+ def test_cookie_authentication_bad_auth(self):
+ "Test that session_valid refuses wrong cookie content"
+ @app.route('/<database_name>/session_required')
+ @app.session_valid
+ def _route(request, database_name):
+ return Response(b'')
+
+ client = self.client()
+ client.set_cookie(
+ TRYTON_SESSION_COOKIE,
+ encode_session_cookie('user', '1', 'Wrong Token'),
+ path=f'/{self.db_name}')
+ response = client.get(f'/{self.db_name}/session_required')
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+
+ def test_cookie_login(self):
+ "Test logging in through the cookie setting route"
+ client = self.client()
+ client.post(f'/{self.db_name}/session/login', json={
+ 'method': 'common.db.login',
+ 'params': ['user', {'password': '12345678'}, 'en'],
+ })
+ session_cookie = client.get_cookie(
+ TRYTON_SESSION_COOKIE, path=f'/{self.db_name}').value
+ _, _, token = session_cookie.rsplit(':', 2)
+
+ with Transaction().start(self.db_name, 0):
+ pool = Pool()
+ Session = pool.get('ir.session')
+ sessions = Session.search([('key', '=', token)])
+ self.assertEqual(len(sessions), 1)
+
+ def test_cookie_logout(self):
+ "Test logging out through the cookie unsetting route"
+ client = self.client()
+ client.post(f'/{self.db_name}/session/login', json={
+ 'method': 'common.db.login',
+ 'params': ['user', {'password': '12345678'}, 'en'],
+ })
+ session_cookie = client.get_cookie(
+ TRYTON_SESSION_COOKIE, path=f'/{self.db_name}').value
+ _, _, token = decode_session_cookie(session_cookie)
+ client.post(f'/{self.db_name}/session/logout')
+
+ self.assertIsNone(
+ client.get_cookie(TRYTON_SESSION_COOKIE, path=f'/{self.db_name}'))
+ with Transaction().start(self.db_name, 0):
+ pool = Pool()
+ Session = pool.get('ir.session')
+ sessions = Session.search([('key', '=', token)])
+ self.assertEqual(len(sessions), 0)
+
+ def test_cookie_precedence_good_auth(self):
+ "Test the cookie have precedence over Authorization header"
+ @app.route('/<database_name>/session_required')
+ @app.session_valid
+ def _route(request, database_name):
+ return Response(b'')
+
+ user_id, key = security.login(
+ self.db_name, 'user', {'password': '12345678'})
+ client = self.client()
+ client.set_cookie(
+ TRYTON_SESSION_COOKIE,
+ encode_session_cookie('user', str(user_id), key),
+ path=f'/{self.db_name}')
+ session_hdr = 'Session ' + base64.b64encode(
+ f'user:{user_id}:Wrong Key'.encode('utf8')).decode('utf8')
+ response = client.get(
+ f'/{self.db_name}/session_required',
+ headers=[('Authorization', session_hdr)])
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+
+ def test_cookie_precedence_bad_auth(self):
+ "Test the cookie have precedence over Authorization header"
+ @app.route('/<database_name>/session_required')
+ @app.session_valid
+ def _route(request, database_name):
+ return Response(b'')
+
+ user_id, key = security.login(
+ self.db_name, 'user', {'password': '12345678'})
+ client = self.client()
+ client.set_cookie(
+ TRYTON_SESSION_COOKIE,
+ encode_session_cookie('user', str(user_id), 'Wrong Key'),
+ path=f'/{self.db_name}')
+ session_hdr = 'Session ' + base64.b64encode(
+ f'user:{user_id}:{key}'.encode('utf8')).decode('utf8')
+ response = client.get(
+ f'/{self.db_name}/session_required',
+ headers=[('Authorization', session_hdr)])
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
diff -r e6004c09cc94 -r 375fb083e95a trytond/trytond/wsgi.py
--- a/trytond/trytond/wsgi.py Wed Mar 18 17:18:42 2026 +0100
+++ b/trytond/trytond/wsgi.py Mon Mar 30 16:24:44 2026 +0200
@@ -95,15 +95,11 @@
def session_valid(self, func):
@wraps(func)
def wrapper(request, *args, **kwargs):
- if (not request.authorization
- or request.authorization.type != 'session'):
+ if request.session is None:
_do_basic_auth(request)
- userid = request.authorization.get('userid')
- session = request.authorization.get('session')
dbname = request.view_args.get('database_name')
-
session_check = security.check(
- dbname, userid, session, {
+ dbname, request.session.userid, request.session.token, {
'_request': {
'remote_addr': request.remote_addr,
},