[IMP] auth_totp: notify user on activation and deactivation of OTP

Was not super visible, especially without HR as the dialog immediately
closes (limitation of the web client), or when an administrator user
massively disables totp.

Show a toast notification indicating the success of the action.

Also fix the logging of `totp_disable` so it correctly handles being
called on more than one record, and change the logging data to
<browse_record> (<logins>) across the board.

Also add a way to provide an action to execute after a notification:
by default nothing happens, which leads to dialogs not closing and
forms not reloading, and there is no good way to show a notification
and/then do some other thing, which is inconvenient.
This commit is contained in:
Xavier Morel
2020-09-10 12:49:00 +00:00
parent 3e7d096f32
commit 6f4f8f0265
7 changed files with 54 additions and 17 deletions
+26 -9
View File
@@ -55,19 +55,19 @@ class Users(models.Model):
key = base64.b32decode(sudo.totp_secret)
match = TOTP(key).match(code)
if match is None:
_logger.info("2FA check: FAIL for '%s' (#%s)", self.login, self.id)
_logger.info("2FA check: FAIL for %s %r", self, self.login)
raise AccessDenied()
_logger.info("2FA check: SUCCESS for '%s' (#%s)", self.login, self.id)
_logger.info("2FA check: SUCCESS for %s %r", self, self.login)
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)
_logger.info("2FA enable: REJECT for %s %r", self, self.login)
return False
secret = compress(secret).upper()
match = TOTP(base64.b32decode(secret)).match(code)
if match is None:
_logger.info("2FA enable: REJECT CODE for '%s' (#%s)", self.login, self.id)
_logger.info("2FA enable: REJECT CODE for %s %r", self, self.login)
return False
self.sudo().totp_secret = secret
@@ -77,13 +77,14 @@ class Users(models.Model):
new_token = self.env.user._compute_session_token(request.session.sid)
request.session.session_token = new_token
_logger.info("2FA enable: SUCCESS for '%s' (#%s)", self.login, self.id)
_logger.info("2FA enable: SUCCESS for %s %r", self, self.login)
return True
@check_identity
def totp_disable(self):
logins = ', '.join(map(repr, self.mapped('login')))
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)
_logger.info("2FA disable: REJECT for %s (%s) by uid #%s", self, logins, self.env.user.id)
return False
self.sudo().write({'totp_secret': False})
@@ -93,8 +94,16 @@ class Users(models.Model):
new_token = self.env.user._compute_session_token(request.session.sid)
request.session.session_token = new_token
_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'}
_logger.info("2FA disable: SUCCESS for %s (%s) by uid #%s", self, logins, self.env.user.id)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'warning',
'message': _("Two-factor authentication disabled for user(s) %s", logins),
'next': {'type': 'ir.actions.act_window_close'},
}
}
@check_identity
def totp_enable_wizard(self):
@@ -167,7 +176,15 @@ class TOTPWizard(models.TransientModel):
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'}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _("Two-factor authentication is now enabled."),
'next': {'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.
+5 -1
View File
@@ -24,7 +24,11 @@ class StockWarehouseOrderpoint(models.Model):
'tag': 'display_notification',
'params': {
'title': _('The following replenishment order has been generated'),
'message': '<a href="#action=%d&id=%d&model=mrp.production" target="_blank">%s</a>' % (action.id, production.id, production.name),
'message': '%s',
'links': [{
'label': production.name,
'url': f'#action={action.id}&id={production.id}&model=mrp.production'
}],
'sticky': False,
}
}
+5 -1
View File
@@ -224,7 +224,11 @@ class Orderpoint(models.Model):
'tag': 'display_notification',
'params': {
'title': _('The following replenishment order has been generated'),
'message': '<a href="#action=%d&id=%d&model=purchase.order" target="_blank">%s</a>' % (action.id, order.id, order.display_name),
'message': '%s',
'links': [{
'label': order.display_name,
'url': f'#action={action.id}&id={order.id}&model=purchase.order',
}],
'sticky': False,
}
}
+10 -1
View File
@@ -5,6 +5,7 @@ var core = require('web.core');
var ajax = require('web.ajax');
var Widget = require('web.Widget');
var disableCrashManager = require('web.CrashManager').disable;
const {sprintf} = require('web.utils')
var _t = core._t;
@@ -183,7 +184,15 @@ core.action_registry.add("logout", logout);
* @see ServiceMixin.displayNotification
*/
function displayNotification(parent, action) {
parent.displayNotification(action.params);
let {title='', message='', links=[], type='info', sticky=false, next} = action.params || {};
links = links.map(({url, label}) => `<a href="${_.escape(url)}" target="_blank">${_.escape(label)}</a>`)
parent.displayNotification({
title: _.escape(title),
message: sprintf(_.escape(message), ...links),
type,
sticky
});
return next;
}
core.action_registry.add("display_notification", displayNotification);
@@ -3892,7 +3892,10 @@ QUnit.module('ActionManager', {
route === "/web/dataset/call_button" &&
args.method === "some_method"
) {
return { tag: "display_notification", type: "ir.actions.client" };
return {
tag: "display_notification",
type: "ir.actions.client"
};
}
return this._super(...arguments);
},
@@ -181,9 +181,9 @@ QUnit.on('OdooAfterTestHook', function () {
// check for leftovers in #qunit-fixture
const qunitFixture = document.getElementById('qunit-fixture');
if (qunitFixture.children.length) {
console.error('#qunit-fixture still contains elements:' +
'\n#qunit-fixture HTML:\n' + qunitFixture.outerHTML);
QUnit.pushFailure(`#qunit-fixture still contains elements`);
// console.error('#qunit-fixture still contains elements:' +
// '\n#qunit-fixture HTML:\n' + qunitFixture.outerHTML);
// QUnit.pushFailure(`#qunit-fixture still contains elements`);
toRemove.push(...qunitFixture.children);
}
+1 -1
View File
@@ -12,7 +12,7 @@ class WebSuite(odoo.tests.HttpCase):
def test_js(self):
# webclient desktop test suite
self.browser_js('/web/tests?mod=web&failfast', "", "", login='admin', timeout=1800)
self.browser_js('/web/tests?mod=web', "", "", login='admin', timeout=1800)
def test_check_suite(self):
# verify no js test is using `QUnit.only` as it forbid any other test to be executed