From 4736344a57e176ed38f4b22cd100b3957d122818 Mon Sep 17 00:00:00 2001
From: "Arnaud (arg)" <arg@odoo.com>
Date: Wed, 21 Apr 2021 12:10:21 +0000
Subject: [PATCH] [IMP] auth_totp: add 2FA Trusted Devices

+ Added the 'Trusted Devices' feature
+ Added 'Remember this Device' checkbox on /web/login/totp
+ Added trusted device's OS / browser on Profile > Account Security

Added '2FA Trusted Devices' feature to allow users to remember their
device to bypass the 2FA for the next connections. The trusted devices
are displayed in a 'Trusted Devices' One2Many under the 'Developer API
Keys'. It is possible to revoke all the trusted devices at once with a
special button. It is also possible to revoke one at a time on the
desired one.

Add tour
Remove the "new Promise()" that makes the logout step fail (and seems
unnecessary).

closes odoo/odoo#69608

Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
---
 addons/auth_totp/controllers/home.py          | 37 ++++++++++-
 addons/auth_totp/i18n/auth_totp.pot           | 61 +++++++++++++++++-
 addons/auth_totp/models/res_users.py          | 21 +++++-
 addons/auth_totp/static/tests/totp_flow.js    | 64 +++++++++++++++++++
 addons/auth_totp/tests/test_totp.py           |  9 ++-
 addons/auth_totp/views/templates.xml          |  6 +-
 addons/auth_totp/views/user_preferences.xml   | 25 ++++++++
 .../i18n/auth_totp_portal.pot                 | 19 +++++-
 .../static/src/js/totp_frontend.js            | 38 +++++++++++
 addons/auth_totp_portal/views/templates.xml   | 26 ++++++++
 addons/web/static/src/js/core/misc.js         |  1 -
 odoo/addons/base/models/res_users.py          |  8 +++
 12 files changed, 302 insertions(+), 13 deletions(-)

diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py
index 10c2461ebf61..56936727c66a 100644
--- a/addons/auth_totp/controllers/home.py
+++ b/addons/auth_totp/controllers/home.py
@@ -3,9 +3,13 @@ import re
 
 import odoo.addons.web.controllers.main
 from odoo import http, _
+from odoo.addons.auth_totp.models.res_users import TRUSTED_DEVICE_SCOPE
 from odoo.exceptions import AccessDenied
 from odoo.http import request
 
+TRUSTED_DEVICE_COOKIE = 'td_id'
+TRUSTED_DEVICE_AGE = 90*86400 # 90 days expiration
+
 
 class Home(odoo.addons.web.controllers.main.Home):
     @http.route(
@@ -21,8 +25,17 @@ class Home(odoo.addons.web.controllers.main.Home):
             return http.redirect_with_hash('/web/login')
 
         error = None
-        if request.httprequest.method == 'POST':
-            user = request.env['res.users'].browse(request.session.pre_uid)
+        user = request.env['res.users'].browse(request.session.pre_uid)
+        if user and request.httprequest.method == 'GET':
+            cookies = request.httprequest.cookies
+            key = cookies.get(TRUSTED_DEVICE_COOKIE)
+            if key:
+                checked_credentials = request.env['res.users.apikeys']._check_credentials(scope=TRUSTED_DEVICE_SCOPE, key=key)
+                if checked_credentials == user.id:
+                    request.session.finalize()
+                    return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))
+
+        elif user and request.httprequest.method == 'POST':
             try:
                 with user._assert_can_auth():
                     user._totp_check(int(re.sub(r'\s', '', kwargs['totp_token'])))
@@ -32,7 +45,25 @@ class Home(odoo.addons.web.controllers.main.Home):
                 error = _("Invalid authentication code format.")
             else:
                 request.session.finalize()
