diff options
| author | Xavier Morel <xmo@odoo.com> | 2019-06-05 12:45:16 +0000 |
|---|---|---|
| committer | Olivier Dony <odo@odoo.com> | 2020-08-14 23:06:24 +0000 |
| commit | a9a65097132b53813991c9ce61c878ab3f302f53 (patch) | |
| tree | 6c0d2a4a44f3428028f85e8df3305bd00ddd4be7 /addons/auth_totp | |
| parent | 63e38dad8de9d59edc2b75433b4e3f761eda0f3c (diff) | |
[ADD] auth_totp
New module for supporting two-factor authentication via time-base
one-time-password (TOTP).
Users (including portal users) can choose to enable two-factor auth in
their user account settings, by scanning a QR code and adding it to an
authenticator app, such as Google Auth, 1Password, etc.
When two-factor is enabled, password-based non-interactive RPC is only
possible by using API keys.
Co-authored-by: Olivier Dony <odo@odoo.com>
Diffstat (limited to 'addons/auth_totp')
| -rw-r--r-- | addons/auth_totp/__init__.py | 3 | ||||
| -rw-r--r-- | addons/auth_totp/__manifest__.py | 25 | ||||
| -rw-r--r-- | addons/auth_totp/controllers/__init__.py | 2 | ||||
| -rw-r--r-- | addons/auth_totp/controllers/home.py | 38 | ||||
| -rw-r--r-- | addons/auth_totp/models/__init__.py | 3 | ||||
| -rw-r--r-- | addons/auth_totp/models/ir_http.py | 13 | ||||
| -rw-r--r-- | addons/auth_totp/models/res_users.py | 197 | ||||
| -rw-r--r-- | addons/auth_totp/security/security.xml | 16 | ||||
| -rw-r--r-- | addons/auth_totp/static/tests/totp_flow.js | 249 | ||||
| -rw-r--r-- | addons/auth_totp/tests/__init__.py | 1 | ||||
| -rw-r--r-- | addons/auth_totp/tests/test_totp.py | 57 | ||||
| -rw-r--r-- | addons/auth_totp/views/templates.xml | 38 | ||||
| -rw-r--r-- | addons/auth_totp/views/user_preferences.xml | 108 |
13 files changed, 750 insertions, 0 deletions
diff --git a/addons/auth_totp/__init__.py b/addons/auth_totp/__init__.py new file mode 100644 index 00000000000..9e5827f90ee --- /dev/null +++ b/addons/auth_totp/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import controllers +from . import models diff --git a/addons/auth_totp/__manifest__.py b/addons/auth_totp/__manifest__.py new file mode 100644 index 00000000000..f98675c5252 --- /dev/null +++ b/addons/auth_totp/__manifest__.py @@ -0,0 +1,25 @@ +{ + 'name': 'Two-Factor Authentication (TOTP)', + 'description': """ +Two-Factor Authentication (TOTP) +================================ +Allows users to configure two-factor authentication on their user account +for extra security, using time-based one-time passwords (TOTP). + +Once enabled, the user will need to enter a 6-digit code as provided +by their authenticator app before being granted access to the system. +All popular authenticator apps are supported. + +Note: logically, two-factor prevents password-based RPC access for users +where it is enabled. In order to be able to execute RPC scripts, the user +can setup API keys to replace their main password. + """, + 'depends': ['web'], + 'category': 'Extra Tools', + 'auto_install': True, + 'data': [ + 'security/security.xml', + 'views/user_preferences.xml', + 'views/templates.xml', + ], +} diff --git a/addons/auth_totp/controllers/__init__.py b/addons/auth_totp/controllers/__init__.py new file mode 100644 index 00000000000..3f3f69c6abf --- /dev/null +++ b/addons/auth_totp/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import home diff --git a/addons/auth_totp/controllers/home.py b/addons/auth_totp/controllers/home.py new file mode 100644 index 00000000000..f68f9ed3896 --- /dev/null +++ b/addons/auth_totp/controllers/home.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import odoo.addons.web.controllers.main +from odoo import http, _ +from odoo.exceptions import AccessDenied +from odoo.http import request + + +class Home(odoo.addons.web.controllers.main.Home): + @http.route( + '/web/login/totp', + type='http', auth='public', methods=['GET', 'POST'], sitemap=False, + website=True, # website breaks the login layout... + ) + def web_totp(self, redirect=None, **kwargs): + if request.session.uid: + return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect)) + + if not request.session.pre_uid: + return http.redirect_with_hash('/web/login') + + error = None + if request.httprequest.method == 'POST': + user = request.env['res.users'].browse(request.session.pre_uid) + try: + with user._assert_can_auth(): + user._totp_check(int(kwargs['totp_token'])) + except AccessDenied: + error = _("Verification failed, please double-check the 6-digit code") + except ValueError: + error = _("Invalid authentication code format.") + else: + request.session.finalize() + return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect)) + + return request.render('auth_totp.auth_totp_form', { + 'error': error, + 'redirect': redirect, + }) diff --git a/addons/auth_totp/models/__init__.py b/addons/auth_totp/models/__init__.py new file mode 100644 index 00000000000..5f0fe0d93ac --- /dev/null +++ b/addons/auth_totp/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import ir_http +from . import res_users diff --git a/addons/auth_totp/models/ir_http.py b/addons/auth_totp/models/ir_http.py new file mode 100644 index 00000000000..9bae70facc3 --- /dev/null +++ b/addons/auth_totp/models/ir_http.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from odoo import models +from odoo.http import request + +class IrHttp(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + info = super().session_info() + # because frontend session_info uses this key and is embedded in + # the view source + info["user_id"] = request.session.uid, + return info diff --git a/addons/auth_totp/models/res_users.py b/addons/auth_totp/models/res_users.py new file mode 100644 index 00000000000..5625724c68b --- /dev/null +++ b/addons/auth_totp/models/res_users.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +import hmac +import io +import logging +import os +import struct +import time + +import qrcode +import werkzeug.urls + +from odoo import _, api, fields, models +from odoo.addons.base.models.res_users import check_identity +from odoo.exceptions import AccessDenied, UserError + +_logger = logging.getLogger(__name__) + +class Users(models.Model): + _inherit = 'res.users' + + totp_secret = fields.Char(copy=False, groups=".") # no access + totp_enabled = fields.Boolean(string="TOTP enabled", compute='_compute_totp_enabled') + + def __init__(self, pool, cr): + init_res = super().__init__(pool, cr) + type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled'] + return init_res + + def _mfa_url(self): + r = super()._mfa_url() + if r is not None: + return r + if self.totp_enabled: + return '/web/login/totp' + + @api.depends('totp_secret') + def _compute_totp_enabled(self): + for r, v in zip(self, self.sudo()): + r.totp_enabled = bool(v.totp_secret) + + def _rpc_api_keys_only(self): + # 2FA enabled means we can't allow password-based RPC + self.ensure_one() + return self.totp_enabled or super()._rpc_api_keys_only() + + def _totp_check(self, code): + sudo = self.sudo() + key = base64.b32decode(sudo.totp_secret.upper()) + match = TOTP(key).match(code) + if match is None: + _logger.info("2FA check: FAIL for '%s' (#%s)", self.login, self.id) + raise AccessDenied() + _logger.info("2FA check: SUCCESS for '%s' (#%s)", self.login, self.id) + + def _totp_try_setting(self, secret, code): + if self.totp_enabled or self != self.env.user: + _logger.info("2FA enable: REJECT for '%s' (#%s)", self.login, self.id) + return False + + match = TOTP(base64.b32decode(secret.upper())).match(code) + if match is None: + _logger.info("2FA enable: REJECT CODE for '%s' (#%s)", self.login, self.id) + return False + + self.sudo().totp_secret = secret + _logger.info("2FA enable: SUCCESS for '%s' (#%s)", self.login, self.id) + return True + + @check_identity + def totp_disable(self): + if not (self == self.env.user or self.env.user._is_admin() or self.env.su): + _logger.info("2FA disable: REJECT for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id) + return False + + self.sudo().write({'totp_secret': False}) + _logger.info("2FA disable: SUCCESS for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id) + return {'type': 'ir.actions.act_window_close'} + + @check_identity + def totp_enable_wizard(self): + if self.env.user != self: + raise UserError(_("Two-factor authentication can only be enabled for yourself")) + + if self.totp_enabled: + raise UserError(_("Two-factor authentication already enabled")) + + secret_bytes_count = TOTP_SECRET_SIZE // 8 + w = self.env['auth_totp.wizard'].create({ + 'user_id': self.id, + 'secret': base64.b32encode(os.urandom(secret_bytes_count)).decode(), + }) + return { + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_model': 'auth_totp.wizard', + 'name': _("Enable Two-Factor Authentication"), + 'res_id': w.id, + 'views': [(False, 'form')], + } + +class TOTPWizard(models.TransientModel): + _name = 'auth_totp.wizard' + _description = "Two-Factor Setup Wizard" + + user_id = fields.Many2one('res.users', required=True, readonly=True) + secret = fields.Char(required=True, readonly=True) + url = fields.Char(store=True, readonly=True, compute='_compute_qrcode') + qrcode = fields.Binary( + attachment=False, store=True, readonly=True, + compute='_compute_qrcode', + ) + code = fields.Char(string="Verification Code", placeholder="6-digit code", size=6) + + @api.depends('user_id.login', 'user_id.company_id.display_name', 'secret') + def _compute_qrcode(self): + for w in self: + label = '{0.company_id.display_name}:{0.login}'.format(w.user_id) + w.url = url = werkzeug.urls.url_unparse(( + 'otpauth', 'totp', + werkzeug.urls.url_quote(label, safe=''), + werkzeug.urls.url_encode({ + 'secret': w.secret, + 'issuer': w.user_id.company_id.display_name, + # apparently a lowercase hash name is anathema to google + # authenticator (error) and passlib (no token) + 'algorithm': ALGORITHM.upper(), + 'digits': DIGITS, + 'period': TIMESTEP, + }), '' + )) + + data = io.BytesIO() + qrcode.make(url.encode(), box_size=4).save(data, optimise=True, format='PNG') + w.qrcode = base64.b64encode(data.getvalue()).decode() + + @check_identity + def enable(self): + try: + c = int(self.code) + except ValueError: + raise UserError(_("The verification code should only contain numbers")) + if self.user_id._totp_try_setting(self.secret, c): + self.secret = '' # empty it, because why keep it until GC? + return {'type': 'ir.actions.act_window_close'} + raise UserError(_('Verification failed, please double-check the 6-digit code')) + +# 160 bits, as recommended by HOTP RFC 4226, section 4, R6. +# Google Auth uses 80 bits by default but supports 160. +TOTP_SECRET_SIZE = 160 + +# The algorithm (and key URI format) allows customising these parameters but +# google authenticator doesn't support it +# https://github.com/google/google-authenticator/wiki/Key-Uri-Format +ALGORITHM = 'sha1' +DIGITS = 6 +TIMESTEP = 30 + +class TOTP: + def __init__(self, key): + self._key = key + + def match(self, code, t=None, window=TIMESTEP): + """ + :param code: authenticator code to check against this key + :param int t: current timestamp (seconds) + :param int window: fuzz window to account for slow fingers, network + latency, desynchronised clocks, ..., every code + valid between t-window an t+window is considered + valid + """ + if t is None: + t = time.time() + + low = int((t - window) / TIMESTEP) + high = int((t + window) / TIMESTEP) + 1 + + return next(( + counter for counter in range(low, high) + if hotp(self._key, counter) == code + ), None) + +def hotp(secret, counter): + # C is the 64b counter encoded in big-endian + C = struct.pack(">Q", counter) + mac = hmac.new(secret, msg=C, digestmod=ALGORITHM).digest() + # the data offset is the last nibble of the hash + offset = mac[-1] & 0xF + # code is the 4 bytes at the offset interpreted as a 31b big-endian uint + # (31b to avoid sign concerns). This effectively limits digits to 9 and + # hard-limits it to 10: each digit is normally worth 3.32 bits but the + # 10th is only worth 1.1 (9 digits encode 29.9 bits). + code = struct.unpack_from('>I', mac, offset)[0] & 0x7FFFFFFF + r = code % (10 ** DIGITS) + # NOTE: use text / bytes instead of int? + return r diff --git a/addons/auth_totp/security/security.xml b/addons/auth_totp/security/security.xml new file mode 100644 index 00000000000..4d44c23a457 --- /dev/null +++ b/addons/auth_totp/security/security.xml @@ -0,0 +1,16 @@ +<odoo> + <record model="ir.model.access" id="access_auth_totp_wizard"> + <field name="name">auth_totp wizard access rules</field> + <field name="model_id" ref="model_auth_totp_wizard"/> + <field name="group_id" ref="base.group_user"/> + <field name="perm_read">1</field> + <field name="perm_write">1</field> + <field name="perm_create">1</field> + <field name="perm_unlink">1</field> + </record> + <record model="ir.rule" id="rule_auth_totp_wizard"> + <field name="name">Users can only access their own wizard</field> + <field name="model_id" ref="model_auth_totp_wizard"/> + <field name="domain_force">[('user_id', '=', user.id)]</field> + </record> +</odoo> diff --git a/addons/auth_totp/static/tests/totp_flow.js b/addons/auth_totp/static/tests/totp_flow.js new file mode 100644 index 00000000000..3b3f1b67cc1 --- /dev/null +++ b/addons/auth_totp/static/tests/totp_flow.js @@ -0,0 +1,249 @@ +odoo.define('auth_totp.tours', function(require) { +"use strict"; + +const tour = require('web_tour.tour'); +const ajax = require('web.ajax'); + +tour.register('totp_tour_setup', { + test: true, + url: '/web' +}, [{ + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Switch to security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: 'click', +}, { + content: "Open totp wizard", + trigger: 'button[name=totp_enable_wizard]', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text demo', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Check the wizard has opened", + trigger: 'div:contains("Scan the image below")', + run: () => {} +}, { + content: "Get secret from collapsed div", + trigger: 'a:contains("show the code")', + run: async function(helpers) { + const secret = this.$anchor.closest('div').find('code').text(); + const token = await ajax.jsonRpc('/totphook', 'call', { + secret + }); + helpers._text(helpers._get_action_values('input[name=code]'), token); + helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)')); + } +}, { // re-navigate to the profile as unless hr is installed the preference dialog will close + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Disable two-factor authentication)', + run: () => {} +}]); + +tour.register('totp_login_enabled', { + 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: "input code", + trigger: 'input[name=totp_token]', + run: async function (helpers) { + const token = await ajax.jsonRpc('/totphook', 'call', {}); + 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: () => {} +}, { + // 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 + // succeed w/ totp enabled + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Switch to security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: 'click', +}, { + content: "Open totp wizard", + trigger: 'button[name=totp_disable]', +}, { + content: "Check that we have to enter enhanced security mode", + trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text demo', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Reopen the preference / profile as we don't know whether HR is installed", + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Enable two-factor authentication)', + run: () => {} +}]); + +tour.register('totp_login_disabled', { + 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")', +}, { // normally we'd end there but being sure there's no more queries is a + // pain in the ass so go and open the profile screen + content: 'Open user account menu', + trigger: '.o_user_menu .oe_topbar_name', + run: 'click', +}, { + content: "Open preferences / profile screen", + trigger: '[data-menu=settings]', + run: 'click', +}, { + content: "Check the pref screen has opened by looking for the Account Security tab", + trigger: 'a[role=tab]:contains("Account Security")', + run: () => {} +}]); + +const columns = {}; +tour.register('totp_admin_disables', { + test: true, + url: '/web' +}, [tour.stepUtils.showAppsMenuItem(), { + content: 'Go to settings', + trigger: '[data-menu-xmlid="base.menu_administration"]' +}, { + content: 'Wait for page', + trigger: '.o_menu_brand:contains("Settings")', + run: () => {} +}, { + content: "Open Users menu", + trigger: '[data-menu-xmlid="base.menu_users"]' +}, { + content: "Open Users view", + trigger: '[data-menu-xmlid="base.menu_action_res_users"]', + run: function (helpers) { + // funny story: the users view we're trying to reach, sometimes we're + // already there, but if we re-click the next step executes before the + // action has the time to re-load, the one after that doesn't, and our + // selection get discarded by the action reloading, so here try to + // see if we're already on the users action through the breadcrumb and + // just close the menu if so + const $crumb = $('.breadcrumb'); + if ($crumb.text().indexOf('Users') === -1) { + // on general settings page, click menu + helpers.click(); + } else { + // else close menu + helpers.click($('[data-menu-xmlid="base.menu_users"]')); + } + } +}, { + content: "Find Demo User", + trigger: 'td.o_data_cell:contains("demo")', + run: function (helpers) { + const $titles = this.$anchor.closest('table').find('tr:first th'); + for (let i=0; i<$titles.length; ++i) { + columns[$titles[i].getAttribute('data-name')] = i; + } + const $row = this.$anchor.closest('tr'); + const sel = $row.find('.o_list_record_selector input[type=checkbox]'); + const totp = $row[0].children[columns['totp_enabled']].querySelector('input'); + if (totp.checked) { + helpers.click(sel); + } + } +}, { + content: "Open Actions menu", + trigger: 'button.o_dropdown_toggler_btn:contains("Action")' +}, { + content: "Select totp remover", + trigger: 'a.dropdown-item:contains(Disable TOTP on users)' +}, { // enhanced security yo + content: "Check that we have to enter enhanced security mode", +trigger: '.card-title:contains("confirm your password")', + run: () => {}, +}, { + content: "Input password", + trigger: '[name=password]', + run: 'text admin', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "check that demo user has been de-totp'd", + trigger: "td.o_data_cell:contains(demo)", + run: function () { + const totpcell = this.$anchor.closest('tr')[0].children[columns['totp_enabled']]; + if (totpcell.querySelector('input').checked) { + throw new Error("totp should have been disabled on demo user"); + } + } +}]) +}); diff --git a/addons/auth_totp/tests/__init__.py b/addons/auth_totp/tests/__init__.py new file mode 100644 index 00000000000..31e2de2ba2d --- /dev/null +++ b/addons/auth_totp/tests/__init__.py @@ -0,0 +1 @@ +from . import test_totp diff --git a/addons/auth_totp/tests/test_totp.py b/addons/auth_totp/tests/test_totp.py new file mode 100644 index 00000000000..b4b8b6478d2 --- /dev/null +++ b/addons/auth_totp/tests/test_totp.py @@ -0,0 +1,57 @@ +import time + +from passlib.totp import TOTP + +from odoo import http +from odoo.exceptions import AccessDenied +from odoo.service import common as auth, model +from odoo.tests import tagged, HttpCase + +from ..controllers.home import Home + +@tagged('post_install', '-at_install') +class TestTOTP(HttpCase): + def setUp(self): + super().setUp() + + totp = None + # test endpoint as doing totp on the client side is not really an option + # (needs sha1 and hmac + BE packing of 64b integers) + def totp_hook(self, secret=None): + nonlocal totp + if totp is None: + totp = TOTP(secret) + if secret: + return totp.generate().token + else: + # on check, take advantage of window because previous token has been + # "burned" so we can't generate the same, but tour is so fast + # we're pretty certainly within the same 30s + return totp.generate(time.time() + 30).token + # because not preprocessed by ControllerType metaclass + totp_hook.routing_type = 'json' + self.env['ir.http']._clear_routing_map() + # patch Home to add test endpoint + Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook) + # remove endpoint and destroy routing map + @self.addCleanup + def _cleanup(): + del Home.totp_hook + self.env['ir.http']._clear_routing_map() + + def test_totp(self): + # 1. Enable 2FA + self.start_tour('/web', 'totp_tour_setup', login='demo') + + # 2. Check 2FA is required + self.start_tour('/', 'totp_login_enabled', login=None) + + # 3. TODO: verify that RPC is blocked because 2FA is on. + + # 4. Finally, disable 2FA otherwise we can't re-login + self.start_tour('/', 'totp_login_disabled', login=None) + + def test_totp_administration(self): + self.start_tour('/web', 'totp_tour_setup', login='demo') + self.start_tour('/web', 'totp_admin_disables', login='admin') + self.start_tour('/', 'totp_login_disabled', login=None) diff --git a/addons/auth_totp/views/templates.xml b/addons/auth_totp/views/templates.xml new file mode 100644 index 00000000000..6ed3fdfac6f --- /dev/null +++ b/addons/auth_totp/views/templates.xml @@ -0,0 +1,38 @@ +<odoo> + <template id="assets_tests" inherit_id="web.assets_tests"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/auth_totp/static/tests/totp_flow.js"></script> + </xpath> + </template> + <template id="auth_totp_form"> + <t t-call="web.login_layout"> + <t t-set="disable_footer">1</t> + <h5 class="card-title">Two-factor Authentication</h5> + <div class="oe_login_form"> + <form method="POST" action="" class=""> + <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> + <input type="hidden" name="redirect" t-att-value="redirect"/> + <div class="form-group"> + <label for="totp_token">Authentication Code</label> + <input name="totp_token" class="form-control mb-2" + autofocus="autofocus" required="required" placeholder="6-digit code"/> + </div> + <p class="alert alert-danger" t-if="error" role="alert"> + <t t-esc="error"/> + </p> + <div t-attf-class="clearfix oe_login_buttons text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}"> + <button type="submit" class="btn btn-primary btn-block"> + Verify + </button> + </div> + </form> + <form method="POST" action="/web/session/logout" class="form-inline"> + <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> + <button type="submit" class="btn btn-link btn-sm mb-2"> + Cancel + </button> + </form> + </div> + </t> + </template> +</odoo> diff --git a/addons/auth_totp/views/user_preferences.xml b/addons/auth_totp/views/user_preferences.xml new file mode 100644 index 00000000000..b490efe2cbf --- /dev/null +++ b/addons/auth_totp/views/user_preferences.xml @@ -0,0 +1,108 @@ +<odoo> + <record model="ir.ui.view" id="view_totp_list"> + <field name="name">users list: add totp status</field> + <field name="model">res.users</field> + <field name="inherit_id" ref="base.view_users_tree"/> + <field name="arch" type="xml"> + <tree> + <field name="totp_enabled"/> + </tree> + </field> + </record> + <record model="ir.actions.server" id="action_disable_totp"> + <field name="name">Disable TOTP on users</field> + <field name="model_id" ref="base.model_res_users"/> + <field name="binding_model_id" ref="base.model_res_users"/> + <field name="binding_view_types">list</field> + <field name="state">code</field> + <field name="code"> + action = records.totp_disable() + </field> + <field name="groups_id" eval="[(4, ref('base.group_erp_manager'), 0)]"/> + </record> + + <record model="ir.ui.view" id="view_totp_wizard"> + <field name="name">auth_totp wizard</field> + <field name="model">auth_totp.wizard</field> + <field name="arch" type="xml"> + <form> + <sheet> + <div class="row container"> + <div class="col-lg-6 offset-lg-3 mb-3"> + <h3 class="font-weight-bold">Scan this barcode with your app</h3> + <div> + Scan the image below with the authenticator app on your phone.<br/> + If you cannot scan the barcode, here are some alternative options: + <ul> + <li><field class="text-wrap" name="url" widget="url" + options="{'website_path': True}" + text="Click on this link to open your authenticator app"/></li> + + <li>Or enter the secret code manually: + <a data-toggle="collapse" + href="#collapseTotpSecret" role="button" aria-expanded="false" + aria-controls="collapseTotpSecret">show the code</a> + </li> + </ul> + <!-- code outside list to have more horiz space on mobile --> + <div class="collapse" id="collapseTotpSecret"> + <div class="card card-body"> + <h3>Your two-factor secret:</h3> + <code><field name="secret"/></code> + </div> + </div> + </div> + + <field class="offset-1 offset-lg-2" name="qrcode" readonly="True" widget="image"/> + + <h3 class="font-weight-bold">Enter the 6-digit code from your app</h3> + <div class="text-justify"> + After scanning the barcode, the app will display a 6-digit code that you + should enter below. Don't worry if the code changes in the app, + it stays valid a bit longer. + </div> + <div class="mt-2"> + <label for="code">Verification Code</label> + <field required="True" name="code"/> + </div> + <button type="object" name="enable" class="btn btn-primary" + string="Enable two-factor authentication"/> + </div> + </div> + </sheet> + <footer><!-- no buttons --></footer> + </form> + </field> + </record> + + <record model="ir.ui.view" id="view_totp_field"> + <field name="name">users preference: totp</field> + <field name="model">res.users</field> + <field name="inherit_id" ref="base.view_users_form_simple_modif"/> + <field name="arch" type="xml"> + <button name="preference_change_password" position="after"> + <field name="totp_enabled" invisible="1"/> + <group attrs="{'invisible': [('totp_enabled', '!=', False)]}"> + <div> + <span class="alert alert-info" role="status"> + <i class="fa fa-warning"/> + Two-factor authentication not enabled + </span> + <button name="totp_enable_wizard" type="object" string="Enable two-factor authentication" + class="btn btn-info mx-3"/> + </div> + </group> + <group attrs="{'invisible': [('totp_enabled', '=', False)]}"> + <div> + <span class="text-success"> + <i class="fa fa-check-circle"/> + Two-factor authentication enabled + </span> + <button name="totp_disable" type="object" string="(Disable two-factor authentication)" + class="btn btn-link text-muted"/> + </div> + </group> + </button> + </field> + </record> +</odoo> |
