diff --git a/addons/account/tests/test_portal_attachment.py b/addons/account/tests/test_portal_attachment.py index 02ac0cf04a7..2340fc75f2a 100644 --- a/addons/account/tests/test_portal_attachment.py +++ b/addons/account/tests/test_portal_attachment.py @@ -32,6 +32,8 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon): def test_01_portal_attachment(self): """Test the portal chatter attachment route.""" + self.authenticate(None, None) + # Test public user can't create attachment without token of document res = self.url_open( url='%s/portal/attachment/add' % self.base_url, diff --git a/addons/auth_ldap/models/res_users.py b/addons/auth_ldap/models/res_users.py index 637ecd58507..a153221763d 100644 --- a/addons/auth_ldap/models/res_users.py +++ b/addons/auth_ldap/models/res_users.py @@ -10,9 +10,9 @@ class Users(models.Model): _inherit = "res.users" @classmethod - def _login(cls, db, login, password): + def _login(cls, db, login, password, user_agent_env): try: - return super(Users, cls)._login(db, login, password) + return super(Users, cls)._login(db, login, password, user_agent_env=user_agent_env) except AccessDenied as e: with registry(db).cursor() as cr: cr.execute("SELECT id FROM res_users WHERE lower(login)=%s", (login,)) @@ -28,11 +28,12 @@ class Users(models.Model): return Ldap._get_or_create_user(conf, login, entry) raise e - def _check_credentials(self, password): + def _check_credentials(self, password, env): try: - super(Users, self)._check_credentials(password) + return super(Users, self)._check_credentials(password, env) except AccessDenied: - if self.env.user.active: + passwd_allowed = env['interactive'] or not self.env.user._rpc_api_keys_only() + if passwd_allowed and self.env.user.active: Ldap = self.env['res.company.ldap'] for conf in Ldap._get_ldap_dicts(): if Ldap._authenticate(conf, self.env.user.login, password): diff --git a/addons/auth_oauth/models/res_users.py b/addons/auth_oauth/models/res_users.py index 0e54a23b0aa..02a2bd97af8 100644 --- a/addons/auth_oauth/models/res_users.py +++ b/addons/auth_oauth/models/res_users.py @@ -109,13 +109,16 @@ class ResUsers(models.Model): # return user credentials return (self.env.cr.dbname, login, access_token) - def _check_credentials(self, password): + def _check_credentials(self, password, env): try: - return super(ResUsers, self)._check_credentials(password) + return super(ResUsers, self)._check_credentials(password, env) except AccessDenied: - res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)]) - if not res: - raise + passwd_allowed = env['interactive'] or not self.env.user._rpc_api_keys_only() + if passwd_allowed and self.env.user.active: + res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)]) + if res: + return + raise def _get_session_token_fields(self): return super(ResUsers, self)._get_session_token_fields() | {'oauth_access_token'} diff --git a/addons/auth_password_policy_portal/__init__.py b/addons/auth_password_policy_portal/__init__.py new file mode 100644 index 00000000000..153a9e31ebb --- /dev/null +++ b/addons/auth_password_policy_portal/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import controllers diff --git a/addons/auth_password_policy_portal/__manifest__.py b/addons/auth_password_policy_portal/__manifest__.py new file mode 100644 index 00000000000..8241c90a670 --- /dev/null +++ b/addons/auth_password_policy_portal/__manifest__.py @@ -0,0 +1,7 @@ +{ + 'name': "Password Policy support for Signup", + 'depends': ['auth_password_policy', 'portal'], + 'category': 'Tools', + 'auto_install': True, + 'data': ['views/templates.xml'] +} diff --git a/addons/auth_password_policy_portal/controllers.py b/addons/auth_password_policy_portal/controllers.py new file mode 100644 index 00000000000..89678520b97 --- /dev/null +++ b/addons/auth_password_policy_portal/controllers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal + +class CustomerPortalPasswordPolicy(CustomerPortal): + def _prepare_portal_layout_values(self): + d = super()._prepare_portal_layout_values() + d['password_minimum_length'] = request.env['ir.config_parameter'].sudo().get_param('auth_password_policy.minlength') + return d diff --git a/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss new file mode 100644 index 00000000000..8a3cb187ece --- /dev/null +++ b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss @@ -0,0 +1,6 @@ +.o_portal_security_body #new-password-group { + position: relative; + meter { + bottom: calc(#{$input-height-sm} / 2 - 7px); + } +} diff --git a/addons/auth_password_policy_portal/views/templates.xml b/addons/auth_password_policy_portal/views/templates.xml new file mode 100644 index 00000000000..427ee055e51 --- /dev/null +++ b/addons/auth_password_policy_portal/views/templates.xml @@ -0,0 +1,17 @@ + + + + diff --git a/addons/auth_password_policy_signup/static/src/js/signup_policy.js b/addons/auth_password_policy_signup/static/src/js/signup_policy.js index 1d37484a34c..14e3d8d19ca 100644 --- a/addons/auth_password_policy_signup/static/src/js/signup_policy.js +++ b/addons/auth_password_policy_signup/static/src/js/signup_policy.js @@ -11,7 +11,7 @@ if (!$signupForm.length) { return; } // hook in password strength meter // * requirement is the password field's minlength // * recommendations are from the module -var $password = $signupForm.find('#password'); +var $password = $('[type=password][minlength]'); var minlength = Number($password.attr('minlength')); if (isNaN(minlength)) { return; } diff --git a/addons/auth_totp/__init__.py b/addons/auth_totp/__init__.py new file mode 100644 index 00000000000..9e5827f90ee --- /dev/null +++ b/addons/auth_totp/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import controllers +from . import models diff --git a/addons/auth_totp/__manifest__.py b/addons/auth_totp/__manifest__.py new file mode 100644 index 00000000000..f98675c5252 --- /dev/null +++ b/addons/auth_totp/__manifest__.py @@ -0,0 +1,25 @@ +{ + 'name': 'Two-Factor Authentication (TOTP)', + 'description': """ +Two-Factor Authentication (TOTP) +================================ +Allows users to configure two-factor authentication on their user account +for extra security, using time-based one-time passwords (TOTP). + +Once enabled, the user will need to enter a 6-digit code as provided +by their authenticator app before being granted access to the system. +All popular authenticator apps are supported. + +Note: logically, two-factor prevents password-based RPC access for users +where it is enabled. In order to be able to execute RPC scripts, the user +can setup API keys to replace their main password. + """, + 'depends': ['web'], + 'category': 'Extra Tools', + 'auto_install': True, + 'data': [ + 'security/security.xml', + 'views/user_preferences.xml', + 'views/templates.xml', + ], +} diff --git a/addons/auth_totp/controllers/__init__.py b/addons/auth_totp/controllers/__init__.py new file mode 100644 index 00000000000..3f3f69c6abf --- /dev/null +++ b/addons/auth_totp/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import home diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py new file mode 100644 index 00000000000..f68f9ed3896 --- /dev/null +++ b/addons/auth_totp/controllers/home.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import odoo.addons.web.controllers.main +from odoo import http, _ +from odoo.exceptions import AccessDenied +from odoo.http import request + + +class Home(odoo.addons.web.controllers.main.Home): + @http.route( + '/web/login/totp', + type='http', auth='public', methods=['GET', 'POST'], sitemap=False, + website=True, # website breaks the login layout... + ) + def web_totp(self, redirect=None, **kwargs): + if request.session.uid: + return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect)) + + if not request.session.pre_uid: + return http.redirect_with_hash('/web/login') + + error = None + if request.httprequest.method == 'POST': + user = request.env['res.users'].browse(request.session.pre_uid) + try: + with user._assert_can_auth(): + user._totp_check(int(kwargs['totp_token'])) + except AccessDenied: + error = _("Verification failed, please double-check the 6-digit code") + except ValueError: + error = _("Invalid authentication code format.") + else: + request.session.finalize() + return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect)) + + return request.render('auth_totp.auth_totp_form', { + 'error': error, + 'redirect': redirect, + }) diff --git a/addons/auth_totp/models/__init__.py b/addons/auth_totp/models/__init__.py new file mode 100644 index 00000000000..5f0fe0d93ac --- /dev/null +++ b/addons/auth_totp/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import ir_http +from . import res_users diff --git a/addons/auth_totp/models/ir_http.py b/addons/auth_totp/models/ir_http.py new file mode 100644 index 00000000000..9bae70facc3 --- /dev/null +++ b/addons/auth_totp/models/ir_http.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from odoo import models +from odoo.http import request + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + info = super().session_info() + # because frontend session_info uses this key and is embedded in + # the view source + info["user_id"] = request.session.uid, + return info diff --git a/addons/auth_totp/models/res_users.py b/addons/auth_totp/models/res_users.py new file mode 100644 index 00000000000..5625724c68b --- /dev/null +++ b/addons/auth_totp/models/res_users.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +import hmac +import io +import logging +import os +import struct +import time + +import qrcode +import werkzeug.urls + +from odoo import _, api, fields, models +from odoo.addons.base.models.res_users import check_identity +from odoo.exceptions import AccessDenied, UserError + +_logger = logging.getLogger(__name__) + +class Users(models.Model): + _inherit = 'res.users' + + totp_secret = fields.Char(copy=False, groups=".") # no access + totp_enabled = fields.Boolean(string="TOTP enabled", compute='_compute_totp_enabled') + + def __init__(self, pool, cr): + init_res = super().__init__(pool, cr) + type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled'] + return init_res + + def _mfa_url(self): + r = super()._mfa_url() + if r is not None: + return r + if self.totp_enabled: + return '/web/login/totp' + + @api.depends('totp_secret') + def _compute_totp_enabled(self): + for r, v in zip(self, self.sudo()): + r.totp_enabled = bool(v.totp_secret) + + def _rpc_api_keys_only(self): + # 2FA enabled means we can't allow password-based RPC + self.ensure_one() + return self.totp_enabled or super()._rpc_api_keys_only() + + def _totp_check(self, code): + sudo = self.sudo() + key = base64.b32decode(sudo.totp_secret.upper()) + match = TOTP(key).match(code) + if match is None: + _logger.info("2FA check: FAIL for '%s' (#%s)", self.login, self.id) + raise AccessDenied() + _logger.info("2FA check: SUCCESS for '%s' (#%s)", self.login, self.id) + + def _totp_try_setting(self, secret, code): + if self.totp_enabled or self != self.env.user: + _logger.info("2FA enable: REJECT for '%s' (#%s)", self.login, self.id) + return False + + match = TOTP(base64.b32decode(secret.upper())).match(code) + if match is None: + _logger.info("2FA enable: REJECT CODE for '%s' (#%s)", self.login, self.id) + return False + + self.sudo().totp_secret = secret + _logger.info("2FA enable: SUCCESS for '%s' (#%s)", self.login, self.id) + return True + + @check_identity + def totp_disable(self): + if not (self == self.env.user or self.env.user._is_admin() or self.env.su): + _logger.info("2FA disable: REJECT for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id) + return False + + self.sudo().write({'totp_secret': False}) + _logger.info("2FA disable: SUCCESS for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id) + return {'type': 'ir.actions.act_window_close'} + + @check_identity + def totp_enable_wizard(self): + if self.env.user != self: + raise UserError(_("Two-factor authentication can only be enabled for yourself")) + + if self.totp_enabled: + raise UserError(_("Two-factor authentication already enabled")) + + secret_bytes_count = TOTP_SECRET_SIZE // 8 + w = self.env['auth_totp.wizard'].create({ + 'user_id': self.id, + 'secret': base64.b32encode(os.urandom(secret_bytes_count)).decode(), + }) + return { + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_model': 'auth_totp.wizard', + 'name': _("Enable Two-Factor Authentication"), + 'res_id': w.id, + 'views': [(False, 'form')], + } + +class TOTPWizard(models.TransientModel): + _name = 'auth_totp.wizard' + _description = "Two-Factor Setup Wizard" + + user_id = fields.Many2one('res.users', required=True, readonly=True) + secret = fields.Char(required=True, readonly=True) + url = fields.Char(store=True, readonly=True, compute='_compute_qrcode') + qrcode = fields.Binary( + attachment=False, store=True, readonly=True, + compute='_compute_qrcode', + ) + code = fields.Char(string="Verification Code", placeholder="6-digit code", size=6) + + @api.depends('user_id.login', 'user_id.company_id.display_name', 'secret') + def _compute_qrcode(self): + for w in self: + label = '{0.company_id.display_name}:{0.login}'.format(w.user_id) + w.url = url = werkzeug.urls.url_unparse(( + 'otpauth', 'totp', + werkzeug.urls.url_quote(label, safe=''), + werkzeug.urls.url_encode({ + 'secret': w.secret, + 'issuer': w.user_id.company_id.display_name, + # apparently a lowercase hash name is anathema to google + # authenticator (error) and passlib (no token) + 'algorithm': ALGORITHM.upper(), + 'digits': DIGITS, + 'period': TIMESTEP, + }), '' + )) + + data = io.BytesIO() + qrcode.make(url.encode(), box_size=4).save(data, optimise=True, format='PNG') + w.qrcode = base64.b64encode(data.getvalue()).decode() + + @check_identity + def enable(self): + try: + c = int(self.code) + except ValueError: + raise UserError(_("The verification code should only contain numbers")) + if self.user_id._totp_try_setting(self.secret, c): + self.secret = '' # empty it, because why keep it until GC? + return {'type': 'ir.actions.act_window_close'} + raise UserError(_('Verification failed, please double-check the 6-digit code')) + +# 160 bits, as recommended by HOTP RFC 4226, section 4, R6. +# Google Auth uses 80 bits by default but supports 160. +TOTP_SECRET_SIZE = 160 + +# The algorithm (and key URI format) allows customising these parameters but +# google authenticator doesn't support it +# https://github.com/google/google-authenticator/wiki/Key-Uri-Format +ALGORITHM = 'sha1' +DIGITS = 6 +TIMESTEP = 30 + +class TOTP: + def __init__(self, key): + self._key = key + + def match(self, code, t=None, window=TIMESTEP): + """ + :param code: authenticator code to check against this key + :param int t: current timestamp (seconds) + :param int window: fuzz window to account for slow fingers, network + latency, desynchronised clocks, ..., every code + valid between t-window an t+window is considered + valid + """ + if t is None: + t = time.time() + + low = int((t - window) / TIMESTEP) + high = int((t + window) / TIMESTEP) + 1 + + return next(( + counter for counter in range(low, high) + if hotp(self._key, counter) == code + ), None) + +def hotp(secret, counter): + # C is the 64b counter encoded in big-endian + C = struct.pack(">Q", counter) + mac = hmac.new(secret, msg=C, digestmod=ALGORITHM).digest() + # the data offset is the last nibble of the hash + offset = mac[-1] & 0xF + # code is the 4 bytes at the offset interpreted as a 31b big-endian uint + # (31b to avoid sign concerns). This effectively limits digits to 9 and + # hard-limits it to 10: each digit is normally worth 3.32 bits but the + # 10th is only worth 1.1 (9 digits encode 29.9 bits). + code = struct.unpack_from('>I', mac, offset)[0] & 0x7FFFFFFF + r = code % (10 ** DIGITS) + # NOTE: use text / bytes instead of int? + return r diff --git a/addons/auth_totp/security/security.xml b/addons/auth_totp/security/security.xml new file mode 100644 index 00000000000..4d44c23a457 --- /dev/null +++ b/addons/auth_totp/security/security.xml @@ -0,0 +1,16 @@ + + + auth_totp wizard access rules + + + 1 + 1 + 1 + 1 + + + Users can only access their own wizard + + [('user_id', '=', user.id)] + + diff --git a/addons/auth_totp/static/tests/totp_flow.js b/addons/auth_totp/static/tests/totp_flow.js new file mode 100644 index 00000000000..3b3f1b67cc1 --- /dev/null +++ b/addons/auth_totp/static/tests/totp_flow.js @@ -0,0 +1,249 @@ +odoo.define('auth_totp.tours', function(require) { +"use strict"; + +const tour = require('web_tour.tour'); +const ajax = require('web.ajax'); + +tour.register('totp_tour_setup', { + test: true, + url: '/web' +}, [{ + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Switch to security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: 'click', +}, { + content: "Open totp wizard", + trigger: 'button[name=totp_enable_wizard]', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text demo', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Check the wizard has opened", + trigger: 'div:contains("Scan the image below")', + run: () => {} +}, { + content: "Get secret from collapsed div", + trigger: 'a:contains("show the code")', + run: async function(helpers) { + const secret = this.$anchor.closest('div').find('code').text(); + const token = await ajax.jsonRpc('/totphook', 'call', { + secret + }); + helpers._text(helpers._get_action_values('input[name=code]'), token); + helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)')); + } +}, { // re-navigate to the profile as unless hr is installed the preference dialog will close + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Disable two-factor authentication)', + run: () => {} +}]); + +tour.register('totp_login_enabled', { + test: true, + url: '/' +}, [{ + content: "check that we're on the login page or go to it", + trigger: 'input#login, a:contains(Sign in)' +}, { + content: "input login", + trigger: 'input#login', + run: 'text demo', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text demo', +}, { + content: "click da button", + trigger: 'button:contains("Log in")', +}, { + content: "expect totp screen", + trigger: 'label:contains(Authentication Code)', +}, { + content: "input code", + trigger: 'input[name=totp_token]', + run: async function (helpers) { + const token = await ajax.jsonRpc('/totphook', 'call', {}); + helpers._text(helpers._get_action_values(), token); + // FIXME: is there a way to put the button as its own step trigger without + // the tour straight blowing through and not waiting for this? + helpers._click(helpers._get_action_values('button:contains("Verify")')); + } +}, { + content: "check we're logged in", + trigger: ".o_user_menu .oe_topbar_name", + run: () => {} +}, { + // now go and disable totp would be annoying to do in a separate tour + // because we'd need to login & totp again as HttpCase.authenticate can't + // succeed w/ totp enabled + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Switch to security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: 'click', +}, { + content: "Open totp wizard", + trigger: 'button[name=totp_disable]', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text demo', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Reopen the preference / profile as we don't know whether HR is installed", + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Enable two-factor authentication)', + run: () => {} +}]); + +tour.register('totp_login_disabled', { + test: true, + url: '/' +}, [{ + content: "check that we're on the login page or go to it", + trigger: 'input#login, a:contains(Sign in)' +}, { + content: "input login", + trigger: 'input#login', + run: 'text demo', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text demo', +}, { + content: "click da button", + trigger: 'button:contains("Log in")', +}, { // normally we'd end there but being sure there's no more queries is a + // pain in the ass so go and open the profile screen + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check the pref screen has opened by looking for the Account Security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: () => {} +}]); + +const columns = {}; +tour.register('totp_admin_disables', { + test: true, + url: '/web' +}, [tour.stepUtils.showAppsMenuItem(), { + content: 'Go to settings', + trigger: '[data-menu-xmlid="base.menu_administration"]' +}, { + content: 'Wait for page', + trigger: '.o_menu_brand:contains("Settings")', + run: () => {} +}, { + content: "Open Users menu", + trigger: '[data-menu-xmlid="base.menu_users"]' +}, { + content: "Open Users view", + trigger: '[data-menu-xmlid="base.menu_action_res_users"]', + run: function (helpers) { + // funny story: the users view we're trying to reach, sometimes we're + // already there, but if we re-click the next step executes before the + // action has the time to re-load, the one after that doesn't, and our + // selection get discarded by the action reloading, so here try to + // see if we're already on the users action through the breadcrumb and + // just close the menu if so + const $crumb = $('.breadcrumb'); + if ($crumb.text().indexOf('Users') === -1) { + // on general settings page, click menu + helpers.click(); + } else { + // else close menu + helpers.click($('[data-menu-xmlid="base.menu_users"]')); + } + } +}, { + content: "Find Demo User", + trigger: 'td.o_data_cell:contains("demo")', + run: function (helpers) { + const $titles = this.$anchor.closest('table').find('tr:first th'); + for (let i=0; i<$titles.length; ++i) { + columns[$titles[i].getAttribute('data-name')] = i; + } + const $row = this.$anchor.closest('tr'); + const sel = $row.find('.o_list_record_selector input[type=checkbox]'); + const totp = $row[0].children[columns['totp_enabled']].querySelector('input'); + if (totp.checked) { + helpers.click(sel); + } + } +}, { + content: "Open Actions menu", + trigger: 'button.o_dropdown_toggler_btn:contains("Action")' +}, { + content: "Select totp remover", + trigger: 'a.dropdown-item:contains(Disable TOTP on users)' +}, { // enhanced security yo + content: "Check that we have to enter enhanced security mode", +trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text admin', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "check that demo user has been de-totp'd", + trigger: "td.o_data_cell:contains(demo)", + run: function () { + const totpcell = this.$anchor.closest('tr')[0].children[columns['totp_enabled']]; + if (totpcell.querySelector('input').checked) { + throw new Error("totp should have been disabled on demo user"); + } + } +}]) +}); diff --git a/addons/auth_totp/tests/__init__.py b/addons/auth_totp/tests/__init__.py new file mode 100644 index 00000000000..31e2de2ba2d --- /dev/null +++ b/addons/auth_totp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_totp diff --git a/addons/auth_totp/tests/test_totp.py b/addons/auth_totp/tests/test_totp.py new file mode 100644 index 00000000000..b4b8b6478d2 --- /dev/null +++ b/addons/auth_totp/tests/test_totp.py @@ -0,0 +1,57 @@ +import time + +from passlib.totp import TOTP + +from odoo import http +from odoo.exceptions import AccessDenied +from odoo.service import common as auth, model +from odoo.tests import tagged, HttpCase + +from ..controllers.home import Home + +@tagged('post_install', '-at_install') +class TestTOTP(HttpCase): + def setUp(self): + super().setUp() + + totp = None + # test endpoint as doing totp on the client side is not really an option + # (needs sha1 and hmac + BE packing of 64b integers) + def totp_hook(self, secret=None): + nonlocal totp + if totp is None: + totp = TOTP(secret) + if secret: + return totp.generate().token + else: + # on check, take advantage of window because previous token has been + # "burned" so we can't generate the same, but tour is so fast + # we're pretty certainly within the same 30s + return totp.generate(time.time() + 30).token + # because not preprocessed by ControllerType metaclass + totp_hook.routing_type = 'json' + self.env['ir.http']._clear_routing_map() + # patch Home to add test endpoint + Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook) + # remove endpoint and destroy routing map + @self.addCleanup + def _cleanup(): + del Home.totp_hook + self.env['ir.http']._clear_routing_map() + + def test_totp(self): + # 1. Enable 2FA + self.start_tour('/web', 'totp_tour_setup', login='demo') + + # 2. Check 2FA is required + self.start_tour('/', 'totp_login_enabled', login=None) + + # 3. TODO: verify that RPC is blocked because 2FA is on. + + # 4. Finally, disable 2FA otherwise we can't re-login + self.start_tour('/', 'totp_login_disabled', login=None) + + def test_totp_administration(self): + self.start_tour('/web', 'totp_tour_setup', login='demo') + self.start_tour('/web', 'totp_admin_disables', login='admin') + self.start_tour('/', 'totp_login_disabled', login=None) diff --git a/addons/auth_totp/views/templates.xml b/addons/auth_totp/views/templates.xml new file mode 100644 index 00000000000..6ed3fdfac6f --- /dev/null +++ b/addons/auth_totp/views/templates.xml @@ -0,0 +1,38 @@ + + + + diff --git a/addons/auth_totp/views/user_preferences.xml b/addons/auth_totp/views/user_preferences.xml new file mode 100644 index 00000000000..b490efe2cbf --- /dev/null +++ b/addons/auth_totp/views/user_preferences.xml @@ -0,0 +1,108 @@ + + + users list: add totp status + res.users + + + + + + + + + Disable TOTP on users + + + list + code + + action = records.totp_disable() + + + + + + auth_totp wizard + auth_totp.wizard + +
+ +
+
+