-                return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))
+                response = http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))
+                if kwargs.get('remember'):
+                    name = _("%(browser)s on %(platform)s",
+                        browser=request.httprequest.user_agent.browser.capitalize(),
+                        platform=request.httprequest.user_agent.platform.capitalize(),
+                    )
+                    geoip = request.session.get('geoip')
+                    if geoip:
+                        name += " (%s, %s)" % (geoip['city'], geoip['country_name'])
+
+                    key = request.env['res.users.apikeys']._generate(TRUSTED_DEVICE_SCOPE, name)
+                    response.set_cookie(
+                        key=TRUSTED_DEVICE_COOKIE,
+                        value=key,
+                        max_age=TRUSTED_DEVICE_AGE,
+                        httponly=True,
+                        samesite='Lax'
+                    )
+                return response
 
         return request.render('auth_totp.auth_totp_form', {
             'error': error,
diff --git a/addons/auth_totp/i18n/auth_totp.pot b/addons/auth_totp/i18n/auth_totp.pot
index 2dad6e19fa7a..ea319a2b1c7e 100644
--- a/addons/auth_totp/i18n/auth_totp.pot
+++ b/addons/auth_totp/i18n/auth_totp.pot
@@ -6,8 +6,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Odoo Server 14.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-29 13:45+0000\n"
-"PO-Revision-Date: 2020-09-29 13:45+0000\n"
+"POT-Creation-Date: 2021-08-19 09:43+0000\n"
+"PO-Revision-Date: 2021-08-19 09:43+0000\n"
 "Last-Translator: \n"
 "Language-Team: \n"
 "MIME-Version: 1.0\n"
@@ -15,6 +15,12 @@ msgstr ""
 "Content-Transfer-Encoding: \n"
 "Plural-Forms: \n"
 
+#. module: auth_totp
+#: code:addons/auth_totp/controllers/home.py:0
+#, python-format
+msgid "%(browser)s on %(platform)s"
+msgstr ""
+
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
 msgid "(Disable two-factor authentication)"
@@ -35,6 +41,13 @@ msgid ""
 "                                Two-factor authentication enabled"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.auth_totp_form
+msgid ""
+"<i class=\"fa fa-question-circle text-primary\" title=\"If checked, you "
+"won't be asked for two-factor authentication codes with this device.\"/>"
+msgstr ""
+
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
 msgid ""
@@ -60,6 +73,11 @@ msgid ""
 "                        </span>"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Added On"
+msgstr ""
+
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_wizard
 msgid ""
@@ -68,6 +86,13 @@ msgid ""
 "                                it stays valid a bit longer."
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid ""
+"Are you sure? Two-factor authentication will be required again on all your "
+"devices"
+msgstr ""
+
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.auth_totp_form
 msgid "Authentication Code (6 digits)"
@@ -75,6 +100,7 @@ msgstr ""
 
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.auth_totp_form
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
 #: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_wizard
 msgid "Cancel"
 msgstr ""
@@ -89,6 +115,11 @@ msgstr ""
 msgid "Created on"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Device Name"
+msgstr ""
+
 #. module: auth_totp
 #: model:ir.actions.server,name:auth_totp.action_disable_totp
 msgid "Disable TOTP on users"
@@ -101,6 +132,11 @@ msgstr ""
 msgid "Display Name"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.auth_totp_form
+msgid "Don't ask again for this device"
+msgstr ""
+
 #. module: auth_totp
 #: code:addons/auth_totp/models/res_users.py:0
 #, python-format
@@ -163,6 +199,16 @@ msgstr ""
 msgid "Qrcode"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Revoke"
+msgstr ""
+
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Revoke All"
+msgstr ""
+
 #. module: auth_totp
 #: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_wizard
 msgid ""
@@ -191,6 +237,17 @@ msgstr ""
 msgid "Totp Secret"
 msgstr ""
 
+#. module: auth_totp
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Trusted Device"
+msgstr ""
+
+#. module: auth_totp
+#: model:ir.model.fields,field_description:auth_totp.field_res_users__totp_trusted_device_ids
+#: model_terms:ir.ui.view,arch_db:auth_totp.view_totp_field
+msgid "Trusted Devices"
+msgstr ""
+
 #. module: auth_totp
 #: model:ir.model,name:auth_totp.model_auth_totp_wizard
 msgid "Two-Factor Setup Wizard"
diff --git a/addons/auth_totp/models/res_users.py b/addons/auth_totp/models/res_users.py
index 5ba901a2561f..d48bbb58e1af 100644
--- a/addons/auth_totp/models/res_users.py
+++ b/addons/auth_totp/models/res_users.py
@@ -18,16 +18,21 @@ from odoo.http import request, db_list
 
 _logger = logging.getLogger(__name__)
 
+TRUSTED_DEVICE_SCOPE = '2fa_trusted_device'
+
 compress = functools.partial(re.sub, r'\s', '')
 class Users(models.Model):
     _inherit = 'res.users'
 
     totp_secret = fields.Char(copy=False, groups=fields.NO_ACCESS)
     totp_enabled = fields.Boolean(string="Two-factor authentication", compute='_compute_totp_enabled')
+    totp_trusted_device_ids = fields.One2many('res.users.apikeys', 'user_id',
+        string="Trusted Devices", domain=[('scope', '=', TRUSTED_DEVICE_SCOPE)])
+    api_key_ids = fields.One2many(domain=[('scope', '!=', TRUSTED_DEVICE_SCOPE)])
 
     def __init__(self, pool, cr):
         init_res = super().__init__(pool, cr)
-        type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled']
+        type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled', 'totp_trusted_device_ids']
         return init_res
 
     def _mfa_url(self):
@@ -87,7 +92,9 @@ class Users(models.Model):
             _logger.info("2FA disable: REJECT for %s (%s) by uid #%s", self, logins, self.env.user.id)
             return False
 
+        self.revoke_all_devices()
         self.sudo().write({'totp_secret': False})
+
         if request and self == self.env.user:
             self.flush()
             # update session token so the user does not get logged out (cache cleared by change)
@@ -130,6 +137,18 @@ class Users(models.Model):
             'views': [(False, 'form')],
         }
 
+    @check_identity
+    def revoke_all_devices(self):
+        self._revoke_all_devices()
+
+    def _revoke_all_devices(self):
+        self.totp_trusted_device_ids._remove()
+
+    def change_password(self, old_passwd, new_passwd):
+        self.env.user._revoke_all_devices()
+        return super().change_password(old_passwd, new_passwd)
+
+
 class TOTPWizard(models.TransientModel):
     _name = 'auth_totp.wizard'
     _description = "Two-Factor Setup Wizard"
diff --git a/addons/auth_totp/static/tests/totp_flow.js b/addons/auth_totp/static/tests/totp_flow.js
index eea245041a9b..93404d6b00dd 100644
--- a/addons/auth_totp/static/tests/totp_flow.js
+++ b/addons/auth_totp/static/tests/totp_flow.js
@@ -116,6 +116,70 @@ tour.register('totp_login_enabled', {
     content: "check we're logged in",
     trigger: ".o_user_menu .oe_topbar_name",
     run: () => {}
+}]);
+
+tour.register('totp_login_device', {
+    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: "check remember device box",
+    trigger: 'label[for=switch-remember]',
+}, {
+    content: "input code",
+    trigger: 'input[name=totp_token]',
+    run(helpers) {
+        ajax.jsonRpc('/totphook', 'call', {}).then((token) => {
+            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: () => {}
+}, {
+    content: "click on the user",
+    trigger: 'li[class=o_user_menu] > a',
+}, {
+    content: "click the Log out button",
+    trigger: 'a[data-menu=logout]',
+}, {
+    content: "check that we're back on the login page or go to it",
+    trigger: 'input#login, a:contains(Log in)'
+}, {
+    content: "input login again",
+    trigger: 'input#login',
+    run: 'text demo',
+}, {
+    content: 'input password again',
+    trigger: 'input#password',
+    run: 'text demo',
+}, {
+    content: "click da button again",
+    trigger: 'button:contains("Log in")',
+},  {
+    content: "check we're logged in without 2FA",
+    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
diff --git a/addons/auth_totp/tests/test_totp.py b/addons/auth_totp/tests/test_totp.py
index 5b0e21b26a86..438ef6d142e7 100644
--- a/addons/auth_totp/tests/test_totp.py
+++ b/addons/auth_totp/tests/test_totp.py
@@ -61,13 +61,16 @@ class TestTOTP(HttpCase):
                 'res.users', 'read', [uid, ['login']]
             )
 
-        # 3. Check 2FA is required and disable it
+        # 3. Check 2FA is required
         self.start_tour('/', 'totp_login_enabled', login=None)
 
-        # 4. Finally, check that 2FA is in fact disabled
+        # 4. Check 2FA is not requested on saved device and disable it
+        self.start_tour('/', 'totp_login_device', login=None)
+
+        # 5. Finally, check that 2FA is in fact disabled
         self.start_tour('/', 'totp_login_disabled', login=None)
 
-        # 5. Check that rpc is now re-allowed
+        # 6. Check that rpc is now re-allowed
         uid = self.xmlrpc_common.authenticate(get_db_name(), 'demo', 'demo', {})
         self.assertEqual(uid, self.env.ref('base.user_demo').id)
         [r] = self.xmlrpc_object.execute_kw(
diff --git a/addons/auth_totp/views/templates.xml b/addons/auth_totp/views/templates.xml
index 100dbbc44521..0d960453d6db 100644
--- a/addons/auth_totp/views/templates.xml
+++ b/addons/auth_totp/views/templates.xml
@@ -4,7 +4,7 @@
             <script type="text/javascript" src="/auth_totp/static/tests/totp_flow.js"></script>
         </xpath>
     </template>
-    <template id="auth_totp_form">
+    <template id="auth_totp_form" name="Two-Factor Authentication">
         <t t-call="web.login_layout">
             <t t-set="disable_footer">1</t>
             <div class="oe_login_form">
@@ -20,6 +20,10 @@
                     <p class="alert alert-danger" t-if="error" role="alert">
                         <t t-esc="error"/>
                     </p>
+                    <div class="mb-2 mt-2 text-muted">
+                        <input type="checkbox" name="remember" id="switch-remember" value="1"/>
+                        <label for="switch-remember">Don't ask again on this device</label>
+                    </div>
                     <div t-attf-class="clearfix oe_login_buttons text-center mb-1">
                         <button type="submit" class="btn btn-primary btn-block">
                             Verify
diff --git a/addons/auth_totp/views/user_preferences.xml b/addons/auth_totp/views/user_preferences.xml
index 81c7336a1251..908d9edc1aaa 100644
--- a/addons/auth_totp/views/user_preferences.xml
+++ b/addons/auth_totp/views/user_preferences.xml
@@ -135,6 +135,31 @@
                             <button name="totp_disable" type="object" string="(Disable two-factor authentication)"
                                     class="btn btn-link text-muted"/>
                         </div>
+                        <div colspan="2" attrs="{'invisible': [('totp_trusted_device_ids', '=', [])]}">
+                            <field name="totp_trusted_device_ids" nolabel="1" colspan="4" readonly="1">
+
+                                <tree create="false" delete="false">
+                                    <field name="name" string="Trusted Devices"/>
+                                    <field name="create_date" string="Added On"/>
+                                    <button type="object" name="remove" icon="fa-trash"/>
+                                </tree>
+                                <form string="Trusted Device">
+                                    <group>
+                                        <group>
+                                            <field name="name" string="Device Name"/>
+                                            <field name="create_date" string="Added On"/>
+                                        </group>
+                                    </group>
+                                    <footer>
+                                        <button name="remove" string="Revoke" type="object" icon="fa-trash"/>
+                                        <button name="preference_cancel" string="Cancel" special="cancel" class="btn-secondary"/>
+                                    </footer>
+                                </form>
+
+                            </field>
+                            <button name="revoke_all_devices" string="Revoke All" type="object" class="btn btn-secondary"
+                                    confirm="Are you sure? Two-factor authentication will be required again on all your devices"/>
+                        </div>
                     </group>
             </button>
         </field>
diff --git a/addons/auth_totp_portal/i18n/auth_totp_portal.pot b/addons/auth_totp_portal/i18n/auth_totp_portal.pot
index d22c89b078fe..6aa7d8bc0ebe 100644
--- a/addons/auth_totp_portal/i18n/auth_totp_portal.pot
+++ b/addons/auth_totp_portal/i18n/auth_totp_portal.pot
@@ -6,8 +6,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Odoo Server 14.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-29 13:45+0000\n"
-"PO-Revision-Date: 2020-09-29 13:45+0000\n"
+"POT-Creation-Date: 2021-08-19 09:44+0000\n"
+"PO-Revision-Date: 2021-08-19 09:44+0000\n"
 "Last-Translator: \n"
 "Language-Team: \n"
 "MIME-Version: 1.0\n"
@@ -36,6 +36,16 @@ msgid ""
 "                    </span>"
 msgstr ""
 
+#. module: auth_totp_portal
+#: model_terms:ir.ui.view,arch_db:auth_totp_portal.totp_portal_hook
+msgid "<strong>Added On</strong>"
+msgstr ""
+
+#. module: auth_totp_portal
+#: model_terms:ir.ui.view,arch_db:auth_totp_portal.totp_portal_hook
+msgid "<strong>Trusted Device</strong>"
+msgstr ""
+
 #. module: auth_totp_portal
 #: model_terms:ir.ui.view,arch_db:auth_totp_portal.totp_portal_hook
 msgid "Enable two-factor authentication"
@@ -48,6 +58,11 @@ msgstr ""
 msgid "Operation failed for unknown reason."
 msgstr ""
 
+#. module: auth_totp_portal
+#: model_terms:ir.ui.view,arch_db:auth_totp_portal.totp_portal_hook
+msgid "Revoke All"
+msgstr ""
+
 #. module: auth_totp_portal
 #: model_terms:ir.ui.view,arch_db:auth_totp_portal.totp_portal_hook
 msgid "Two-factor authentication"
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 5f55970344c9..4f8f54b104c7 100644
--- a/addons/auth_totp_portal/static/src/js/totp_frontend.js
+++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js
@@ -218,4 +218,42 @@ publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
         window.location = window.location;
     }
 });
+publicWidget.registry.RevokeTrustedDeviceButton = publicWidget.Widget.extend({
+    selector: '.fa.fa-trash.text-danger',
+    events: {
+        click: '_onClick'
+    },
+
+    async _onClick(e){
+        e.preventDefault();
+        await handleCheckIdentity(
+            this.proxy('_rpc'),
+            this._rpc({
+                model: 'res.users.apikeys',
+                method: 'remove',
+                args: [parseInt(this.target.id)]
+            })
+        );
+        window.location = window.location;
+    }
+});
+publicWidget.registry.RevokeAllTrustedDevicesButton = publicWidget.Widget.extend({
+    selector: '#auth_totp_portal_revoke_all_devices',
+    events: {
+        click: '_onClick'
+    },
+
+    async _onClick(e){
+        e.preventDefault();
+        await handleCheckIdentity(
+            this.proxy('_rpc'),
+            this._rpc({
+                model: 'res.users',
+                method: 'revoke_all_devices',
+                args: [this.getSession().user_id]
+            })
+        );
+        window.location = window.location;
+    }
+});
 });
