[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) <mat@odoo.com>
This commit is contained in:
Thomas (thbe)
2023-07-18 19:27:19 +02:00
committed by Martin Trigaux
parent 21e1a2eac8
commit 82985296e1
10 changed files with 372 additions and 209 deletions
@@ -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 <field> elements by normal HTML, strip out the rest entirely
+1
View File
@@ -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',
-184
View File
@@ -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,
}
@@ -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,
}
@@ -38,4 +38,25 @@
</p>
</div>
</t>
<!-- Popup's view !-->
<t t-name="portal.revoke_all_devices_popup_template">
<section>
<div>
You are about to log out from all devices that currently have access to this user's account.
</div><br/>
<t t-set="path">password</t>
<form action="/my/security" method="post" class="oe_reset_password_form" >
<input type="hidden" name="op" value="revoke"/>
<div class="mb-3">
<label for="current">Type in your password to confirm :</label>
<input type="password" t-attf-class="form-control form-control-sm"
id="current" name="password"
autocomplete="current-password" required="required"/>
<div class="invalid-feedback">
<t t-esc="error"/>
</div>
</div>
</form>
</section>
</t>
</templates>
+7
View File
@@ -531,6 +531,13 @@
<button type="submit" class="btn btn-secondary">Change Password</button>
</form>
</section>
<section name="portal_revoke_all_devices_popup">
<h3>Revoke All Sessions</h3>
<t t-set="path"/>
<button type="button" class="btn btn-secondary" id="portal_revoke_all_sessions_popup">
Log out from all devices
</button>
</section>
<section t-if="debug and allow_api_keys">
<h3>
Developer API Keys
+1
View File
@@ -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',
+25 -3
View File
@@ -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 []
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_users_identitycheck_view_form" model="ir.ui.view">
<field name="model">res.users.identitycheck</field>
<field name="arch" type="xml">
<form string="Password Confirmation">
<sheet class="bg-primary">
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
<div>
<field class="o_field_highlight col-10 col-md-6 px-0" name="password" autocomplete="current-password"
required="True" password="True" placeholder="************"/>
</div>
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
</sheet>
<footer>
<button string="Confirm Password" type="object" name="run_check" class="btn btn-primary" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="z" class="btn btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="res_users_identitycheck_view_form_revokedevices" model="ir.ui.view">
<field name="name">Revoke All Devices</field>
<field name="model">res.users.identitycheck</field>
<field name="priority">40</field>
<field name="arch" type="xml">
<form string="Log out from all devices">
<div>
You are about to log out from all devices that currently have access to your account.<br/><br/>
<strong>Type in your password to confirm :</strong>
<field class="oe_inline o_field_highlight" name="password" password="True" required="True"/>
</div>
<footer>
<button string="Log out from all devices" name="revoke_all_devices" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
</data>
</odoo>
+5 -21
View File
@@ -52,27 +52,6 @@
<field name="binding_model_id" ref="base.model_res_users"/>
</record>
<record id="identity_check_wizard" model="ir.ui.view">
<field name="model">res.users.identitycheck</field>
<field name="arch" type="xml">
<form string="Password Confirmation">
<sheet class="bg-primary">
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
<br/>
<div>
<field class="o_field_highlight col-10 col-md-6 px-0" name="password" autocomplete="current-password"
required="True" password="True" placeholder="************"/>
</div>
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
</sheet>
<footer>
<button string="Confirm Password" type="object" name="run_check" class="btn btn-primary" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="z" class="btn btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- res.groups -->
<record id="view_groups_search" model="ir.ui.view">
<field name="name">res.groups.search</field>
@@ -493,6 +472,11 @@
<button name="preference_change_password" type="object" string="Change password" class="btn btn-secondary"/>
</div>
</group>
<group name="access" string="Other Devices">
<div colspan="2">
<button name="action_revoke_all_devices" type="object" string="Log out from all devices" class="btn btn-secondary"/>
</div>
</group>
<group string="API Keys">
<div class="text-muted" colspan="2">
API Keys are used to connect to Odoo from external tools without the need for a password or Two-factor Authentication.