[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
This commit is contained in:
Julien Castiaux
2022-02-24 13:30:50 +00:00
parent eb16546132
commit f04b90b6e8
15 changed files with 265 additions and 215 deletions
+16 -8
View File
@@ -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 = {
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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):
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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):
+4 -2
View File
@@ -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",
+5 -3
View File
@@ -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.")
+11 -25
View File
@@ -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)
+68 -132
View File
@@ -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/<string:unique>', 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/<path:path>', 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",
+52 -13
View File
@@ -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=<mode>` 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'),
@@ -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;
+1 -1
View File
@@ -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'))
+4 -4
View File
@@ -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)
+17 -3
View File
@@ -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):
+72 -9
View File
@@ -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