From 82985296e19ae4bb80c0ede1edce1b192e8c1b86 Mon Sep 17 00:00:00 2001 From: "Thomas (thbe)" Date: Tue, 28 Feb 2023 15:23:08 +0000 Subject: [PATCH] [ADD] base: Revoke access to your account to all devices Prior to this, there was no way to shut an active session you didn't have the access to anymore or close every active session remotely. Adding the RevokeAllDevices class, it is now possible to close every open sessions, including the one you are performing the action on of the account you are logged into via the Account Security page in the preferences. This system is based on the principle that every open session uses the password hash to maintain it. When the hash is computed, salt is added to it to make it not reversible. Thanks to that salt, it is possible to change the user's password's hash without changing the password. Therefore, the flow of this addition is the following : 1. User clicks on the button and uses his passord to confirm identity. 2. User's input is used to check his identity (_check_credentials()). 3. User's input is used to refresh the password hash in the db (_change_password()). 4. User is logged out. This adds a way to close all sessions remotely and improve user's control over their account and therefore their security. task-3191567 closes odoo/odoo#113899 Related: odoo/upgrade#4951 Signed-off-by: Martin Trigaux (mat) --- .../static/src/js/totp_frontend.js | 2 +- addons/portal/__manifest__.py | 1 + addons/portal/static/src/js/portal.js | 184 ------------ .../portal/static/src/js/portal_security.js | 266 ++++++++++++++++++ .../portal/static/src/xml/portal_security.xml | 21 ++ addons/portal/views/portal_templates.xml | 7 + odoo/addons/base/__manifest__.py | 1 + odoo/addons/base/models/res_users.py | 28 +- .../views/res_users_identitycheck_views.xml | 45 +++ odoo/addons/base/views/res_users_views.xml | 26 +- 10 files changed, 372 insertions(+), 209 deletions(-) create mode 100644 addons/portal/static/src/js/portal_security.js create mode 100644 odoo/addons/base/views/res_users_identitycheck_views.xml diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js index 7bc1067da8c..c8c916bae8d 100644 --- a/addons/auth_totp_portal/static/src/js/totp_frontend.js +++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js @@ -3,7 +3,7 @@ import {_t} from "web.core"; import publicWidget from "web.public.widget"; import Dialog from "web.Dialog"; -import {handleCheckIdentity} from "portal.portal"; +import {handleCheckIdentity} from "portal.security"; /** * Replaces specific elements by normal HTML, strip out the rest entirely diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py index 3561e462e5a..66ec2599ef4 100644 --- a/addons/portal/__manifest__.py +++ b/addons/portal/__manifest__.py @@ -42,6 +42,7 @@ a dependency towards website editing and customization capabilities.""", 'portal/static/src/js/portal_chatter.js', 'portal/static/src/xml/portal_chatter.xml', 'portal/static/src/js/portal_composer.js', + 'portal/static/src/js/portal_security.js', 'portal/static/src/js/portal_signature.js', 'portal/static/src/xml/portal_signature.xml', 'portal/static/src/js/portal_sidebar.js', diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js index 05ef0f2f25c..7efa4580800 100644 --- a/addons/portal/static/src/js/portal.js +++ b/addons/portal/static/src/js/portal.js @@ -1,9 +1,6 @@ /** @odoo-module alias=portal.portal **/ import publicWidget from "web.public.widget"; -import Dialog from "web.Dialog"; -import {_t, qweb} from "web.core"; -import session from "web.session"; publicWidget.registry.portalDetails = publicWidget.Widget.extend({ selector: '.o_portal_details', @@ -177,184 +174,3 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({ this._search(); }, }); - -publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({ - selector: '.o_portal_new_api_key', - events: { - click: '_onClick' - }, - - async _onClick(e){ - e.preventDefault(); - // This call is done just so it asks for the password confirmation before starting displaying the - // dialog forms, to mimic the behavior from the backend, in which it asks for the password before - // displaying the wizard. - // The result of the call is unused. But it's required to call a method with the decorator `@check_identity` - // in order to use `handleCheckIdentity`. - await handleCheckIdentity(this.proxy('_rpc'), this._rpc({ - model: 'res.users', - method: 'api_key_wizard', - args: [session.user_id], - })); - const self = this; - const d_description = new Dialog(self, { - title: _t('New API Key'), - $content: qweb.render('portal.keydescription'), - buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => { - var description = d_description.el.querySelector('[name="description"]').value; - var wizard_id = await this._rpc({ - model: 'res.users.apikeys.description', - method: 'create', - args: [{name: description}], - }); - var res = await handleCheckIdentity( - this.proxy('_rpc'), - this._rpc({ - model: 'res.users.apikeys.description', - method: 'make_key', - args: [wizard_id], - }) - ); - const d_show = new Dialog(self, { - title: _t('API Key Ready'), - $content: qweb.render('portal.keyshow', {key: res.context.default_key}), - buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}], - }); - d_show.on('closed', this, () => { - window.location = window.location; - }); - d_show.open(); - }}, {text: _t('Discard'), close: true}], - }); - d_description.opened(() => { - const input = d_description.el.querySelector('[name="description"]'); - input.focus(); - d_description.el.addEventListener('submit', (e) => { - e.preventDefault(); - d_description.$footer.find('.btn-primary').click(); - }); - }); - d_description.open(); - } -}); - -publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({ - selector: '.o_portal_remove_api_key', - events: { - click: '_onClick' - }, - - async _onClick(e){ - e.preventDefault(); - await handleCheckIdentity( - this.proxy('_rpc'), - this._rpc({ - model: 'res.users.apikeys', - method: 'remove', - args: [parseInt(this.el.id)] - }) - ); - window.location = window.location; - } -}); - -publicWidget.registry.portalSecurity = publicWidget.Widget.extend({ - selector: '.o_portal_security_body', - - /** - * @override - */ - init: function () { - // Show the "deactivate your account" modal if needed - $('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show'); - - // Remove the error messages when we close the modal, - // so when we re-open it again we get a fresh new form - $('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => { - const $target = $(event.currentTarget); - $target.find('.alert').remove(); - $target.find('.invalid-feedback').remove(); - $target.find('.is-invalid').removeClass('is-invalid'); - }); - - return this._super(...arguments); - }, - -}); - -/** - * 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 (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) { - return r; - } - const check_id = r.res_id; - return 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(); - }); - }); -} -export default { - handleCheckIdentity, -} diff --git a/addons/portal/static/src/js/portal_security.js b/addons/portal/static/src/js/portal_security.js new file mode 100644 index 00000000000..6c1de4babd0 --- /dev/null +++ b/addons/portal/static/src/js/portal_security.js @@ -0,0 +1,266 @@ +/** @odoo-module alias=portal.security **/ + +import publicWidget from "web.public.widget"; +import Dialog from "web.Dialog"; +import {_t, qweb} from "web.core"; +import session from "web.session"; + +publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({ + selector: '.o_portal_new_api_key', + events: { + click: '_onClick' + }, + + async _onClick(e){ + e.preventDefault(); + // This call is done just so it asks for the password confirmation before starting displaying the + // dialog forms, to mimic the behavior from the backend, in which it asks for the password before + // displaying the wizard. + // The result of the call is unused. But it's required to call a method with the decorator `@check_identity` + // in order to use `handleCheckIdentity`. + await handleCheckIdentity(this.proxy('_rpc'), this._rpc({ + model: 'res.users', + method: 'api_key_wizard', + args: [session.user_id], + })); + const self = this; + const d_description = new Dialog(self, { + title: _t('New API Key'), + $content: qweb.render('portal.keydescription'), + buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => { + var description = d_description.el.querySelector('[name="description"]').value; + var wizard_id = await this._rpc({ + model: 'res.users.apikeys.description', + method: 'create', + args: [{name: description}], + }); + var res = await handleCheckIdentity( + this.proxy('_rpc'), + this._rpc({ + model: 'res.users.apikeys.description', + method: 'make_key', + args: [wizard_id], + }) + ); + const d_show = new Dialog(self, { + title: _t('API Key Ready'), + $content: qweb.render('portal.keyshow', {key: res.context.default_key}), + buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}], + }); + d_show.on('closed', this, () => { + window.location = window.location; + }); + d_show.open(); + }}, {text: _t('Discard'), close: true}], + }); + d_description.opened(() => { + const input = d_description.el.querySelector('[name="description"]'); + input.focus(); + d_description.el.addEventListener('submit', (e) => { + e.preventDefault(); + d_description.$footer.find('.btn-primary').click(); + }); + }); + d_description.open(); + } +}); + +publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({ + selector: '.o_portal_remove_api_key', + events: { + click: '_onClick' + }, + + async _onClick(e){ + e.preventDefault(); + await handleCheckIdentity( + this.proxy('_rpc'), + this._rpc({ + model: 'res.users.apikeys', + method: 'remove', + args: [parseInt(this.el.id)] + }) + ); + window.location = window.location; + } +}); + +publicWidget.registry.portalSecurity = publicWidget.Widget.extend({ + selector: '.o_portal_security_body', + + /** + * @override + */ + init: function () { + // Show the "deactivate your account" modal if needed + $('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show'); + + // Remove the error messages when we close the modal, + // so when we re-open it again we get a fresh new form + $('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => { + const $target = $(event.currentTarget); + $target.find('.alert').remove(); + $target.find('.invalid-feedback').remove(); + $target.find('.is-invalid').removeClass('is-invalid'); + }); + + return this._super(...arguments); + }, + +}); + +/** + * Defining what happens when you click the "Log out from all devices" + * on the "/my/security" page. + */ +publicWidget.registry.RevokeSessionsButton = publicWidget.Widget.extend({ + selector: '#portal_revoke_all_sessions_popup', + events: { + click: '_onClick', + }, + + async _onClick() { + const rpc = this.proxy('_rpc'); + const wrapped = this._rpc({ + model: 'res.users', + method: 'api_key_wizard', + args: [session.user_id], + }); + await handleRevokeSessions(rpc, wrapped); + window.location.href = "/web/session/logout?redirect=/"; + + function handleRevokeSessions(rpc, wrapped) { + return wrapped.then((inst) => { + const check_id = inst.res_id + var $content = $(qweb.render("portal.revoke_all_devices_popup_template")); + return new Promise((resolve) => { + var dialog = new Dialog(this, { + title: _t("Log out from all devices?"), + $content, + buttons: [{ + text: _t("Log out from all devices"), classes: 'btn btn-primary', + // nb: if click & close, waits for click to resolve before closing + async click() { + const password_input = this.el.querySelector('[name=password]'); + if (!password_input.reportValidity()) { + password_input.classList.add('is-invalid'); + return; + }; + await rpc({ + model: 'res.users.identitycheck', + method: 'write', + args: [check_id, {password: password_input.value}] + }); + await rpc({ + model: 'res.users.identitycheck', + method: 'revoke_all_devices', + args: [check_id] + }).then((inst) => { + this.close(); + resolve(inst); + }, (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 + }] + }); + dialog.opened(() => { + const password_input = dialog.el.querySelector('[name="password"]'); + password_input.focus(); + password_input.addEventListener('input', () => { + password_input.classList.remove('is-invalid'); + password_input.setCustomValidity(''); + }); + dialog.el.addEventListener('submit', (e) => { + e.preventDefault(); + dialog.$footer.find('.btn-primary').click(); + }); + }); + dialog.open(); + }) + }) + } + } +}); + +/** + * 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 (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) { + return r; + } + const check_id = r.res_id; + return 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(); + }); + }); +} +export default { + handleCheckIdentity, +} diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml index 290c1779e61..34aa2a2071d 100644 --- a/addons/portal/static/src/xml/portal_security.xml +++ b/addons/portal/static/src/xml/portal_security.xml @@ -38,4 +38,25 @@

