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 @@