[IMP] auth_totp: 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.

Task-id 2523092

closes odoo/odoo#75535

Related: odoo/upgrade#2800
Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
Co-authored-by: Martin Trigaux <mat@odoo.com>
This commit is contained in:
Arnaud Gony
2021-09-06 13:17:48 +00:00
committed by Martin Trigaux
co-authored by Martin Trigaux
parent 717c178a5e
commit 2dee29a7dc
12 changed files with 216 additions and 14 deletions
+2 -1
View File
@@ -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',
+34 -3
View File
@@ -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,
+1
View File
@@ -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
+14
View File
@@ -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
+16 -1
View File
@@ -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)
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_auth_totp_device_access_employee TOTP Device access employees model_auth_totp_device base.group_user 1 0 0 0
3 access_auth_totp_device_access_portal TOTP Device access portal model_auth_totp_device base.group_portal 1 0 0 0
+23
View File
@@ -13,4 +13,27 @@
<field name="model_id" ref="model_auth_totp_wizard"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
<!-- rules for API token -->
<record id="api_key_public" model="ir.rule">
<field name="name">Public users can't interact with keys at all</field>
<field name="model_id" ref="model_auth_totp_device"/>
<field name="domain_force">[(0, '=', 1)]</field>
<field name="groups" eval="[Command.link(ref('base.group_public'))]"/>
</record>
<record id="api_key_user" model="ir.rule">
<field name="name">Users can read and delete their own keys</field>
<field name="model_id" ref="model_auth_totp_device"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[
Command.link(ref('base.group_portal')),
Command.link(ref('base.group_user')),
]"/>
</record>
<record id="api_key_admin" model="ir.rule">
<field name="name">Administrators can view user keys to revoke them</field>
<field name="model_id" ref="model_auth_totp_device"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[Command.link(ref('base.group_system'))]"/>
</record>
</odoo>
@@ -37,6 +37,31 @@
<button attrs="{'invisible': [('totp_enabled', '=', False)]}" name="action_totp_disable" type="object"
class="fa fa-toggle-on o_auth_2fa_btn text-primary enabled" aria-label="Disable 2FA"></button>
</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>
<span attrs="{'invisible': [('totp_enabled', '!=', False)]}" class="text-muted">
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.
+8 -1
View File
@@ -1,5 +1,6 @@
<odoo>
<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">
@@ -22,13 +23,19 @@
<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">
Login
</button>
</div>
</div>
</form>
<form method="POST" action="/web/session/logout" class="form-inline">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="w-100 text-center">
@@ -265,4 +265,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;
}
});
});
@@ -29,6 +29,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>
+26 -8
View File
@@ -1668,12 +1668,29 @@ class APIKeys(models.Model):
index varchar({index_size}) not null CHECK (char_length(index) = {index_size}),
key varchar not null,
create_date timestamp without time zone DEFAULT (now() at time zone 'utc')
);
CREATE INDEX IF NOT EXISTS res_users_apikeys_user_id_index_idx ON {table} (user_id, index);
)
""".format(table=self._table, index_size=INDEX_SIZE))
index_name = self._table + "_user_id_index_idx"
if len(index_name) > 63:
# unique determinist index name
index_name = self._table[:50] + "_idx_" + sha256(self._table.encode()).hexdigest()[:8]
self.env.cr.execute("""
CREATE INDEX IF NOT EXISTS {index_name} ON {table} (user_id, index);
""".format(
table=self._table,
index_name=index_name
))
@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",
@@ -1687,9 +1704,10 @@ class APIKeys(models.Model):
index = key[:INDEX_SIZE]
self.env.cr.execute('''
SELECT user_id, key
FROM res_users_apikeys INNER JOIN res_users u ON (u.id = user_id)
FROM {} INNER JOIN res_users u ON (u.id = user_id)
WHERE u.active and index = %s AND (scope IS NULL OR scope = %s)
''', [index, scope])
'''.format(self._table),
[index, scope])
for user_id, current_key in self.env.cr.fetchall():
if KEY_CRYPT_CONTEXT.verify(key, current_key):
return user_id
@@ -1704,15 +1722,15 @@ class APIKeys(models.Model):
# no need to clear the LRU when *adding* a key, only when removing
k = binascii.hexlify(os.urandom(API_KEY_SIZE)).decode()
self.env.cr.execute("""
INSERT INTO res_users_apikeys (name, user_id, scope, key, index)
INSERT INTO {table} (name, user_id, scope, key, index)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
""".format(table=self._table),
[name, self.env.user.id, scope, hash_api_key(k), k[:INDEX_SIZE]])
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
_logger.info("API key generated: scope: <%s> for '%s' (#%s) from %s",
scope, self.env.user.login, self.env.uid, ip)
_logger.info("%s generated: scope: <%s> for '%s' (#%s) from %s",
self._description, scope, self.env.user.login, self.env.uid, ip)
return k