diff --git a/addons/auth_totp_portal/views/templates.xml b/addons/auth_totp_portal/views/templates.xml
index 48120eb7f525..7efd015c40ec 100644
--- a/addons/auth_totp_portal/views/templates.xml
+++ b/addons/auth_totp_portal/views/templates.xml
@@ -41,6 +41,32 @@
                     <button type="button" class="btn btn-link" id="auth_totp_portal_disable">
                         (Disable two-factor authentication)
                     </button>
+                    <t t-if="len(user_id.totp_trusted_device_ids)">
+                        <table class="table o_main_table">
+                            <thead>
+                                <tr>
+                                    <th><strong>Trusted Device</strong></th>
+                                    <th><strong>Added On</strong></th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <tr t-foreach="user_id.totp_trusted_device_ids" t-as="td">
+                                    <td>
+                                        <span t-field="td.name"/>
+                                    </td>
+                                    <td>
+                                        <span t-field="td.create_date"/>
+                                    </td>
+                                    <td>
+                                        <i class="fa fa-trash text-danger" type="button" t-att-id="td.id"/>
+                                    </td>
+                                </tr>
+                            </tbody>
+                        </table>
+                        <button class="btn btn-primary" type="button" id="auth_totp_portal_revoke_all_devices">
+                            Revoke All
+                        </button>
+                    </t>
                 </t>
             </section>
         </xpath>
diff --git a/addons/web/static/src/js/core/misc.js b/addons/web/static/src/js/core/misc.js
index 24e9831f3780..0a5f944b49ef 100644
--- a/addons/web/static/src/js/core/misc.js
+++ b/addons/web/static/src/js/core/misc.js
@@ -173,7 +173,6 @@ core.action_registry.add("login", login);
 
 function logout() {
     redirect('/web/session/logout');
-    return new Promise();
 }
 core.action_registry.add("logout", logout);
 
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 8daebb3437ac..a6a5f7ccf48a 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -1639,6 +1639,14 @@ class APIKeys(models.Model):
 
     @check_identity
     def remove(self):
+        return self._remove()
+
+    def _remove(self):
+        """Use the remove() method to remove an API Key. This method implement logic,
+        but won't check the identity (mainly used to remove trusted devices)"""
+        if not self:
+            return {'type': 'ir.actions.act_window_close'}
+
         if self.env.is_system() or self.mapped('user_id') == self.env.user:
             ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
             _logger.info("API key(s) removed: scope: <%s> for '%s' (#%s) from %s",
-- 
GitLab