summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXavier Morel <xmo@odoo.com>2019-06-05 12:45:16 +0000
committerOlivier Dony <odo@odoo.com>2020-08-14 23:06:24 +0000
commita9a65097132b53813991c9ce61c878ab3f302f53 (patch)
tree6c0d2a4a44f3428028f85e8df3305bd00ddd4be7
parent63e38dad8de9d59edc2b75433b4e3f761eda0f3c (diff)
[ADD] auth_totp
New module for supporting two-factor authentication via time-base one-time-password (TOTP). Users (including portal users) can choose to enable two-factor auth in their user account settings, by scanning a QR code and adding it to an authenticator app, such as Google Auth, 1Password, etc. When two-factor is enabled, password-based non-interactive RPC is only possible by using API keys. Co-authored-by: Olivier Dony <odo@odoo.com>
-rw-r--r--addons/auth_totp/__init__.py3
-rw-r--r--addons/auth_totp/__manifest__.py25
-rw-r--r--addons/auth_totp/controllers/__init__.py2
-rw-r--r--addons/auth_totp/controllers/home.py38
-rw-r--r--addons/auth_totp/models/__init__.py3
-rw-r--r--addons/auth_totp/models/ir_http.py13
-rw-r--r--addons/auth_totp/models/res_users.py197
-rw-r--r--addons/auth_totp/security/security.xml16
-rw-r--r--addons/auth_totp/static/tests/totp_flow.js249
-rw-r--r--addons/auth_totp/tests/__init__.py1
-rw-r--r--addons/auth_totp/tests/test_totp.py57
-rw-r--r--addons/auth_totp/views/templates.xml38
-rw-r--r--addons/auth_totp/views/user_preferences.xml108
-rw-r--r--addons/auth_totp_portal/__init__.py1
-rw-r--r--addons/auth_totp_portal/__manifest__.py10
-rw-r--r--addons/auth_totp_portal/security/security.xml16
-rw-r--r--addons/auth_totp_portal/static/src/js/totp_frontend.js226
-rw-r--r--addons/auth_totp_portal/static/tests/totp_portal.js124
-rw-r--r--addons/auth_totp_portal/tests/__init__.py1
-rw-r--r--addons/auth_totp_portal/tests/test_tour.py44
-rw-r--r--addons/auth_totp_portal/views/templates.xml48
-rw-r--r--addons/hr/tests/test_self_user_access.py1
-rw-r--r--addons/web/controllers/main.py34
-rw-r--r--addons/website/controllers/main.py2
-rw-r--r--odoo/http.py7
25 files changed, 1247 insertions, 17 deletions
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 @@
+<odoo>
+ <record model="ir.model.access" id="access_auth_totp_wizard">
+ <field name="name">auth_totp wizard access rules</field>
+ <field name="model_id" ref="model_auth_totp_wizard"/>
+ <field name="group_id" ref="base.group_user"/>
+ <field name="perm_read">1</field>
+ <field name="perm_write">1</field>
+ <field name="perm_create">1</field>
+ <field name="perm_unlink">1</field>
+ </record>
+ <record model="ir.rule" id="rule_auth_totp_wizard">
+ <field name="name">Users can only access their own wizard</field>
+ <field name="model_id" ref="model_auth_totp_wizard"/>
+ <field name="domain_force">[('user_id', '=', user.id)]</field>
+ </record>
+</odoo>
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 @@
+<odoo>
+ <template id="assets_tests" inherit_id="web.assets_tests">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/auth_totp/static/tests/totp_flow.js"></script>
+ </xpath>
+ </template>
+ <template id="auth_totp_form">
+ <t t-call="web.login_layout">
+ <t t-set="disable_footer">1</t>
+ <h5 class="card-title">Two-factor Authentication</h5>
+ <div class="oe_login_form">
+ <form method="POST" action="" class="">
+ <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+ <input type="hidden" name="redirect" t-att-value="redirect"/>
+ <div class="form-group">
+ <label for="totp_token">Authentication Code</label>
+ <input name="totp_token" class="form-control mb-2"
+ autofocus="autofocus" required="required" placeholder="6-digit code"/>
+ </div>
+ <p class="alert alert-danger" t-if="error" role="alert">
+ <t t-esc="error"/>
+ </p>
+ <div t-attf-class="clearfix oe_login_buttons text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}">
+ <button type="submit" class="btn btn-primary btn-block">
+ Verify
+ </button>
+ </div>
+ </form>
+ <form method="POST" action="/web/session/logout" class="form-inline">
+ <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
+ <button type="submit" class="btn btn-link btn-sm mb-2">
+ Cancel
+ </button>
+ </form>
+ </div>
+ </t>
+ </template>
+</odoo>
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 @@
+<odoo>
+ <record model="ir.ui.view" id="view_totp_list">
+ <field name="name">users list: add totp status</field>
+ <field name="model">res.users</field>
+ <field name="inherit_id" ref="base.view_users_tree"/>
+ <field name="arch" type="xml">
+ <tree>
+ <field name="totp_enabled"/>
+ </tree>
+ </field>
+ </record>
+ <record model="ir.actions.server" id="action_disable_totp">
+ <field name="name">Disable TOTP on users</field>
+ <field name="model_id" ref="base.model_res_users"/>
+ <field name="binding_model_id" ref="base.model_res_users"/>
+ <field name="binding_view_types">list</field>
+ <field name="state">code</field>
+ <field name="code">
+ action = records.totp_disable()
+ </field>
+ <field name="groups_id" eval="[(4, ref('base.group_erp_manager'), 0)]"/>
+ </record>
+
+ <record model="ir.ui.view" id="view_totp_wizard">
+ <field name="name">auth_totp wizard</field>
+ <field name="model">auth_totp.wizard</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <div class="row container">
+ <div class="col-lg-6 offset-lg-3 mb-3">
+ <h3 class="font-weight-bold">Scan this barcode with your app</h3>
+ <div>
+ Scan the image below with the authenticator app on your phone.<br/>
+ If you cannot scan the barcode, here are some alternative options:
+ <ul>
+ <li><field class="text-wrap" name="url" widget="url"
+ options="{'website_path': True}"
+ text="Click on this link to open your authenticator app"/></li>
+
+ <li>Or enter the secret code manually:
+ <a data-toggle="collapse"
+ href="#collapseTotpSecret" role="button" aria-expanded="false"
+ aria-controls="collapseTotpSecret">show the code</a>
+ </li>
+ </ul>
+ <!-- code outside list to have more horiz space on mobile -->
+ <div class="collapse" id="collapseTotpSecret">
+ <div class="card card-body">
+ <h3>Your two-factor secret:</h3>
+ <code><field name="secret"/></code>
+ </div>
+ </div>
+ </div>
+
+ <field class="offset-1 offset-lg-2" name="qrcode" readonly="True" widget="image"/>
+
+ <h3 class="font-weight-bold">Enter the 6-digit code from your app</h3>
+ <div class="text-justify">
+ 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.
+ </div>
+ <div class="mt-2">
+ <label for="code">Verification Code</label>
+ <field required="True" name="code"/>
+ </div>
+ <button type="object" name="enable" class="btn btn-primary"
+ string="Enable two-factor authentication"/>
+ </div>
+ </div>
+ </sheet>
+ <footer><!-- no buttons --></footer>
+ </form>
+ </field>
+ </record>
+
+ <record model="ir.ui.view" id="view_totp_field">
+ <field name="name">users preference: totp</field>
+ <field name="model">res.users</field>
+ <field name="inherit_id" ref="base.view_users_form_simple_modif"/>
+ <field name="arch" type="xml">
+ <button name="preference_change_password" position="after">
+ <field name="totp_enabled" invisible="1"/>
+ <group attrs="{'invisible': [('totp_enabled', '!=', False)]}">
+ <div>
+ <span class="alert alert-info" role="status">
+ <i class="fa fa-warning"/>
+ Two-factor authentication not enabled
+ </span>
+ <button name="totp_enable_wizard" type="object" string="Enable two-factor authentication"
+ class="btn btn-info mx-3"/>
+ </div>
+ </group>
+ <group attrs="{'invisible': [('totp_enabled', '=', False)]}">
+ <div>
+ <span class="text-success">
+ <i class="fa fa-check-circle"/>
+ Two-factor authentication enabled
+ </span>
+ <button name="totp_disable" type="object" string="(Disable two-factor authentication)"
+ class="btn btn-link text-muted"/>
+ </div>
+ </group>
+ </button>
+ </field>
+ </record>
+</odoo>
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 @@
+<odoo>
+ <!-- copies ACL for portal users -->
+ <record model="ir.model.access" id="access_auth_totp_portal_wizard">
+ <field name="name">auth_totp_portal wizard access rules</field>
+ <field name="model_id" ref="auth_totp.model_auth_totp_wizard"/>
+ <field name="group_id" ref="base.group_portal"/>
+ <field name="perm_read">1</field>
+ <field name="perm_write">1</field>
+ <field name="perm_create">1</field>
+ <field name="perm_unlink">1</field>
+ </record>
+ <!-- adds portal users to rule -->
+ <record model="ir.rule" id="auth_totp.rule_auth_totp_wizard">
+ <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
+ </record>
+</odoo>
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 <field> 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<oldNode.attributes.length; ++i) {
+ const attr = oldNode.attributes[i];
+ node.setAttribute(attr.name, attr.value);
+ }
+ for(let j=0; j<oldNode.childNodes.length; ++j) {
+ const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record);
+ if (ch) { node.appendChild(ch); }
+ if (qr) { qrcode = qr; }
+ if (co) { code = co; }
+ }
+ break;
+ case 3: case 4: // text, cdata
+ node = document.createTextNode(oldNode.data);
+ break;
+ default:
+ // don't care about PI & al
+ }
+
+ return [node, qrcode, code]
+}
+
+/**
+ * Converts a backend <button> 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 @@
+<odoo>
+ <template id="auth_totp_frontend" name="TOTP frontend (portal)" inherit_id="portal.assets_frontend">
+ <xpath expr="." position="inside">
+ <script type="application/javascript"
+ src="/auth_totp_portal/static/src/js/totp_frontend.js"/>
+ </xpath>
+ </template>
+ <template id="assets_tests" inherit_id="web.assets_tests">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/auth_totp_portal/static/tests/totp_portal.js"></script>
+ </xpath>
+ </template>
+
+ <template id="totp_portal_hook" name="TOTP Portal hook" inherit_id="portal.portal_my_security">
+ <xpath expr="//div[hasclass('o_portal_security_body')]">
+ <!--
+ portal users don't have access to non-qweb views anymore, so
+ embed the target view as a *data island* of sorts, a JSON embed
+ of the fields_view_get could have been nice but at 14k it'd be a
+ bit ridiculous
+ -->
+ <div class="d-none" id="totp_wizard_view">
+ <t t-esc="env.ref('auth_totp.view_totp_wizard').sudo().read_combined(['arch'])['arch']"/>
+ </div>
+ <section>
+ <h3>Two-factor authentication</h3>
+ <t t-if="not user_id.totp_enabled">
+ <div class="alert alert-secondary" role="status">
+ <i class="fa fa-warning"/>
+ Two-factor authentication not enabled
+ </div>
+ <button type="button" class="btn btn-secondary" id="auth_totp_portal_enable">
+ Enable two-factor authentication
+ </button>
+ </t>
+ <t t-else="">
+ <span class="text-success">
+ <i class="fa fa-check-circle"/>
+ Two-factor authentication enabled
+ </span>
+ <button type="button" class="btn btn-link" id="auth_totp_portal_disable">
+ (Disable two-factor authentication)
+ </button>
+ </t>
+ </section>
+ </xpath>
+ </template>
+</odoo>
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/web/controllers/main.py b/addons/web/controllers/main.py
index e3996348043..6e5bd95432c 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -297,8 +297,26 @@ def make_conditional(response, last_modified=None, etag=None, max_age=0):
response.set_etag(etag)
return response.make_conditional(request.httprequest)
+def _get_login_redirect_url(uid, redirect=None):
+ """ Decide if user requires a specific post-login redirect, e.g. for 2FA, or if they are
+ fully logged and can proceed to the requested URL
+ """
+ if request.session.uid: # fully logged
+ return redirect or '/web'
+
+ # partial session (MFA)
+ url = request.env(user=uid)['res.users'].browse(uid)._mfa_url()
+ if not redirect:
+ return url
+
+ parsed = werkzeug.urls.url_parse(url)
+ qs = parsed.decode_query()
+ qs['redirect'] = redirect
+ return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
+
def login_and_redirect(db, login, key, redirect_url='/web'):
- request.session.authenticate(db, login, key)
+ 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):
@@ -869,18 +887,7 @@ class Home(http.Controller):
return response
def _login_redirect(self, uid, redirect=None):
- if request.session.uid: # fully logged
- return redirect or '/web'
-
- # partial session (MFA)
- url = request.env(user=uid)['res.users'].browse(uid)._mfa_url()
- if not redirect:
- return url
-
- parsed = werkzeug.urls.url_parse(url)
- qs = parsed.decode_query()
- qs['redirect'] = redirect
- return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
+ return _get_login_redirect_url(uid, redirect)
@http.route('/web/login', type='http', auth="none")
def web_login(self, redirect=None, **kw):
@@ -929,6 +936,7 @@ class Home(http.Controller):
uid = request.env.user.id
if request.env.user._is_system():
uid = request.session.uid = odoo.SUPERUSER_ID
+ # invalidate session token cache as we've changed the uid
request.env['res.users'].clear_caches()
request.session.session_token = security.compute_session_token(request.session, request.env)
diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py
index 8f2bc697c62..d5f242e02f9 100644
--- a/addons/website/controllers/main.py
+++ b/addons/website/controllers/main.py
@@ -106,9 +106,11 @@ class Website(Home):
redirect = '/my'
return super()._login_redirect(uid, redirect=redirect)
+ # Force website=True + auth='public', required for login form layout
@http.route(website=True, auth="public", sitemap=False)
def web_login(self, *args, **kw):
return super().web_login(*args, **kw)
+
# ------------------------------------------------------
# Business
# ------------------------------------------------------
diff --git a/odoo/http.py b/odoo/http.py
index d025e7fac31..b794dce36f8 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -977,10 +977,9 @@ class OpenERPSession(sessions.Session):
"""
Authenticate the current user with the given db, login and
password. If successful, store the authentication parameters in the
- current session and request.
-
- :param uid: If not None, that user id will be used instead the login
- to authenticate the user.
+ current session and request, unless multi-factor-authentication
+ is activated. In that case, that last part will be done by
+ :ref:`finalize`.
"""
wsgienv = request.httprequest.environ