+ + +
+
+ You are about to log out from all devices that currently have access to this user's account. +

+ password +
+ +
+ + +
+ +
+
+
+
+
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 33523366e21..dd786494a70 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -531,6 +531,13 @@ +
+

Revoke All Sessions

+ + +

Developer API Keys diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py index bd4488558c6..d77e0f11e8e 100644 --- a/odoo/addons/base/__manifest__.py +++ b/odoo/addons/base/__manifest__.py @@ -69,6 +69,7 @@ The kernel of Odoo, needed for all installation. 'views/res_country_views.xml', 'views/res_currency_views.xml', 'views/res_users_views.xml', + 'views/res_users_identitycheck_views.xml', 'views/ir_property_views.xml', 'views/res_config_settings_views.xml', 'views/report_paperformat_views.xml', diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index d929f224247..c8e53839e5f 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -964,6 +964,18 @@ class Users(models.Model): 'view_mode': 'form', } + def action_revoke_all_devices(self): + ctx = dict(self.env.context, dialog_size='medium') + return { + 'name': _('Log out from all devices?'), + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_model': 'res.users.identitycheck', + 'view_mode': 'form', + 'view_id': self.env.ref('base.res_users_identitycheck_view_form_revokedevices').id, + 'context': ctx, + } + @api.model def has_group(self, group_ext_id): # use singleton's id if called on a non-empty recordset, otherwise @@ -1872,7 +1884,8 @@ class UsersView(models.Model): return res class CheckIdentity(models.TransientModel): - """ Wizard used to re-check the user's credentials (password) + """ Wizard used to re-check the user's credentials (password) and eventually + revoke access to his account to every device he has an active session on. Might be useful before the more security-sensitive operations, users might be leaving their computer unlocked & unattended. Re-checking credentials mitigates @@ -1885,13 +1898,15 @@ class CheckIdentity(models.TransientModel): request = fields.Char(readonly=True, groups=fields.NO_ACCESS) password = fields.Char() - def run_check(self): - assert request, "This method can only be accessed over HTTP" + def _check_identity(self): try: self.create_uid._check_credentials(self.password, {'interactive': True}) except AccessDenied: raise UserError(_("Incorrect Password, try again or click on Forgot Password to reset your password.")) + def run_check(self): + assert request, "This method can only be accessed over HTTP" + self._check_identity() self.password = False request.session['identity-check-last'] = time.time() @@ -1900,6 +1915,12 @@ class CheckIdentity(models.TransientModel): assert getattr(method, '__has_check_identity', False) return method() + def revoke_all_devices(self): + self._check_identity() + self.env.user._change_password(self.password) + self.sudo().unlink() + return {'type': 'ir.actions.client', 'tag': 'reload'} + #---------------------------------------------------------- # change password wizard #---------------------------------------------------------- @@ -1908,6 +1929,7 @@ class ChangePasswordWizard(models.TransientModel): """ A wizard to manage the change of users' passwords. """ _name = "change.password.wizard" _description = "Change Password Wizard" + _transient_max_hours = 0.2 def _default_user_ids(self): user_ids = self._context.get('active_model') == 'res.users' and self._context.get('active_ids') or [] diff --git a/odoo/addons/base/views/res_users_identitycheck_views.xml b/odoo/addons/base/views/res_users_identitycheck_views.xml new file mode 100644 index 00000000000..966b5a70c0e --- /dev/null +++ b/odoo/addons/base/views/res_users_identitycheck_views.xml @@ -0,0 +1,45 @@ + + + + + + res.users.identitycheck + +
+ +

Please enter your password to confirm you own this account

+
+ +
+ Forgot password? +
+
+
+
+
+
+ + + Revoke All Devices + res.users.identitycheck + 40 + +
+
+ You are about to log out from all devices that currently have access to your account.

+ Type in your password to confirm : + +
+
+
+
+
+
+ +
+
diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml index e9d8840d610..33ba6d6a393 100644 --- a/odoo/addons/base/views/res_users_views.xml +++ b/odoo/addons/base/views/res_users_views.xml @@ -52,27 +52,6 @@ - - res.users.identitycheck - -
- -

Please enter your password to confirm you own this account

-
-
- -
- Forgot password? -
-
-
-
-
-
- res.groups.search @@ -493,6 +472,11 @@