Scan this barcode with your app

+
+ Scan the image below with the authenticator app on your phone.
+ If you cannot scan the barcode, here are some alternative options: + + +
+
+

Your two-factor secret:

+ +
+
+
+ + + +

Enter the 6-digit code from your app

+
+ After scanning the barcode, the app will display a 6-digit code that you + should enter below. Don't worry if the code changes in the app, + it stays valid a bit longer. +
+
+ + +
+
+
+
+
+
+
+
+ + + users preference: totp + res.users + + + + + +
diff --git a/addons/auth_totp_portal/__init__.py b/addons/auth_totp_portal/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/addons/auth_totp_portal/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/addons/auth_totp_portal/__manifest__.py b/addons/auth_totp_portal/__manifest__.py new file mode 100644 index 00000000000..c316c51feb9 --- /dev/null +++ b/addons/auth_totp_portal/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': "TOTPortal", + 'category': 'Hidden', + 'depends': ['portal', 'auth_totp'], + 'auto_install': True, + 'data': [ + 'security/security.xml', + 'views/templates.xml', + ], +} diff --git a/addons/auth_totp_portal/security/security.xml b/addons/auth_totp_portal/security/security.xml new file mode 100644 index 00000000000..f9c4c2f70b4 --- /dev/null +++ b/addons/auth_totp_portal/security/security.xml @@ -0,0 +1,16 @@ + + + + auth_totp_portal wizard access rules + + + 1 + 1 + 1 + 1 + + + + + + diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js new file mode 100644 index 00000000000..b4644fc77cd --- /dev/null +++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js @@ -0,0 +1,226 @@ +odoo.define('auth_totp_portal.button', function (require) { +'use strict'; + +const {_t} = require('web.core'); +const publicWidget = require('web.public.widget'); +const Dialog = require('web.Dialog'); +const {handleCheckIdentity} = require('portal.portal'); + +/** + * Replaces specific elements by normal HTML, strip out the rest entirely + */ +function fromField(f, record) { + switch (f.getAttribute('name')) { + case 'qrcode': + const qrcode = document.createElement('img'); + qrcode.setAttribute('class', 'img img-fluid offset-1 offset-lg-2'); + qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']); + return qrcode; + case 'url': + const url = document.createElement('a'); + url.setAttribute('href', record['url']); + url.textContent = f.getAttribute('text') || record['url']; + return url; + case 'code': + const code = document.createElement('input'); + code.setAttribute('name', 'code'); + code.setAttribute('class', 'form-control'); + code.setAttribute('placeholder', '6-digit code'); + code.required = true; + code.maxLength = 6; + code.minLength = 6; + return code; + default: // just display the field's data + return document.createTextNode(record[f.getAttribute('name')] || ''); + } +} + +/** + * Apparently chrome literally absolutely can't handle parsing XML and using + * those nodes in an HTML document (even when parsing as application/xhtml+xml), + * this results in broken rendering and a number of things not working (e.g. + * classes) without any specific warning in the console or anything, things are + * just broken with no indication of why. + * + * So... rebuild the entire f'ing body using document.createElement to ensure + * we have HTML elements. + * + * This is a recursive implementation so it's not super efficient but the views + * to fixup *should* be relatively simple. + */ +function fixupViewBody(oldNode, record) { + let qrcode = null, code = null, node = null; + + switch (oldNode.nodeType) { + case 1: // element + if (oldNode.tagName === 'field') { + node = fromField(oldNode, record); + switch (oldNode.getAttribute('name')) { + case 'qrcode': + qrcode = node; + break; + case 'code': + code = node; + break + } + break; // no need to recurse here + } + node = document.createElement(oldNode.tagName); + for(let i=0; i element and a bunch of metadata into a structure + * which can kinda be of use to Dialog. + */ +class Button { + constructor(parent, model, record_id, input_node, button_node) { + this._parent = parent; + this.model = model; + this.record_id = record_id; + this.input = input_node; + this.text = button_node.getAttribute('string'); + this.classes = button_node.getAttribute('class') || null; + this.action = button_node.getAttribute('name'); + if (button_node.getAttribute('special') === 'cancel') { + this.close = true; + this.click = null; + } else { + this.close = false; + // because Dialog doesnt' call() click on the descriptor object + this.click = this._click.bind(this); + } + if (!button_node.closest('footer')) { + // remove non-footer buttons, otherwise they will appear twice + button_node.parentNode.removeChild(button_node); + } + } + async _click() { + if (!this.input.reportValidity()) { + this.input.classList.add('is-invalid'); + return; + } + + try { + await this.callAction(this.record_id, {code: this.input.value}); + } catch (e) { + this.input.classList.add('is-invalid'); + // show custom validity error message + this.input.setCustomValidity(e.message); + this.input.reportValidity(); + return; + } + this.input.classList.remove('is-invalid'); + // reloads page, avoid window.location.reload() because it re-posts forms + window.location = window.location; + } + async callAction(id, update) { + try { + await this._parent._rpc({model: this.model, method: 'write', args: [id, update]}); + await handleCheckIdentity( + this._parent.proxy('_rpc'), + this._parent._rpc({model: this.model, method: this.action, args: [id]}) + ); + } catch(e) { + // avoid error toast (crashmanager) + e.event.preventDefault(); + // try to unwrap mess of an error object to a usable error message + throw new Error( + !e.message ? e.toString() + : !e.message.data ? e.message.message + : e.message.data.message || _t("Operation failed for unknown reason.") + ); + } + } +} + +publicWidget.registry.TOTPButton = publicWidget.Widget.extend({ + selector: '#auth_totp_portal_enable', + events: { + click: '_onClick', + }, + + async _onClick(e) { + e.preventDefault(); + + const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({ + model: 'res.users', + method: 'totp_enable_wizard', + args: [this.getSession().user_id] + })); + + if (!w) { + // TOTP probably already enabled, just reload page + window.location = window.location; + return; + } + + const {res_model: model, res_id: wizard_id} = w; + + const record = await this._rpc({ + model, method: 'read', args: [wizard_id, []] + }).then(ar => ar[0]); + + const doc = new DOMParser().parseFromString( + document.getElementById('totp_wizard_view').textContent, + 'application/xhtml+xml' + ); + console.log(doc); + + const xmlBody = doc.querySelector('sheet *'); + const [body, , codeInput] = fixupViewBody(xmlBody, record); + // remove custom validity error message any time the field changes + // otherwise it sticks and browsers suppress submit + codeInput.addEventListener('input', () => codeInput.setCustomValidity('')); + + const buttons = []; + for(const button of body.querySelectorAll('button')) { + buttons.push(new Button(this, model, record.id, codeInput, button)); + } + + // wrap in a root host of .modal-body otherwise it breaks our neat flex layout + const $content = document.createElement('form'); + $content.appendChild(body); + // implicit submission by pressing [return] from within input + $content.addEventListener('submit', (e) => { + e.preventDefault(); + // sadness: footer not available as normal element + dialog.$footer.find('.btn-primary').click(); + }); + var dialog = new Dialog(this, {$content, buttons}).open(); + } +}); +publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({ + selector: '#auth_totp_portal_disable', + events: { + click: '_onClick' + }, + + async _onClick(e) { + e.preventDefault(); + await handleCheckIdentity( + this.proxy('_rpc'), + this._rpc({model: 'res.users', method: 'totp_disable', args: [this.getSession().user_id]}) + ) + window.location = window.location; + } +}); +}); diff --git a/addons/auth_totp_portal/static/tests/totp_portal.js b/addons/auth_totp_portal/static/tests/totp_portal.js new file mode 100644 index 00000000000..ec26366e9ad --- /dev/null +++ b/addons/auth_totp_portal/static/tests/totp_portal.js @@ -0,0 +1,124 @@ +odoo.define('auth_totp_portal.tours', function(require) { +"use strict"; + +const tour = require('web_tour.tour'); +const ajax = require('web.ajax'); + +tour.register('totportal_tour_setup', { + test: true, + url: '/my/security' +}, [{ + content: "Open totp wizard", + trigger: 'button#auth_totp_portal_enable', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text portal', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Check the wizard has opened", + trigger: 'div:contains("Scan the image below")', + run: () => {} +}, { + content: "Get secret from collapsed div", + trigger: 'a:contains("show the code")', + run: async function(helpers) { + const secret = this.$anchor.closest('div').find('code').text(); + const token = await ajax.jsonRpc('/totphook', 'call', { + secret + }); + helpers._text(helpers._get_action_values('input[name=code]'), token); + helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)')); + } +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Disable two-factor authentication)', + run: () => {} +}]); + +tour.register('totportal_login_enabled', { + test: true, + url: '/' +}, [{ + content: "check that we're on the login page or go to it", + trigger: 'input#login, a:contains(Sign in)' +}, { + content: "input login", + trigger: 'input#login', + run: 'text portal', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text portal', +}, { + content: "click da button", + trigger: 'button:contains("Log in")', +}, { + content: "expect totp screen", + trigger: 'label:contains(Authentication Code)', +}, { + content: "input code", + trigger: 'input[name=totp_token]', + run: async function (helpers) { + const token = await ajax.jsonRpc('/totphook', 'call', {}); + helpers._text(helpers._get_action_values(), token); + // FIXME: is there a way to put the button as its own step trigger without + // the tour straight blowing through and not waiting for this? + helpers._click(helpers._get_action_values('button:contains("Verify")')); + } +}, { + content: "check we're logged in", + trigger: "h3:contains(Documents)", + run: () => {} +}, { + content: "go back to security", + trigger: "a:contains(Security)", +},{ + content: "Open totp wizard", + trigger: 'button#auth_totp_portal_disable', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text portal', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Enable two-factor authentication)', + run: () => {} +}]); + +tour.register('totportal_login_disabled', { + test: true, + url: '/' +}, [{ + content: "check that we're on the login page or go to it", + trigger: 'input#login, a:contains(Sign in)' +}, { + content: "input login", + trigger: 'input#login', + run: 'text portal', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text portal', +}, { + content: "click da button", + trigger: 'button:contains("Log in")', +}, { + content: "check we're logged in", + trigger: "h3:contains(Documents)", + run: () => {} +}]); +}); diff --git a/addons/auth_totp_portal/tests/__init__.py b/addons/auth_totp_portal/tests/__init__.py new file mode 100644 index 00000000000..f49429ef69a --- /dev/null +++ b/addons/auth_totp_portal/tests/__init__.py @@ -0,0 +1 @@ +from . import test_tour diff --git a/addons/auth_totp_portal/tests/test_tour.py b/addons/auth_totp_portal/tests/test_tour.py new file mode 100644 index 00000000000..242c9f0dcea --- /dev/null +++ b/addons/auth_totp_portal/tests/test_tour.py @@ -0,0 +1,44 @@ +import time + +from passlib.totp import TOTP + +from odoo import http +from odoo.tests import tagged, HttpCase +from odoo.addons.auth_totp.controllers.home import Home + + +@tagged('post_install', '-at_install') +class TestTOTPortal(HttpCase): + """ + Largely replicates TestTOTP + """ + def test_totp(self): + totp = None + # test endpoint as doing totp on the client side is not really an option + # (needs sha1 and hmac + BE packing of 64b integers) + def totp_hook(self, secret=None): + nonlocal totp + if totp is None: + totp = TOTP(secret) + if secret: + return totp.generate().token + else: + # on check, take advantage of window because previous token has been + # "burned" so we can't generate the same, but tour is so fast + # we're pretty certainly within the same 30s + return totp.generate(time.time() + 30).token + # because not preprocessed by ControllerType metaclass + totp_hook.routing_type = 'json' + # patch Home to add test endpoint + Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook) + self.env['ir.http']._clear_routing_map() + # remove endpoint and destroy routing map + @self.addCleanup + def _cleanup(): + del Home.totp_hook + self.env['ir.http']._clear_routing_map() + + self.start_tour('/my/security', 'totportal_tour_setup', login='portal') + # also disables totp otherwise we can't re-login + self.start_tour('/', 'totportal_login_enabled', login=None) + self.start_tour('/', 'totportal_login_disabled', login=None) diff --git a/addons/auth_totp_portal/views/templates.xml b/addons/auth_totp_portal/views/templates.xml new file mode 100644 index 00000000000..48120eb7f52 --- /dev/null +++ b/addons/auth_totp_portal/views/templates.xml @@ -0,0 +1,48 @@ + + + + + diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index bda9ab28a96..ab4002a54ab 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -180,6 +180,13 @@ +
+
+
+
+
diff --git a/addons/hr/tests/test_self_user_access.py b/addons/hr/tests/test_self_user_access.py index 248232b9f04..c4d94831da8 100644 --- a/addons/hr/tests/test_self_user_access.py +++ b/addons/hr/tests/test_self_user_access.py @@ -58,6 +58,7 @@ class TestSelfAccessProfile(TestHrCommon): field.groups.split(',') for field in self.env['res.users']._fields.values() if field.groups + if field.groups != '.' # "no-access" group on purpose ]) all_groups = self.env['res.groups'] for xml_id in all_groups_xml_ids: diff --git a/addons/hr/views/res_users.xml b/addons/hr/views/res_users.xml index 5b2c4462eb2..d9711e0e9bc 100644 --- a/addons/hr/views/res_users.xml +++ b/addons/hr/views/res_users.xml @@ -37,150 +37,149 @@ res.users -
- - -
-
- -
- -
- -
-

