[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:
committed by
Martin Trigaux
co-authored by
Martin Trigaux
parent
717c178a5e
commit
2dee29a7dc
@@ -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',
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user