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_portal | |
| 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_portal')
| -rw-r--r-- | addons/auth_totp_portal/__init__.py | 1 | ||||
| -rw-r--r-- | addons/auth_totp_portal/__manifest__.py | 10 | ||||
| -rw-r--r-- | addons/auth_totp_portal/security/security.xml | 16 | ||||
| -rw-r--r-- | addons/auth_totp_portal/static/src/js/totp_frontend.js | 226 | ||||
| -rw-r--r-- | addons/auth_totp_portal/static/tests/totp_portal.js | 124 | ||||
| -rw-r--r-- | addons/auth_totp_portal/tests/__init__.py | 1 | ||||
| -rw-r--r-- | addons/auth_totp_portal/tests/test_tour.py | 44 | ||||
| -rw-r--r-- | addons/auth_totp_portal/views/templates.xml | 48 |
8 files changed, 470 insertions, 0 deletions
diff --git a/addons/auth_totp_portal/__init__.py b/addons/auth_totp_portal/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/addons/auth_totp_portal/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/addons/auth_totp_portal/__manifest__.py b/addons/auth_totp_portal/__manifest__.py new file mode 100644 index 00000000000..c316c51feb9 --- /dev/null +++ b/addons/auth_totp_portal/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': "TOTPortal", + 'category': 'Hidden', + 'depends': ['portal', 'auth_totp'], + 'auto_install': True, + 'data': [ + 'security/security.xml', + 'views/templates.xml', + ], +} diff --git a/addons/auth_totp_portal/security/security.xml b/addons/auth_totp_portal/security/security.xml new file mode 100644 index 00000000000..f9c4c2f70b4 --- /dev/null +++ b/addons/auth_totp_portal/security/security.xml @@ -0,0 +1,16 @@ +<odoo> + <!-- copies ACL for portal users --> + <record model="ir.model.access" id="access_auth_totp_portal_wizard"> + <field name="name">auth_totp_portal wizard access rules</field> + <field name="model_id" ref="auth_totp.model_auth_totp_wizard"/> + <field name="group_id" ref="base.group_portal"/> + <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> + <!-- adds portal users to rule --> + <record model="ir.rule" id="auth_totp.rule_auth_totp_wizard"> + <field name="groups" eval="[(4, ref('base.group_portal'))]"/> + </record> +</odoo> diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js new file mode 100644 index 00000000000..b4644fc77cd --- /dev/null +++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js @@ -0,0 +1,226 @@ +odoo.define('auth_totp_portal.button', function (require) { +'use strict'; + +const {_t} = require('web.core'); +const publicWidget = require('web.public.widget'); +const Dialog = require('web.Dialog'); +const {handleCheckIdentity} = require('portal.portal'); + +/** + * Replaces specific <field> elements by normal HTML, strip out the rest entirely + */ +function fromField(f, record) { + switch (f.getAttribute('name')) { + case 'qrcode': + const qrcode = document.createElement('img'); + qrcode.setAttribute('class', 'img img-fluid offset-1 offset-lg-2'); + qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']); + return qrcode; + case 'url': + const url = document.createElement('a'); + url.setAttribute('href', record['url']); + url.textContent = f.getAttribute('text') || record['url']; + return url; + case 'code': + const code = document.createElement('input'); + code.setAttribute('name', 'code'); + code.setAttribute('class', 'form-control'); + code.setAttribute('placeholder', '6-digit code'); + code.required = true; + code.maxLength = 6; + code.minLength = 6; + return code; + default: // just display the field's data + return document.createTextNode(record[f.getAttribute('name')] || ''); + } +} + +/** + * Apparently chrome literally absolutely can't handle parsing XML and using + * those nodes in an HTML document (even when parsing as application/xhtml+xml), + * this results in broken rendering and a number of things not working (e.g. + * classes) without any specific warning in the console or anything, things are + * just broken with no indication of why. + * + * So... rebuild the entire f'ing body using document.createElement to ensure + * we have HTML elements. + * + * This is a recursive implementation so it's not super efficient but the views + * to fixup *should* be relatively simple. + */ +function fixupViewBody(oldNode, record) { + let qrcode = null, code = null, node = null; + + switch (oldNode.nodeType) { + case 1: // element + if (oldNode.tagName === 'field') { + node = fromField(oldNode, record); + switch (oldNode.getAttribute('name')) { + case 'qrcode': + qrcode = node; + break; + case 'code': + code = node; + break + } + break; // no need to recurse here + } + node = document.createElement(oldNode.tagName); + for(let i=0; i<oldNode.attributes.length; ++i) { + const attr = oldNode.attributes[i]; + node.setAttribute(attr.name, attr.value); + } + for(let j=0; j<oldNode.childNodes.length; ++j) { + const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record); + if (ch) { node.appendChild(ch); } + if (qr) { qrcode = qr; } + if (co) { code = co; } + } + break; + case 3: case 4: // text, cdata + node = document.createTextNode(oldNode.data); + break; + default: + // don't care about PI & al + } + + return [node, qrcode, code] +} + +/** + * Converts a backend <button> element and a bunch of metadata into a structure + * which can kinda be of use to Dialog. + */ +class Button { + constructor(parent, model, record_id, input_node, button_node) { + this._parent = parent; + this.model = model; + this.record_id = record_id; + this.input = input_node; + this.text = button_node.getAttribute('string'); + this.classes = button_node.getAttribute('class') || null; + this.action = button_node.getAttribute('name'); + if (button_node.getAttribute('special') === 'cancel') { + this.close = true; + this.click = null; + } else { + this.close = false; + // because Dialog doesnt' call() click on the descriptor object + this.click = this._click.bind(this); + } + if (!button_node.closest('footer')) { + // remove non-footer buttons, otherwise they will appear twice + button_node.parentNode.removeChild(button_node); + } + } + async _click() { + if (!this.input.reportValidity()) { + this.input.classList.add('is-invalid'); + return; + } + + try { + await this.callAction(this.record_id, {code: this.input.value}); + } catch (e) { + this.input.classList.add('is-invalid'); + // show custom validity error message + this.input.setCustomValidity(e.message); + this.input.reportValidity(); + return; + } + this.input.classList.remove('is-invalid'); + // reloads page, avoid window.location.reload() because it re-posts forms + window.location = window.location; + } + async callAction(id, update) { + try { + await this._parent._rpc({model: this.model, method: 'write', args: [id, update]}); + await handleCheckIdentity( + this._parent.proxy('_rpc'), + this._parent._rpc({model: this.model, method: this.action, args: [id]}) + ); + } catch(e) { + // avoid error toast (crashmanager) + e.event.preventDefault(); + // try to unwrap mess of an error object to a usable error message + throw new Error( + !e.message ? e.toString() + : !e.message.data ? e.message.message + : e.message.data.message || _t("Operation failed for unknown reason.") + ); + } + } +} + +publicWidget.registry.TOTPButton = publicWidget.Widget.extend({ + selector: '#auth_totp_portal_enable', + events: { + click: '_onClick', + }, + + async _onClick(e) { + e.preventDefault(); + + const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({ + model: 'res.users', + method: 'totp_enable_wizard', + args: [this.getSession().user_id] + })); + + if (!w) { + // TOTP probably already enabled, just reload page + window.location = window.location; + return; + } + + const {res_model: model, res_id: wizard_id} = w; + + const record = await this._rpc({ + model, method: 'read', args: [wizard_id, []] + }).then(ar => ar[0]); + + const doc = new DOMParser().parseFromString( + document.getElementById('totp_wizard_view').textContent, + 'application/xhtml+xml' + ); + console.log(doc); + + const xmlBody = doc.querySelector('sheet *'); + const [body, , codeInput] = fixupViewBody(xmlBody, record); + // remove custom validity error message any time the field changes + // otherwise it sticks and browsers suppress submit + codeInput.addEventListener('input', () => codeInput.setCustomValidity('')); + + const buttons = []; + for(const button of body.querySelectorAll('button')) { + buttons.push(new Button(this, model, record.id, codeInput, button)); + } + + // wrap in a root host of .modal-body otherwise it breaks our neat flex layout + const $content = document.createElement('form'); + $content.appendChild(body); + // implicit submission by pressing [return] from within input + $content.addEventListener('submit', (e) => { + e.preventDefault(); + // sadness: footer not available as normal element + dialog.$footer.find('.btn-primary').click(); + }); + var dialog = new Dialog(this, {$content, buttons}).open(); + } +}); +publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({ + selector: '#auth_totp_portal_disable', + events: { + click: '_onClick' + }, + + async _onClick(e) { + e.preventDefault(); + await handleCheckIdentity( + this.proxy('_rpc'), + this._rpc({model: 'res.users', method: 'totp_disable', args: [this.getSession().user_id]}) + ) + window.location = window.location; + } +}); +}); diff --git a/addons/auth_totp_portal/static/tests/totp_portal.js b/addons/auth_totp_portal/static/tests/totp_portal.js new file mode 100644 index 00000000000..ec26366e9ad --- /dev/null +++ b/addons/auth_totp_portal/static/tests/totp_portal.js @@ -0,0 +1,124 @@ +odoo.define('auth_totp_portal.tours', function(require) { +"use strict"; + +const tour = require('web_tour.tour'); +const ajax = require('web.ajax'); + +tour.register('totportal_tour_setup', { + test: true, + url: '/my/security' +}, [{ + content: "Open totp wizard", + trigger: 'button#auth_totp_portal_enable', +}, { + 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 portal', // 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)')); + } +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Disable two-factor authentication)', + run: () => {} +}]); + +tour.register('totportal_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 portal', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text portal', +}, { + 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: "h3:contains(Documents)", + run: () => {} +}, { + content: "go back to security", + trigger: "a:contains(Security)", +},{ + content: "Open totp wizard", + trigger: 'button#auth_totp_portal_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 portal', // FIXME: better way to do this? +}, { + content: "Confirm", + trigger: "button:contains(Confirm Password)", +}, { + content: "Check that the button has changed", + trigger: 'button:contains(Enable two-factor authentication)', + run: () => {} +}]); + +tour.register('totportal_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 portal', +}, { + content: 'input password', + trigger: 'input#password', + run: 'text portal', +}, { + content: "click da button", + trigger: 'button:contains("Log in")', +}, { + content: "check we're logged in", + trigger: "h3:contains(Documents)", + run: () => {} +}]); +}); diff --git a/addons/auth_totp_portal/tests/__init__.py b/addons/auth_totp_portal/tests/__init__.py new file mode 100644 index 00000000000..f49429ef69a --- /dev/null +++ b/addons/auth_totp_portal/tests/__init__.py @@ -0,0 +1 @@ +from . import test_tour diff --git a/addons/auth_totp_portal/tests/test_tour.py b/addons/auth_totp_portal/tests/test_tour.py new file mode 100644 index 00000000000..242c9f0dcea --- /dev/null +++ b/addons/auth_totp_portal/tests/test_tour.py @@ -0,0 +1,44 @@ +import time + +from passlib.totp import TOTP + +from odoo import http +from odoo.tests import tagged, HttpCase +from odoo.addons.auth_totp.controllers.home import Home + + +@tagged('post_install', '-at_install') +class TestTOTPortal(HttpCase): + """ + Largely replicates TestTOTP + """ + def test_totp(self): + 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' + # patch Home to add test endpoint + Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook) + self.env['ir.http']._clear_routing_map() + # remove endpoint and destroy routing map + @self.addCleanup + def _cleanup(): + del Home.totp_hook + self.env['ir.http']._clear_routing_map() + + self.start_tour('/my/security', 'totportal_tour_setup', login='portal') + # also disables totp otherwise we can't re-login + self.start_tour('/', 'totportal_login_enabled', login=None) + self.start_tour('/', 'totportal_login_disabled', login=None) diff --git a/addons/auth_totp_portal/views/templates.xml b/addons/auth_totp_portal/views/templates.xml new file mode 100644 index 00000000000..48120eb7f52 --- /dev/null +++ b/addons/auth_totp_portal/views/templates.xml @@ -0,0 +1,48 @@ +<odoo> + <template id="auth_totp_frontend" name="TOTP frontend (portal)" inherit_id="portal.assets_frontend"> + <xpath expr="." position="inside"> + <script type="application/javascript" + src="/auth_totp_portal/static/src/js/totp_frontend.js"/> + </xpath> + </template> + <template id="assets_tests" inherit_id="web.assets_tests"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/auth_totp_portal/static/tests/totp_portal.js"></script> + </xpath> + </template> + + <template id="totp_portal_hook" name="TOTP Portal hook" inherit_id="portal.portal_my_security"> + <xpath expr="//div[hasclass('o_portal_security_body')]"> + <!-- + portal users don't have access to non-qweb views anymore, so + embed the target view as a *data island* of sorts, a JSON embed + of the fields_view_get could have been nice but at 14k it'd be a + bit ridiculous + --> + <div class="d-none" id="totp_wizard_view"> + <t t-esc="env.ref('auth_totp.view_totp_wizard').sudo().read_combined(['arch'])['arch']"/> + </div> + <section> + <h3>Two-factor authentication</h3> + <t t-if="not user_id.totp_enabled"> + <div class="alert alert-secondary" role="status"> + <i class="fa fa-warning"/> + Two-factor authentication not enabled + </div> + <button type="button" class="btn btn-secondary" id="auth_totp_portal_enable"> + Enable two-factor authentication + </button> + </t> + <t t-else=""> + <span class="text-success"> + <i class="fa fa-check-circle"/> + Two-factor authentication enabled + </span> + <button type="button" class="btn btn-link" id="auth_totp_portal_disable"> + (Disable two-factor authentication) + </button> + </t> + </section> + </xpath> + </template> +</odoo> |
