mirror of
https://github.com/bringout/oca-ocb-security.git
synced 2026-04-27 04:22:01 +02:00
Initial commit: Security packages
This commit is contained in:
commit
bb469e4763
1399 changed files with 278378 additions and 0 deletions
|
|
@ -0,0 +1,306 @@
|
|||
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');
|
||||
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 col-10 col-md-6');
|
||||
code.setAttribute('placeholder', '6-digit code');
|
||||
code.required = true;
|
||||
code.maxLength = 6;
|
||||
code.minLength = 6;
|
||||
return code;
|
||||
case 'secret':
|
||||
// As CopyClipboard wizard is backend only, mimic his behaviour to use it in frontend.
|
||||
// Field
|
||||
const secretSpan = document.createElement('span');
|
||||
secretSpan.setAttribute('name', 'secret');
|
||||
secretSpan.setAttribute('class', 'o_field_copy_url');
|
||||
secretSpan.textContent = record['secret'];
|
||||
|
||||
// Copy Button
|
||||
const copySpanIcon = document.createElement('span');
|
||||
copySpanIcon.setAttribute('class', 'fa fa-clipboard');
|
||||
const copySpanText = document.createElement('span');
|
||||
copySpanText.textContent = _t(' Copy');
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.setAttribute('class', 'btn btn-sm btn-primary o_clipboard_button o_btn_char_copy py-0 px-2');
|
||||
copyButton.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
$(copyButton).tooltip({title: _t("Copied !"), trigger: "manual", placement: "bottom"});
|
||||
var clipboard = new ClipboardJS('.o_clipboard_button', {
|
||||
target: function () {
|
||||
return $(secretSpan)[0];
|
||||
},
|
||||
container: this.el
|
||||
});
|
||||
clipboard.on('success', function () {
|
||||
clipboard.destroy();
|
||||
$(copyButton).tooltip('show');
|
||||
_.delay(function () {
|
||||
$(copyButton).tooltip("hide");
|
||||
}, 800);
|
||||
});
|
||||
clipboard.on('error', function (e) {
|
||||
clipboard.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
copyButton.appendChild(copySpanIcon);
|
||||
copyButton.appendChild(copySpanText);
|
||||
|
||||
// CopyClipboard Div
|
||||
const secretDiv = document.createElement('div');
|
||||
secretDiv.setAttribute('class', 'o_field_copy d-flex justify-content-center align-items-center');
|
||||
secretDiv.appendChild(secretSpan);
|
||||
secretDiv.appendChild(copyButton);
|
||||
|
||||
return secretDiv;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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: 'action_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'
|
||||
);
|
||||
|
||||
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 doc.querySelectorAll('footer 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: 'action_totp_disable', args: [this.getSession().user_id]})
|
||||
)
|
||||
window.location = window.location;
|
||||
}
|
||||
});
|
||||
publicWidget.registry.RevokeTrustedDeviceButton = publicWidget.Widget.extend({
|
||||
selector: '#totp_wizard_view + * .fa.fa-trash.text-danger',
|
||||
events: {
|
||||
click: '_onClick'
|
||||
},
|
||||
|
||||
async _onClick(e){
|
||||
e.preventDefault();
|
||||
await handleCheckIdentity(
|
||||
this.proxy('_rpc'),
|
||||
this._rpc({
|
||||
model: 'auth_totp.device',
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/* As we use the Enable TOTP backend wizard in frontend, we need to
|
||||
import the needed css from backend to replicate backend copyclipboard
|
||||
widget style. We cannot put this into the original view as all those
|
||||
classes are generated in the backend widget. */
|
||||
.o_auth_totp_enable_2FA {
|
||||
.o_field_copy {
|
||||
height: 24px;
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
border-radius: 5px;
|
||||
border: 1px solid $primary;
|
||||
font-size: $font-size-sm;
|
||||
text-transform: uppercase;
|
||||
color: $o-brand-primary;
|
||||
font-weight: $badge-font-weight;
|
||||
text-align: center;
|
||||
padding-right: 6rem;
|
||||
word-break: break-word;
|
||||
.o_clipboard_button {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
&.o_btn_text_copy {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
&.o_btn_char_copy {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
&.o_field_copy_url {
|
||||
cursor: pointer;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: 'div:contains("enter 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: 'li:contains("scan the barcode below")',
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "Get secret from collapsed div",
|
||||
trigger: 'a:contains("Cannot scan it?")',
|
||||
run: async function(helpers) {
|
||||
const secret = this.$anchor.closest('div').find('span[name="secret"]').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(Activate)'));
|
||||
}
|
||||
}, {
|
||||
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("Log in")'));
|
||||
}
|
||||
}, {
|
||||
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: 'div:contains("enter 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: () => {}
|
||||
}]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue