diff --git a/addons/auth_oauth/controllers/main.py b/addons/auth_oauth/controllers/main.py index 724cd978bc3..d57bb9e1d67 100644 --- a/addons/auth_oauth/controllers/main.py +++ b/addons/auth_oauth/controllers/main.py @@ -16,7 +16,7 @@ from odoo.http import request from odoo import registry as registry_get from odoo.addons.auth_signup.controllers.main import AuthSignupHome as Home -from odoo.addons.web.controllers.main import db_monodb, ensure_db, set_cookie_and_redirect, login_and_redirect +from odoo.addons.web.controllers.main import ensure_db, _get_login_redirect_url _logger = logging.getLogger(__name__) @@ -130,7 +130,7 @@ class OAuthController(http.Controller): with registry.cursor() as cr: try: env = api.Environment(cr, SUPERUSER_ID, context) - credentials = env['res.users'].sudo().auth_oauth(provider, kw) + db, login, key = env['res.users'].sudo().auth_oauth(provider, kw) cr.commit() action = state.get('a') menu = state.get('m') @@ -142,7 +142,11 @@ class OAuthController(http.Controller): url = '/web#action=%s' % action elif menu: url = '/web#menu_id=%s' % menu - resp = login_and_redirect(*credentials, redirect_url=url) + + pre_uid = request.session.authenticate(db, login, key) + resp = request.redirect(_get_login_redirect_url(pre_uid, url), 303) + resp.autocorrect_location_header = False + # Since /web is hardcoded, verify user has right to land on it if werkzeug.urls.url_parse(resp.location).path == '/web' and not request.env.user.has_group('base.group_user'): resp.location = '/' @@ -163,18 +167,20 @@ class OAuthController(http.Controller): _logger.exception("OAuth2: %s" % str(e)) url = "/web/login?oauth_error=2" - return set_cookie_and_redirect(url) + redirect = request.redirect(url, 303) + redirect.autocorrect_location_header = False + return redirect @http.route('/auth_oauth/oea', type='http', auth='none') def oea(self, **kw): """login user via Odoo Account provider""" dbname = kw.pop('db', None) if not dbname: - dbname = db_monodb() + dbname = request.db if not dbname: - return BadRequest() + raise BadRequest() if not http.db_filter([dbname]): - return BadRequest() + raise BadRequest() registry = registry_get(dbname) with registry.cursor() as cr: @@ -182,7 +188,9 @@ class OAuthController(http.Controller): env = api.Environment(cr, SUPERUSER_ID, {}) provider = env.ref('auth_oauth.provider_openerp') except ValueError: - return set_cookie_and_redirect('/web?db=%s' % dbname) + redirect = request.redirect(f'/web?db={dbname}', 303) + redirect.autocorrect_location_header = False + return redirect assert provider._name == 'auth.oauth.provider' state = { diff --git a/addons/auth_oauth/models/res_users.py b/addons/auth_oauth/models/res_users.py index 02a2bd97af8..56f78b8f535 100644 --- a/addons/auth_oauth/models/res_users.py +++ b/addons/auth_oauth/models/res_users.py @@ -80,7 +80,7 @@ class ResUsers(models.Model): token = state.get('t') values = self._generate_signup_values(provider, validation, params) try: - _, login, _ = self.signup(values, token) + login, _ = self.signup(values, token) return login except (SignupError, UserError): raise access_denied_exception diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index 803f107018b..5511bf15cfb 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -18,7 +18,7 @@ class AuthSignupHome(Home): @http.route() def web_login(self, *args, **kw): ensure_db() - response = super(AuthSignupHome, self).web_login(*args, **kw) + response = super().web_login(*args, **kw) response.qcontext.update(self.get_auth_signup_config()) if request.httprequest.method == 'GET' and request.session.uid and request.params.get('redirect'): # Redirect if already logged in and redirect param is present @@ -135,10 +135,10 @@ class AuthSignupHome(Home): request.env.cr.commit() def _signup_with_values(self, token, values): - db, login, password = request.env['res.users'].sudo().signup(values, token) + login, password = request.env['res.users'].sudo().signup(values, token) request.env.cr.commit() # as authenticate will use its own cursor we need to commit the current transaction - uid = request.session.authenticate(db, login, password) - if not uid: + pre_uid = request.session.authenticate(request.db, login, password) + if not pre_uid: raise SignupError(_('Authentication Failed.')) class AuthBaseSetup(BaseSetup): diff --git a/addons/auth_signup/models/ir_http.py b/addons/auth_signup/models/ir_http.py index a6840b670ec..0e0f08fb5b6 100644 --- a/addons/auth_signup/models/ir_http.py +++ b/addons/auth_signup/models/ir_http.py @@ -9,11 +9,11 @@ class Http(models.AbstractModel): _inherit = 'ir.http' @classmethod - def _dispatch(cls): - # add signup token or login to the session if given - if 'auth_signup_token' in request.params: - request.session['auth_signup_token'] = request.params['auth_signup_token'] - if 'auth_login' in request.params: - request.session['auth_login'] = request.params['auth_login'] + def _pre_dispatch(cls, rule, args): + super()._pre_dispatch(rule, args) - return super(Http, cls)._dispatch() + # add signup token or login to the session if given + for key in ('auth_signup_token', 'auth_login'): + val = request.httprequest.args.get(key) + if val is not None: + request.session[key] = val diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py index 557f75e5af9..8dbc67273dc 100644 --- a/addons/auth_signup/models/res_users.py +++ b/addons/auth_signup/models/res_users.py @@ -84,7 +84,7 @@ class ResUsers(models.Model): partner_user.write(values) if not partner_user.login_date: partner_user._notify_inviter() - return (self.env.cr.dbname, partner_user.login, values.get('password')) + return (partner_user.login, values.get('password')) else: # user does not exist: sign up invited user values.update({ @@ -102,7 +102,7 @@ class ResUsers(models.Model): values['email'] = values.get('email') or values.get('login') self._signup_create_user(values) - return (self.env.cr.dbname, values.get('login'), values.get('password')) + return (values.get('login'), values.get('password')) @api.model def _get_signup_invitation_scope(self): diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py index 71356088ba8..fecd2359c2c 100644 --- a/addons/auth_totp/controllers/home.py +++ b/addons/auth_totp/controllers/home.py @@ -32,7 +32,7 @@ class Home(odoo.addons.web.controllers.main.Home): if key: checked_credentials = request.env['auth_totp.device']._check_credentials(scope="browser", key=key) if checked_credentials == user.id: - request.session.finalize() + request.session.finalize(request.env) return request.redirect(self._login_redirect(request.session.uid, redirect=redirect)) elif user and request.httprequest.method == 'POST' and kwargs.get('totp_token'): @@ -44,7 +44,9 @@ class Home(odoo.addons.web.controllers.main.Home): except ValueError: error = _("Invalid authentication code format.") else: - request.session.finalize() + request.session.finalize(request.env) + request.update_env(user=request.session.uid) + request.update_context(**request.session.context) response = request.redirect(self._login_redirect(request.session.uid, redirect=redirect)) if kwargs.get('remember'): name = _("%(browser)s on %(platform)s", diff --git a/addons/auth_totp/tests/test_totp.py b/addons/auth_totp/tests/test_totp.py index f3874fddd81..4a52e1773a2 100644 --- a/addons/auth_totp/tests/test_totp.py +++ b/addons/auth_totp/tests/test_totp.py @@ -8,6 +8,7 @@ from odoo import http from odoo.exceptions import AccessDenied from odoo.service import common as auth, model from odoo.tests import tagged, HttpCase, get_db_name +from odoo.tools import mute_logger from ..controllers.home import Home @@ -86,10 +87,11 @@ class TestTOTP(HttpCase): self.start_tour('/web', 'totp_admin_disables', login='admin') self.start_tour('/', 'totp_login_disabled', login=None) + @mute_logger('odoo.http') def test_totp_authenticate(self): """ - Ensure that JSON-RPC authentication works and don't return the user id - without TOTP check + Ensure we don't leak the session info from an half-logged-in + user. """ self.start_tour('/web', 'totp_tour_setup', login='demo') @@ -112,4 +114,4 @@ class TestTOTP(HttpCase): } response = self.url_open("/web/session/authenticate", data=json.dumps(payload), headers=headers) data = response.json() - self.assertEqual(data['result']['uid'], None) + self.assertEqual(data['error']['data']['message'], "Reniewing an expired session for user that has multi-factor-authentication is not supported. Please use /web/login instead.") diff --git a/addons/base_import_module/controllers/main.py b/addons/base_import_module/controllers/main.py index 1671798c20c..c0e6b64da2b 100644 --- a/addons/base_import_module/controllers/main.py +++ b/addons/base_import_module/controllers/main.py @@ -5,33 +5,19 @@ from odoo import _ from odoo.exceptions import AccessError from odoo.http import Controller, route, request, Response -def webservice(f): - @functools.wraps(f) - def wrap(*args, **kw): - try: - return f(*args, **kw) - except Exception as e: - return Response(response=str(e), status=500) - return wrap - class ImportModule(Controller): - - def check_user(self, uid=None): - if uid is None: - uid = request.uid - is_admin = request.env['res.users'].browse(uid)._is_admin() - if not is_admin: - raise AccessError(_("Only administrators can upload a module")) - @route( '/base_import_module/login_upload', type='http', auth='none', methods=['POST'], csrf=False, save_session=False) - @webservice - def login_upload(self, login, password, db=None, force='', mod_file=None, **kw): - if db and db != request.db: - raise Exception(_("Could not select database '%s'", db)) - uid = request.session.authenticate(request.db, login, password) - self.check_user(uid) - force = True if force == '1' else False - return request.env['ir.module.module'].import_zipfile(mod_file, force=force)[0] + def login_upload(self, login, password, force='', mod_file=None, **kw): + try: + if not request.db: + raise Exception(_("Could not select database '%s'", request.db)) + request.session.authenticate(request.db, login, password) + # request.uid is None in case of MFA + if request.uid and request.env.user._is_admin(): + return request.env['ir.module.module'].import_zipfile(mod_file, force=force == '1')[0] + raise AccessError(_("Only administrators can upload a module")) + except Exception as e: + return Response(response=str(e), status=500) diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 5d22d1f4224..6f230c4db78 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 @@ -30,19 +29,21 @@ from werkzeug.urls import url_encode, url_parse, iri_to_uri import odoo import odoo.modules.registry +from odoo import http from odoo.api import call_kw -from odoo.addons.base.models.ir_qweb import render as qweb_render from odoo.modules import get_resource_path, module, get_manifest +from odoo.exceptions import AccessError, UserError, AccessDenied +from odoo.http import content_disposition, request +from odoo.models import check_method_name +from odoo.service import db, dispatch_rpc, security from odoo.tools import html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr, osutil from odoo.tools.mimetypes import guess_mimetype -from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlsxwriter, file_open, file_path from odoo.tools.safe_eval import safe_eval, time -from odoo import http -from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception -from odoo.exceptions import AccessError, UserError, AccessDenied -from odoo.models import check_method_name -from odoo.service import db, security +from odoo.tools.translate import _ + +from odoo.addons.base.models.ir_qweb import render as qweb_render + _logger = logging.getLogger(__name__) @@ -86,10 +87,6 @@ OPERATOR_MAPPING = { # Odoo Web helpers #---------------------------------------------------------- -db_list = http.db_list - -db_monodb = http.db_monodb - def clean(name): return name.replace('\x3c', '') def serialize_exception(f): @functools.wraps(f) @@ -98,7 +95,7 @@ def serialize_exception(f): return f(*args, **kwargs) except Exception as e: _logger.exception("An exception occurred during an http request") - se = _serialize_exception(e) + se = http.serialize_exception(e) error = { 'code': 200, 'message': "Odoo Server Error", @@ -107,11 +104,6 @@ def serialize_exception(f): return werkzeug.exceptions.InternalServerError(json.dumps(error)) return wrap -def abort_and_redirect(url): - response = request.redirect(url, 302) - response = http.root.get_response(request.httprequest, response, explicit_session=False) - werkzeug.exceptions.abort(response) - def ensure_db(redirect='/web/database/selector'): # This helper should be used in web client auth="none" routes # if those routes needs a db to work with. @@ -140,7 +132,7 @@ def ensure_db(redirect='/web/database/selector'): query_string = iri_to_uri(r.query_string) url_redirect = url_redirect.replace(query=query_string) request.session.db = db - abort_and_redirect(url_redirect.to_url()) + werkzeug.exceptions.abort(request.redirect(url_redirect.to_url(), 302)) # if db not provided, use the session one if not db and request.session.db and http.db_filter([request.session.db]): @@ -148,7 +140,9 @@ def ensure_db(redirect='/web/database/selector'): # if no database provided and no database in session, use monodb if not db: - db = db_monodb(request.httprequest) + all_dbs = http.db_list(force=True) + if len(all_dbs) == 1: + db = all_dbs[0] # if no db can be found til here, send to the database selector # the database selector will redirect to database manager if needed @@ -157,10 +151,10 @@ def ensure_db(redirect='/web/database/selector'): # always switch the session to the computed db if db != request.session.db: - request.session.logout() - abort_and_redirect(request.httprequest.url) - - request.session.db = db + request.session = http.root.session_store.new() + request.session.update(http.DEFAULT_SESSION, db=db) + request.session.context['lang'] = request.default_lang() + werkzeug.exceptions.abort(request.redirect(request.httprequest.url, 302)) def fs2web(path): """convert FS path into web path""" @@ -219,16 +213,6 @@ def _get_login_redirect_url(uid, redirect=None): qs['redirect'] = redirect return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url() -def login_and_redirect(db, login, key, redirect_url='/web'): - uid = request.session.authenticate(db, login, key) - redirect_url = _get_login_redirect_url(uid, redirect_url) - return set_cookie_and_redirect(redirect_url) - -def set_cookie_and_redirect(redirect_url): - redirect = request.redirect(redirect_url, 303) - redirect.autocorrect_location_header = False - return redirect - def clean_action(action, env): action_type = action.setdefault('type', 'ir.actions.act_window_close') if action_type == 'ir.actions.act_window': @@ -820,13 +804,21 @@ class Home(http.Controller): # ideally, this route should be `auth="user"` but that don't work in non-monodb mode. @http.route('/web', type='http', auth="none") def web_client(self, s_action=None, **kw): + + # Ensure we have both a database and a user ensure_db() if not request.session.uid: return request.redirect('/web/login', 303) if kw.get('redirect'): return request.redirect(kw.get('redirect'), 303) + if not security.check_session(request.session, request.env): + raise http.SessionExpiredException("Session expired") - request.uid = request.session.uid + # Side-effect, refresh the session lifetime + request.session.should_touch = True + + # Restore the user on the environment, it was lost due to auth="none" + request.update_env(user=request.session.uid) try: context = request.env['ir.http'].webclient_rendering_context() response = request.render('web.webclient_bootstrap', qcontext=context) @@ -862,8 +854,9 @@ class Home(http.Controller): if request.httprequest.method == 'GET' and redirect and request.session.uid: return request.redirect(redirect) + # so it is correct if overloaded with auth="public" if not request.uid: - request.uid = odoo.SUPERUSER_ID + request.update_env(user=odoo.SUPERUSER_ID) values = {k: v for k, v in request.params.items() if k in SIGN_UP_REQUEST_PARAMS} try: @@ -872,13 +865,11 @@ class Home(http.Controller): values['databases'] = None if request.httprequest.method == 'POST': - old_uid = request.uid try: - uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password']) + uid = request.session.authenticate(request.db, request.params['login'], request.params['password']) request.params['login_success'] = True return request.redirect(self._login_redirect(uid, redirect=redirect)) except odoo.exceptions.AccessDenied as e: - request.uid = old_uid if e.args == odoo.exceptions.AccessDenied().args: values['error'] = _("Wrong login/password") else: @@ -944,10 +935,8 @@ class WebClient(http.Controller): @http.route('/web/webclient/qweb/', type='http', auth="none", cors="*") def qweb(self, unique, mods=None, db=None, bundle=None): - if not request.db and mods is None: mods = odoo.conf.server_wide_modules or [] - content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug, bundle=bundle) return request.make_response(content, [ @@ -964,14 +953,12 @@ class WebClient(http.Controller): # For performance reasons we only load a single translation, so for # sub-languages (that should only be partially translated) we load the # main language PO instead - that should be enough for the login screen. - context = dict(request.context) - request.session._fix_lang(context) - lang = context['lang'].split('_')[0] + lang = request.env.context['lang'].partition('_')[0] if mods is None: mods = odoo.conf.server_wide_modules or [] if request.db: - mods = request.env.registry._init_modules | set(mods) + mods = request.env.registry._init_modules.union(mods) translations_per_module = {} for addon_name in mods: @@ -995,8 +982,6 @@ class WebClient(http.Controller): :param lang: the language of the user :return: """ - request.disable_db = False - if mods: mods = mods.split(',') elif mods is None: @@ -1047,23 +1032,6 @@ class WebClient(http.Controller): return request.make_response(data, [('Content-Type', 'application/json')]) -class Proxy(http.Controller): - - @http.route('/web/proxy/post/', type='http', auth='user', methods=['GET']) - def post(self, path): - """Effectively execute a POST request that was hooked through user login""" - with request.session.load_request_data() as data: - if not data: - raise werkzeug.exceptions.BadRequest() - from werkzeug.test import Client - from werkzeug.wrappers import BaseResponse - base_url = request.httprequest.base_url - query_string = request.httprequest.query_string - client = Client(http.root, BaseResponse) - headers = {'X-Openerp-Session-Id': request.session.sid} - return client.post('/' + path, base_url=base_url, query_string=query_string, - headers=headers, data=data) - class Database(http.Controller): def _render_template(self, **d): @@ -1074,14 +1042,11 @@ class Database(http.Controller): d['countries'] = odoo.service.db.exp_list_countries() d['pattern'] = DBNAME_PATTERN # databases list - d['databases'] = [] try: d['databases'] = http.db_list() d['incompatible_databases'] = odoo.service.db.list_db_incompatible(d['databases']) except odoo.exceptions.AccessDenied: - monodb = db_monodb() - if monodb: - d['databases'] = [monodb] + d['databases'] = [request.db] if request.db else [] templates = {} @@ -1100,12 +1065,14 @@ class Database(http.Controller): @http.route('/web/database/selector', type='http', auth="none") def selector(self, **kw): - request._cr = None + if request.db: + request.env.cr.close() return self._render_template(manage=False) @http.route('/web/database/manager', type='http', auth="none") def manager(self, **kw): - request._cr = None + if request.db: + request.env.cr.close() return self._render_template() @http.route('/web/database/create', type='http', auth="none", methods=['POST'], csrf=False) @@ -1120,8 +1087,10 @@ class Database(http.Controller): country_code = post.get('country_code') or False dispatch_rpc('db', 'create_database', [master_pwd, name, bool(post.get('demo')), lang, password, post['login'], country_code, post['phone']]) request.session.authenticate(name, post['login'], password) + request.session.db = name return request.redirect('/web') except Exception as e: + _logger.exception("Database creation error.") error = "Database creation error: %s" % (str(e) or repr(e)) return self._render_template(error=error) @@ -1134,9 +1103,11 @@ class Database(http.Controller): if not re.match(DBNAME_PATTERN, new_name): raise Exception(_('Invalid database name. Only alphanumerical characters, underscore, hyphen and dot are allowed.')) dispatch_rpc('db', 'duplicate_database', [master_pwd, name, new_name]) - request._cr = None # duplicating a database leads to an unusable cursor + if request.db == name: + request.env.cr.close() # duplicating a database leads to an unusable cursor return request.redirect('/web/database/manager') except Exception as e: + _logger.exception("Database duplication error.") error = "Database duplication error: %s" % (str(e) or repr(e)) return self._render_template(error=error) @@ -1146,16 +1117,14 @@ class Database(http.Controller): if insecure and master_pwd: dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd]) try: - dispatch_rpc('db','drop', [master_pwd, name]) - request._cr = None # dropping a database leads to an unusable cursor - # if the user is connected to the dropped database, redirect will raise an operational error - # trying to access the registry of this database. - # Removing db from this request will prevent an invalid error message. (logout looks like a good idea in this case) - # https://github.com/odoo/odoo/pull/54102 is proposing a complete fix for this issue. + dispatch_rpc('db', 'drop', [master_pwd, name]) + if request.db == name: + request.env.cr._closed = True # the underlying connection was closed if request.session.db == name: request.session.logout() return request.redirect('/web/database/manager') except Exception as e: + _logger.exception("Database deletion error.") error = "Database deletion error: %s" % (str(e) or repr(e)) return self._render_template(error=error) @@ -1219,17 +1188,23 @@ class Database(http.Controller): class Session(http.Controller): - @http.route('/web/session/get_session_info', type='json', auth="none") + @http.route('/web/session/get_session_info', type='json', auth="user") def get_session_info(self): - request.session.check_security() - request.uid = request.session.uid - request.disable_db = False return request.env['ir.http'].session_info() @http.route('/web/session/authenticate', type='json', auth="none") def authenticate(self, db, login, password, base_location=None): - request.session.authenticate(db, login, password) - return request.env['ir.http'].session_info() + if not http.db_filter([db]): + raise AccessError("Database not found.") + pre_uid = request.session.authenticate(db, login, password) + if pre_uid != request.session.uid: + raise AccessError("Reniewing an expired session for user that has multi-factor-authentication is not supported. Please use /web/login instead.") + + request.session.db = db + registry = odoo.modules.registry.Registry(db) + with registry.cursor() as cr: + env = odoo.api.Environment(cr, request.session.uid, request.session.context) + return env['ir.http'].session_info() @http.route('/web/session/change_password', type='json', auth="user") def change_password(self, fields): @@ -1262,39 +1237,11 @@ class Session(http.Controller): @http.route('/web/session/modules', type='json', auth="user") def modules(self): # return all installed modules. Web client is smart enough to not load a module twice - return list(request.env.registry._init_modules | set([module.current_test] if module.current_test else [])) - - @http.route('/web/session/save_session_action', type='json', auth="user") - def save_session_action(self, the_action): - """ - This method store an action object in the session object and returns an integer - identifying that action. The method get_session_action() can be used to get - back the action. - - :param the_action: The action to save in the session. - :type the_action: anything - :return: A key identifying the saved action. - :rtype: integer - """ - return request.session.save_action(the_action) - - @http.route('/web/session/get_session_action', type='json', auth="user") - def get_session_action(self, key): - """ - Gets back a previously saved action. This method can return None if the action - was saved since too much time (this case should be handled in a smart way). - - :param key: The key given by save_session_action() - :type key: integer - :return: The saved action or None. - :rtype: anything - """ - return request.session.get_action(key) + return list(request.env.registry._init_modules.union([module.current_test] if module.current_test else [])) @http.route('/web/session/check', type='json', auth="user") def check(self): - request.session.check_security() - return None + return # ir.http@_authenticate does the job @http.route('/web/session/account', type='json', auth="user") def account(self): @@ -1316,7 +1263,6 @@ class Session(http.Controller): request.session.logout(keep_db=True) return request.redirect(redirect, 303) - class DataSet(http.Controller): @http.route('/web/dataset/search_read', type='json', auth="user") @@ -1538,18 +1484,11 @@ class Binary(http.Controller): imgname = 'logo' imgext = '.png' placeholder = functools.partial(get_resource_path, 'web', 'static', 'img') - uid = None - if request.session.db: - dbname = request.session.db - uid = request.session.uid - elif dbname is None: - dbname = db_monodb() - - if not uid: - uid = odoo.SUPERUSER_ID + dbname = request.db + uid = (request.session.uid if dbname else None) or odoo.SUPERUSER_ID if not dbname: - response = http.send_file(placeholder(imgname + imgext)) + response = http.send_filepath(placeholder(imgname + imgext)) else: try: # create an empty registry @@ -1578,7 +1517,7 @@ class Binary(http.Controller): imgext = '.svg' response = http.send_file(image_data, filename=imgname + imgext, mimetype=mimetype, mtime=row[1]) else: - response = http.send_file(placeholder('nologo.png')) + response = http.send_filepath(placeholder('nologo.png')) except Exception: response = http.send_file(placeholder(imgname + imgext)) @@ -1628,13 +1567,11 @@ class Action(http.Controller): base_action = Actions.browse([action_id]).sudo().read(['type']) if base_action: - ctx = dict(request.context) action_type = base_action[0]['type'] if action_type == 'ir.actions.report': - ctx.update({'bin_size': True}) + request.update_context(bin_size=True) if additional_context: - ctx.update(additional_context) - request.context = ctx + request.update_context(**additional_context) action = request.env[action_type].sudo().browse([action_id]).read() if action: value = clean_action(action[0], env=request.env) @@ -1937,7 +1874,6 @@ class ExcelExport(ExportFormat, http.Controller): return xlsx_writer.value - class ReportController(http.Controller): #------------------------------------------------------ @@ -2007,7 +1943,7 @@ class ReportController(http.Controller): return request.make_response(barcode, headers=[('Content-Type', 'image/png')]) @http.route(['/report/download'], type='http', auth="user") - def report_download(self, data, context=None): + def report_download(self, data, context=None, token=None): # pylint: disable=unused-argument """This function is used by 'action_manager_report.js' in order to trigger the download of a pdf/controller report. @@ -2057,7 +1993,7 @@ class ReportController(http.Controller): return except Exception as e: _logger.exception("Error while generating report %s", reportname) - se = _serialize_exception(e) + se = http.serialize_exception(e) error = { 'code': 200, 'message': "Odoo Server Error", diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 3bb58fce05b..05598afc804 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -1,18 +1,37 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import hashlib import json +import logging import odoo from odoo import api, http, models from odoo.http import request from odoo.tools import file_open, image_process, ustr - +from odoo.tools.misc import str2bool from odoo.addons.web.controllers.main import HomeStaticTemplateHelpers +_logger = logging.getLogger(__name__) + +""" +Debug mode is stored in session and should always be a string. +It can be activated with an URL query string `debug=` where mode +is either: +- 'tests' to load tests assets +- 'assets' to load assets non minified +- any other truthy value to enable simple debug mode (to show some + technical feature, to show complete traceback in frontend error..) +- any falsy value to disable debug mode + +You can use any truthy/falsy value from `str2bool` (eg: 'on', 'f'..) +Multiple debug modes can be activated simultaneously, separated with a +comma (eg: 'tests, assets'). +""" +ALLOWED_DEBUG_MODES = ['', '1', 'assets', 'tests'] + + class Http(models.AbstractModel): _inherit = 'ir.http' @@ -25,6 +44,18 @@ class Http(models.AbstractModel): # timeit has been done to check the optimum method return any(bot in user_agent for bot in cls.bots) + @classmethod + def _pre_dispatch(cls, rule, args): + super()._pre_dispatch(rule, args) + debug = request.httprequest.args.get('debug') + if debug is not None: + request.session.debug = ','.join( + mode if mode in ALLOWED_DEBUG_MODES + else '1' if str2bool(mode, mode) + else '' + for mode in (debug or '1').split(',') + ) + def webclient_rendering_context(self): return { 'menu_data': request.env['ir.ui.menu'].load_menus(request.session.debug), @@ -33,24 +64,28 @@ class Http(models.AbstractModel): def session_info(self): user = request.env.user + session_uid = request.session.uid version_info = odoo.service.common.exp_version() - session_uid = request.session.uid - user_context = request.session.get_context() if session_uid else {} + if session_uid: + user_context = dict(self.env['res.users'].context_get()) + if user_context != request.session.context: + request.session.context = user_context + else: + user_context = {} + IrConfigSudo = self.env['ir.config_parameter'].sudo() max_file_upload_size = int(IrConfigSudo.get_param( 'web.max_file_upload_size', default=128 * 1024 * 1024, # 128MiB )) mods = odoo.conf.server_wide_modules or [] - lang = user_context.get("lang") - translation_hash = request.env['ir.translation'].sudo().get_web_translations_hash(mods, lang) session_info = { "uid": session_uid, "is_system": user._is_system() if session_uid else False, "is_admin": user._is_admin() if session_uid else False, "user_context": user_context, - "db": request.session.db, + "db": request.db, "server_version": version_info.get('server_version'), "server_version_info": version_info.get('server_version_info'), "support_url": "https://www.odoo.com/buy", @@ -67,7 +102,9 @@ class Http(models.AbstractModel): "max_file_upload_size": max_file_upload_size, "home_action_id": user.action_id.id, "cache_hashes": { - "translations": translation_hash, + "translations": request.env['ir.translation'].sudo().get_web_translations_hash( + mods, request.session.context['lang'] + ) if session_uid else None, }, "currencies": self.sudo().get_currencies(), } @@ -105,18 +142,20 @@ class Http(models.AbstractModel): @api.model def get_frontend_session_info(self): + user = self.env.user + session_uid = request.session.uid session_info = { - 'is_admin': request.session.uid and self.env.user._is_admin() or False, - 'is_system': request.session.uid and self.env.user._is_system() or False, - 'is_website_user': request.session.uid and self.env.user._is_public() or False, - 'user_id': request.session.uid and self.env.user.id or False, + 'is_admin': user._is_admin() if session_uid else False, + 'is_system': user._is_system() if session_uid else False, + 'is_website_user': user._is_public() if session_uid else False, + 'user_id': user.id if session_uid else False, 'is_frontend': True, 'profile_session': request.session.profile_session, 'profile_collectors': request.session.profile_collectors, 'profile_params': request.session.profile_params, 'show_effect': bool(request.env['ir.config_parameter'].sudo().get_param('base_setup.show_effect')), } - if request.session.uid: + if session_uid: version_info = odoo.service.common.exp_version() session_info.update({ 'server_version': version_info.get('server_version'), diff --git a/addons/web/static/src/legacy/js/core/translation.js b/addons/web/static/src/legacy/js/core/translation.js index f0aa3bcc789..5adb9a5b9a7 100644 --- a/addons/web/static/src/legacy/js/core/translation.js +++ b/addons/web/static/src/legacy/js/core/translation.js @@ -69,7 +69,7 @@ var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# url += '/' + (cacheId ? cacheId : Date.now()); const paramsGet = {}; if (modules) { - paramsGet.modules = modules.join(','); + paramsGet.mods = modules.join(','); } if (lang) { paramsGet.lang = lang; diff --git a/addons/web/tests/test_health.py b/addons/web/tests/test_health.py index cec042b12d0..4ba4b5ba7fa 100644 --- a/addons/web/tests/test_health.py +++ b/addons/web/tests/test_health.py @@ -9,4 +9,4 @@ class TestWebController(HttpCase): self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload['status'], 'pass') - self.assertNotIn('session_id', response.cookies) + self.assertFalse(response.cookies.get('session_id')) diff --git a/addons/web/tests/test_profiler.py b/addons/web/tests/test_profiler.py index 0a5a82a39cd..7ef1b3b5f3b 100644 --- a/addons/web/tests/test_profiler.py +++ b/addons/web/tests/test_profiler.py @@ -24,7 +24,7 @@ class ProfilingHttpCase(HttpCase): def profile_rpc(self, params=None): params = params or {} - return self.url_open( + req = self.url_open( '/web/dataset/call_kw/ir.profile/set_profiling', # use model and method in route has web client does headers={'Content-Type': 'application/json'}, data=json.dumps({'params':{ @@ -33,12 +33,13 @@ class ProfilingHttpCase(HttpCase): 'args': [], 'kwargs': params, }}) - ).json() + ) + req.raise_for_status() + return req.json() @tagged('post_install', '-at_install', 'profiling') class TestProfilingWeb(ProfilingHttpCase): - @mute_logger('odoo.http') def test_profiling_enabled(self): # since profiling will use a direct connection to the database patch 'db_connect' to ensure we are using the test cursor self.authenticate('admin', 'admin') @@ -64,7 +65,6 @@ class TestProfilingWeb(ProfilingHttpCase): @tagged('post_install', '-at_install', 'profiling') class TestProfilingModes(ProfilingHttpCase): - @mute_logger('odoo.http') def test_profile_collectors(self): expiration = datetime.datetime.now() + datetime.timedelta(seconds=50) self.env['ir.config_parameter'].set_param('base.profiling_enabled_until', expiration) diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 700f314a8ee..2cd55dfbf25 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -16,6 +16,7 @@ from hashlib import sha256 from itertools import chain, repeat from markupsafe import Markup +import babel.core import decorator import pytz from lxml import etree @@ -26,7 +27,7 @@ from psycopg2 import sql from odoo import api, fields, models, tools, SUPERUSER_ID, _, Command from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationError -from odoo.http import request +from odoo.http import request, DEFAULT_LANG from odoo.osv import expression from odoo.service.db import check_super from odoo.tools import is_html_empty, partition, collections, frozendict, lazy_property @@ -646,10 +647,23 @@ class Users(models.Model): # use read() to not read other fields: this must work while modifying # the schema of models res.users or res.partner values = user.read(list(name_to_key), load=False)[0] - return frozendict({ + + context = { key: values[name] for name, key in name_to_key.items() - }) + } + + # ensure the language is set and is compatible with the web client + lang = context.get('lang') or (request and request.default_lang()) or DEFAULT_LANG + if lang == 'ar_AR': + context['lang'] = 'ar' + if lang in babel.core.LOCALE_ALIASES: + context['lang'] = babel.core.LOCALE_ALIASES[lang] + + # ensure uid is set + context['uid'] = self.env.uid + + return frozendict(context) @api.model def action_get(self): diff --git a/odoo/http.py b/odoo/http.py index 89f46ed1e73..f6fff7d3c21 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -684,7 +684,7 @@ class FilesystemSessionStore(sessions.FilesystemSessionStore): class Session(dict): """ Structure containing data persisted across requests. """ - __slots__ = ('can_save', 'is_explicit', 'json_data', 'new', 'should_rotate', 'sid') + __slots__ = ('can_save', 'is_explicit', 'json_data', 'new', 'should_rotate', 'should_touch', 'sid') def __init__(self, data, sid, new=False): super().__init__(data) @@ -693,6 +693,7 @@ class Session(dict): object.__setattr__(self, 'json_data', json.dumps(data)) object.__setattr__(self, 'new', new) object.__setattr__(self, 'should_rotate', False) + object.__setattr__(self, 'should_touch', False) object.__setattr__(self, 'sid', sid) def __getattr__(self, attr): @@ -704,7 +705,67 @@ class Session(dict): else: self[key] = val - ... + def authenticate(self, dbname, login=None, password=None): + """ + Authenticate the current user with the given db, login and + password. If successful, store the authentication parameters in + the current session, unless multi-factor-auth (MFA) is + activated. In that case, that last part will be done by + :ref:`finalize`. + + .. versionchanged:: saas-15.3 + The current request is no longer updated using the user and + context of the session when the authentication is done using + a database different than request.db. It is up to the caller + to open a new cursor/registry/env on the given database. + """ + wsgienv = { + 'interactive': True, + 'base_location': request.httprequest.url_root.rstrip('/'), + 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'], + 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'], + } + + registry = Registry(dbname) + pre_uid = registry['res.users'].authenticate(dbname, login, password, wsgienv) + + self.uid = None + self.pre_login = login + self.pre_uid = pre_uid + + with registry.cursor() as cr: + env = odoo.api.Environment(cr, pre_uid, {}) + + # if 2FA is disabled we finalize immediately + user = env['res.users'].browse(pre_uid) + if not user._mfa_url(): + self.finalize(env) + + if request and request.session is self and request.db == dbname: + # Like update_env(user=request.session.uid) but works when uid is None + request.env = odoo.api.Environment(request.env.cr, self.uid, self.context) + request.update_context(**self.context) + + return pre_uid + + def finalize(self, env): + """ + Finalizes a partial session, should be called on MFA validation + to convert a partial / pre-session into a logged-in one. + """ + login = self.pop('pre_login') + uid = self.pop('pre_uid') + + env = env(user=uid) + user_context = dict(env['res.users'].context_get()) + + self.should_rotate = True + self.update({ + 'login': login, + 'uid': uid, + 'context': user_context, + 'session_token': env.user._compute_session_token(self.sid), + }) def logout(self, keep_db=False): db = self.db if keep_db else DEFAULT_SESSION['db'] # None @@ -1044,13 +1105,15 @@ class Request: def _save_session(self): """ Save a modified session on disk. """ - if not self.session.can_save: + sess = self.session + + if not sess.can_save: return - if self.session.should_rotate: - root.session_store.rotate(self.session, self.env) # it saves - elif json.dumps(self.session) != self.session.json_data: - root.session_store.save(self.session) + if sess.should_rotate: + root.session_store.rotate(sess, self.env) # it saves + elif sess.should_touch or json.dumps(sess) != sess.json_data: + root.session_store.save(sess) # We must not set the cookie if the session id was specified # using a http header or a GET parameter. @@ -1062,8 +1125,8 @@ class Request: # cookie). That is a special feature of the Javascript Session. # - It could allow session fixation attacks. cookie_sid = self.httprequest.cookies.get('session_id') - if (cookie_sid != self.session.sid and not self.session.is_explicit): - self.future_response.set_cookie('session_id', self.session.sid, max_age=SESSION_LIFETIME, httponly=True) + if (sess.should_touch or cookie_sid != sess.sid and not sess.is_explicit): + self.future_response.set_cookie('session_id', sess.sid, max_age=SESSION_LIFETIME, httponly=True) def _set_request_dispatcher(self, rule): routing = rule.endpoint.routing