- -

-
-
-

- -

-
+
+ + Not Connected + +
+
+ + + + + + + Connected Since +
+ +
+ +
+

+ +

+
+
+

+ +

+
+ - - - - + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
+
+
+ + + + + + + + + - - - - + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + - - -
$0
-
- -
-
- - - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - +
+ + + + + + + + + +
diff --git a/addons/hr_holidays/views/hr_views.xml b/addons/hr_holidays/views/hr_views.xml index 3573d79b523..dbaf7d13360 100644 --- a/addons/hr_holidays/views/hr_views.xml +++ b/addons/hr_holidays/views/hr_views.xml @@ -259,7 +259,7 @@ res.users - + diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 7e063c763fb..a8e44515574 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -2,14 +2,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import functools import json +import logging import math import re from werkzeug import urls from odoo import fields as odoo_fields, http, tools, _, SUPERUSER_ID -from odoo.exceptions import ValidationError, AccessError, MissingError, UserError +from odoo.exceptions import ValidationError, AccessError, MissingError, UserError, AccessDenied from odoo.http import content_disposition, Controller, request, route from odoo.tools import consteq @@ -17,7 +19,7 @@ from odoo.tools import consteq # Misc tools # -------------------------------------------------- - +_logger = logging.getLogger(__name__) def pager(url, total, page=1, step=30, scope=5, url_args=None): """ Generate a dict with required value to render `website.pager` template. This method compute url, page range to display, ... in the pager. @@ -212,6 +214,46 @@ class CustomerPortal(Controller): response.headers['X-Frame-Options'] = 'DENY' return response + @route('/my/security', type='http', auth='user', website=True, methods=['GET', 'POST']) + def security(self, **post): + values = self._prepare_portal_layout_values() + values['get_error'] = get_error + + if request.httprequest.method == 'POST': + values.update(self._update_password( + post['old'].strip(), + post['new1'].strip(), + post['new2'].strip() + )) + + return request.render('portal.portal_my_security', values, headers={ + 'X-Frame-Options': 'DENY' + }) + + def _update_password(self, old, new1, new2): + for k, v in [('old', old), ('new1', new1), ('new2', new2)]: + if not v: + return {'errors': {'password': {k: _("You cannot leave any password empty.")}}} + + if new1 != new2: + return {'errors': {'password': {'new2': _("The new password and its confirmation must be identical.")}}} + + try: + request.env['res.users'].change_password(old, new1) + except UserError as e: + return {'errors': {'password': e.name}} + except AccessDenied as e: + msg = e.args[0] + if msg == AccessDenied().args[0]: + msg = _('The old password you provided is incorrect, your password was not changed.') + return {'errors': {'password': {'old': msg}}} + + # update session token so the user does not get logged out (cache cleared by passwd change) + new_token = request.env.user._compute_session_token(request.session.sid) + request.session.session_token = new_token + + return {'success': {'password': True}} + @http.route('/portal/attachment/add', type='http', auth='public', methods=['POST'], website=True) def attachment_add(self, name, file, res_model, res_id, access_token=None, **kwargs): """Process a file uploaded from the portal chatter and create the @@ -392,3 +434,15 @@ class CustomerPortal(Controller): filename = "%s.pdf" % (re.sub('\W+', '-', model._get_report_base_filename())) reporthttpheaders.append(('Content-Disposition', content_disposition(filename))) return request.make_response(report, headers=reporthttpheaders) + +def get_error(e, path=''): + """ Recursively dereferences `path` (a period-separated sequence of dict + keys) in `e` (an error dict or value), returns the final resolution IIF it's + an str, otherwise returns None + """ + for k in (path.split('.') if path else []): + if not isinstance(e, dict): + return None + e = e.get(k) + + return e if isinstance(e, str) else None diff --git a/addons/portal/controllers/web.py b/addons/portal/controllers/web.py index d1c2c013aff..9e6e6175b32 100644 --- a/addons/portal/controllers/web.py +++ b/addons/portal/controllers/web.py @@ -16,7 +16,7 @@ class Home(Home): def _login_redirect(self, uid, redirect=None): if not redirect and not request.env['res.users'].sudo().browse(uid).has_group('base.group_user'): - return '/my' + redirect = '/my' return super(Home, self)._login_redirect(uid, redirect=redirect) @http.route('/web', type='http', auth="none") diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js index 8c117167699..ee6bbe16eb5 100644 --- a/addons/portal/static/src/js/portal.js +++ b/addons/portal/static/src/js/portal.js @@ -2,6 +2,9 @@ odoo.define('portal.portal', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +const Dialog = require('web.Dialog'); +const {_t, qweb} = require('web.core'); +const ajax = require('web.ajax'); publicWidget.registry.portalDetails = publicWidget.Widget.extend({ selector: '.o_portal_details', @@ -119,4 +122,83 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({ } }, }); + +const tmpl = ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb); + +/** + * Wraps an RPC call in a check for the result being an identity check action + * descriptor. If no such result is found, just returns the wrapped promise's + * result as-is; otherwise shows an identity check dialog and resumes the call + * on success. + * + * Warning: does not in and of itself trigger an identity check, a promise which + * never triggers and identity check internally will do nothing of use. + * + * @param {Function} rpc Widget#_rpc bound do the widget + * @param {Promise} wrapped promise to check for an identity check request + * @returns {Promise} result of the original call + */ +function handleCheckIdentity(rpc, wrapped) { + return wrapped.then((r) => { + if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) { + return r; + } + const check_id = r.res_id; + return tmpl.then(() => new Promise((resolve, reject) => { + const d = new Dialog(null, { + title: _t("Security Control"), + $content: qweb.render('portal.identitycheck'), + buttons: [{ + text: _t("Confirm Password"), classes: 'btn btn-primary', + // nb: if click & close, waits for click to resolve before closing + click() { + const password_input = this.el.querySelector('[name=password]'); + if (!password_input.reportValidity()) { + password_input.classList.add('is-invalid'); + return; + } + return rpc({ + model: 'res.users.identitycheck', + method: 'write', + args: [check_id, {password: password_input.value}] + }).then(() => rpc({ + model: 'res.users.identitycheck', + method: 'run_check', + args: [check_id] + })).then((r) => { + this.close(); + resolve(r); + }, (err) => { + err.event.preventDefault(); // suppress crashmanager + password_input.classList.add('is-invalid'); + password_input.setCustomValidity(_t("Check failed")); + password_input.reportValidity(); + }); + } + }, { + text: _t('Cancel'), close: true + }] + }).on('close', null, () => { + // unlink wizard object? + reject(); + }); + d.opened(() => { + const pw = d.el.querySelector('[name="password"]'); + pw.focus(); + pw.addEventListener('input', () => { + pw.classList.remove('is-invalid'); + pw.setCustomValidity(''); + }); + d.el.addEventListener('submit', (e) => { + e.preventDefault(); + d.$footer.find('.btn-primary').click(); + }); + }); + d.open(); + })); + }); +} +return { + handleCheckIdentity, +} }); diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index 604c0a70a25..be50f1c20e8 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -536,6 +536,25 @@ form label { } } +.o_portal_security_body { + @extend .mx-auto; + max-width: map-get($container-max-widths, sm); + section { + @extend .mt-4; + form.oe_reset_password_form { + max-width: initial; + margin: initial; + } + .form-group { + // for the absolutely positioned meter on new password + position: relative; + } + label, button { + @extend .text-nowrap; + } + } +} + // Copyright .o_footer_copyright { @@ -551,3 +570,4 @@ form label { } } } + diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml new file mode 100644 index 00000000000..1f905563ede --- /dev/null +++ b/addons/portal/static/src/xml/portal_security.xml @@ -0,0 +1,28 @@ + + +
+
+
+
+
+ + Security Control +
+
+ Forgot password? +

Please confirm your password to continue

+

+ This is necessary for security-related changes. The authorization will last for a few minutes. +

+
+ + +
+
+
+
+
+ +
+
diff --git a/addons/portal/tests/__init__.py b/addons/portal/tests/__init__.py index 06a8d4cd529..f0834f66623 100644 --- a/addons/portal/tests/__init__.py +++ b/addons/portal/tests/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_load_process +from . import test_tours diff --git a/addons/portal/tests/test_load_process.py b/addons/portal/tests/test_tours.py similarity index 100% rename from addons/portal/tests/test_load_process.py rename to addons/portal/tests/test_tours.py diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 642b818981b..be4a35f2db0 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -117,6 +117,11 @@
+
+

Account Security

+
+ Edit Security Settings +
@@ -438,6 +443,58 @@ + +