diff --git a/addons/auth_totp/__manifest__.py b/addons/auth_totp/__manifest__.py index e420254099c..e350d36c9b0 100644 --- a/addons/auth_totp/__manifest__.py +++ b/addons/auth_totp/__manifest__.py @@ -18,9 +18,10 @@ can setup API keys to replace their main password. 'category': 'Extra Tools', 'auto_install': True, 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', 'data/ir_action_data.xml', 'data/mail_template_data.xml', - 'security/security.xml', 'views/res_users_views.xml', 'views/templates.xml', 'wizard/auth_totp_wizard_views.xml', diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py index 19d678f47a1..1c6f2f879c1 100644 --- a/addons/auth_totp/controllers/home.py +++ b/addons/auth_totp/controllers/home.py @@ -6,6 +6,9 @@ from odoo import http, _ 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 +24,18 @@ class Home(odoo.addons.web.controllers.main.Home): return request.redirect('/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['auth_totp.device']._check_credentials(scope="browser", key=key) + if checked_credentials == user.id: + request.session.finalize() + return request.redirect(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 request.redirect(self._login_redirect(request.session.uid, redirect=redirect)) + response = request.redirect(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.geoip + if geoip: + name += " (%s, %s)" % (geoip['city'], geoip['country_name']) + + key = request.env['auth_totp.device']._generate("browser", 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/models/__init__.py b/addons/auth_totp/models/__init__.py index 563dd656946..94805ad2cef 100644 --- a/addons/auth_totp/models/__init__.py +++ b/addons/auth_totp/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import auth_totp from . import ir_http from . import res_users from . import totp diff --git a/addons/auth_totp/models/auth_totp.py b/addons/auth_totp/models/auth_totp.py new file mode 100644 index 00000000000..38ac76df4a4 --- /dev/null +++ b/addons/auth_totp/models/auth_totp.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from odoo import models + + +class AuthTotpDevice(models.Model): + + # init is overriden in res.users.apikeys to create a secret column 'key' + # use a different model to benefit from the secured methods while not mixing + # two different concepts + + _name = "auth_totp.device" + _inherit = "res.users.apikeys" + _description = "Authentication Device" + _auto = False diff --git a/addons/auth_totp/models/res_users.py b/addons/auth_totp/models/res_users.py index 708c9bd87c5..8e19092de32 100644 --- a/addons/auth_totp/models/res_users.py +++ b/addons/auth_totp/models/res_users.py @@ -22,10 +22,12 @@ class Users(models.Model): 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('auth_totp.device', 'user_id', string="Trusted Devices") @property def SELF_READABLE_FIELDS(self): - return super().SELF_READABLE_FIELDS + ['totp_enabled'] + return super().SELF_READABLE_FIELDS + ['totp_enabled', 'totp_trusted_device_ids'] + def _mfa_url(self): r = super()._mfa_url() @@ -120,7 +122,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) @@ -163,3 +167,14 @@ class Users(models.Model): 'views': [(False, 'form')], 'context': self.env.context, } + + @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) diff --git a/addons/auth_totp/security/ir.model.access.csv b/addons/auth_totp/security/ir.model.access.csv new file mode 100644 index 00000000000..4a116ba291c --- /dev/null +++ b/addons/auth_totp/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_auth_totp_device_access_employee","TOTP Device access employees","model_auth_totp_device","base.group_user",1,0,0,0 +"access_auth_totp_device_access_portal","TOTP Device access portal","model_auth_totp_device","base.group_portal",1,0,0,0 diff --git a/addons/auth_totp/security/security.xml b/addons/auth_totp/security/security.xml index 4d44c23a457..5d6a455508f 100644 --- a/addons/auth_totp/security/security.xml +++ b/addons/auth_totp/security/security.xml @@ -13,4 +13,27 @@ [('user_id', '=', user.id)] + + + + Public users can't interact with keys at all + + [(0, '=', 1)] + + + + Users can read and delete their own keys + + [('user_id', '=', user.id)] + + + + Administrators can view user keys to revoke them + + [(1, '=', 1)] + + diff --git a/addons/auth_totp/views/res_users_views.xml b/addons/auth_totp/views/res_users_views.xml index 43d116ae405..5a268addd1e 100644 --- a/addons/auth_totp/views/res_users_views.xml +++ b/addons/auth_totp/views/res_users_views.xml @@ -37,6 +37,31 @@ +
+ + + + + +
Two-factor Authentication ("2FA") is a system of double authentication. The first one is done with your password and the second one with a code you get from a dedicated mobile app. diff --git a/addons/auth_totp/views/templates.xml b/addons/auth_totp/views/templates.xml index af73f829b7c..56ed08d3ab5 100644 --- a/addons/auth_totp/views/templates.xml +++ b/addons/auth_totp/views/templates.xml @@ -1,5 +1,6 @@ -