Initial commit: Security packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit bb469e4763
1399 changed files with 278378 additions and 0 deletions

View file

@ -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;
}
});
});

View file

@ -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;
}
}
}

View file

@ -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: () => {}
}]);
});