From f04b90b6e856bd8c1679cc728255f53fc788f8fd Mon Sep 17 00:00:00 2001 From: Julien Castiaux Date: Thu, 10 Feb 2022 13:30:43 +0000 Subject: [PATCH] [REF] core: HTTPocalypse (12) web ir.http & login This commit is the 12th commit of a comprehensive refactor of our HTTP framework. See odoo/odoo#78857 for complete historic, discussions and rationnals. The web module is twofold, on one side there are many controllers: /, /web, /web/login, /web/database/selector, /web/dataset/call_kw, etc, on the other side there is `session_info`: the method responsible to create the web client's environ. This module is kinda an exception as it is (with base) a server wide module. In the case of the HTTP framework, it means that the controllers of web are always accessible, i.e. going to / or /web/login will never return a 404 Not Found even if the user is not connected to a database. This is both a blessing and a curse. It is a blessing because the controllers are always accessible it means that a new users can freely access those routes. It is a curse because *any* user can access them, even user who don't have a session yet thus who are not connected to a database yet. From a developer standpoint, we have to put extra care to correct serve users with and without a database. An example is the /web/login route, the login/password pair is stored in a database, without database it is impossible to validate a user login but users can still access this route without db. To solve this problem, there is the `ensure_db` function. This function attempts to find a database using various sources (?db= query-string, session db, mono db) and to save it on the user session. In case no db is found, the user is redirected to the database selector. In a way, this function grants a database to the user in a seamingly experience. In a way, this function brings a welcome differentiation between `auth='none'` with a database and `auth='none'` without a database. Such differentiation only matters for the server wide modules as "regular" module controllers are only accessible via the ir.http routing map, i.e. it is not possible to declare a nodb controller outside of server wide modules. An important changement is the `session.authenticate` method, before it was possible to call the method when the cursor was not yet initialized, authenticate would open a cursor against the given database, setup a registry and an environment and ultimately save everything on the current request. Because the cursor is now greedily created, it is no more possible to update the request environment when authenticating on another database. PR: odoo#78857 Task: 2571224 --- addons/auth_oauth/controllers/main.py | 24 ++- addons/auth_oauth/models/res_users.py | 2 +- addons/auth_signup/controllers/main.py | 8 +- addons/auth_signup/models/ir_http.py | 14 +- addons/auth_signup/models/res_users.py | 4 +- addons/auth_totp/controllers/home.py | 6 +- addons/auth_totp/tests/test_totp.py | 8 +- addons/base_import_module/controllers/main.py | 36 +--- addons/web/controllers/main.py | 200 ++++++------------ addons/web/models/ir_http.py | 65 ++++-- .../static/src/legacy/js/core/translation.js | 2 +- addons/web/tests/test_health.py | 2 +- addons/web/tests/test_profiler.py | 8 +- odoo/addons/base/models/res_users.py | 20 +- odoo/http.py | 81 ++++++- 15 files changed, 265 insertions(+), 215 deletions(-) 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