diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py index 10c2461ebf618fcd0b01aca3a1ecc0053866df8c..56936727c66a6b9a86f1057816bd43e64c2a5172 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 2dad6e19fa7a53599874a17601e2c657e3a39da8..ea319a2b1c7ef23c61181930f2dce077c9936b2a 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 5ba901a2561fd753032cd53484c0867d6beb4eef..d48bbb58e1afc4b534006bcf07c710545e1e3023 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 eea245041a9b55e93c6e6f2a0d4cca1f70cda545..93404d6b00dd800485f1b022595da518671ff0c5 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 5b0e21b26a868ff26fc3df562d9d39b86c4db69f..438ef6d142e7fc5b2ac7a464034fcb0ca738abc6 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 100dbbc44521c532b8d54560370a743023cc4cc0..0d960453d6db74d32eb7a3b656fe12611f80c90e 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 81c7336a1251755a9e1099079c8bd0bc9888e6bf..908d9edc1aaa195f454347fa27fc8993fd1f0f57 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 d22c89b078fe0356b8952158290855ed0ae2d820..6aa7d8bc0ebe750040bdc9ced23897b3e002e863 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 5f55970344c901b148e4a3d11d84b212c63440f3..4f8f54b104c7e5683fe68aeec283f2dc66ff4346 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 48120eb7f525982be0572363cb1870ad0ca52a95..7efd015c40ec7bcfe41cbff6c5ffa7f702634b4a 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 24e9831f378078a94f8d7e7b44d7134b03b5be69..0a5f944b49ef40af6b2dda7259cc76205ff4e6fa 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 8daebb3437aca559c962a3e58b4783164e342ea5..a6a5f7ccf48a915bb2c94c4079e4c75f6ed1b9d7 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",