www: use TFA widgets from widget toolkit

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2021-11-17 09:27:21 +01:00
parent 52fbc86fc9
commit 9a7431e2e0
16 changed files with 15 additions and 1663 deletions

View File

@ -105,7 +105,7 @@ Ext.define('PBS.LoginView', {
));
let resp = await new Promise((resolve, reject) => {
Ext.create('PBS.login.TfaWindow', {
Ext.create('Proxmox.window.TfaLoginWindow', {
userid,
ticket,
challenge,
@ -331,360 +331,3 @@ Ext.define('PBS.LoginView', {
},
],
});
Ext.define('PBS.login.TfaWindow', {
extend: 'Ext.window.Window',
mixins: ['Proxmox.Mixin.CBind'],
title: gettext("Second login factor required"),
modal: true,
resizable: false,
width: 512,
layout: {
type: 'vbox',
align: 'stretch',
},
defaultButton: 'tfaButton',
viewModel: {
data: {
confirmText: gettext('Confirm Second Factor'),
canConfirm: false,
availableChallenge: {},
},
},
cancelled: true,
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
let me = this;
let vm = me.getViewModel();
if (!view.userid) {
throw "no userid given";
}
if (!view.ticket) {
throw "no ticket given";
}
const challenge = view.challenge;
if (!challenge) {
throw "no challenge given";
}
let lastTabId = me.getLastTabUsed();
let initialTab = -1, i = 0;
for (const k of ['webauthn', 'totp', 'recovery']) {
const available = !!challenge[k];
vm.set(`availableChallenge.${k}`, available);
if (available) {
if (i === lastTabId) {
initialTab = i;
} else if (initialTab < 0) {
initialTab = i;
}
}
i++;
}
view.down('tabpanel').setActiveTab(initialTab);
if (challenge.recovery) {
me.lookup('availableRecovery').update(Ext.String.htmlEncode(
gettext('Available recovery keys: ') + view.challenge.recovery.join(', '),
));
me.lookup('availableRecovery').setVisible(true);
if (view.challenge.recovery.length <= 3) {
me.lookup('recoveryLow').setVisible(true);
}
}
if (challenge.webauthn && initialTab === 0) {
let _promise = me.loginWebauthn();
}
},
control: {
'tabpanel': {
tabchange: function(tabPanel, newCard, oldCard) {
// for now every TFA method has at max one field, so keep it simple..
let oldField = oldCard.down('field');
if (oldField) {
oldField.setDisabled(true);
}
let newField = newCard.down('field');
if (newField) {
newField.setDisabled(false);
newField.focus();
newField.validate();
}
let confirmText = newCard.confirmText || gettext('Confirm Second Factor');
this.getViewModel().set('confirmText', confirmText);
this.saveLastTabUsed(tabPanel, newCard);
},
},
'field': {
validitychange: function(field, valid) {
// triggers only for enabled fields and we disable the one from the
// non-visible tab, so we can just directly use the valid param
this.getViewModel().set('canConfirm', valid);
},
afterrender: field => field.focus(), // ensure focus after initial render
},
},
saveLastTabUsed: function(tabPanel, card) {
let id = tabPanel.items.indexOf(card);
window.localStorage.setItem('PBS.TFALogin.lastTab', JSON.stringify({ id }));
},
getLastTabUsed: function() {
let data = window.localStorage.getItem('PBS.TFALogin.lastTab');
if (typeof data === 'string') {
let last = JSON.parse(data);
return last.id;
}
return null;
},
onClose: function() {
let me = this;
let view = me.getView();
if (!view.cancelled) {
return;
}
view.onReject();
},
cancel: function() {
this.getView().close();
},
loginTotp: function() {
let me = this;
let code = me.lookup('totp').getValue();
let _promise = me.finishChallenge(`totp:${code}`);
},
loginWebauthn: async function() {
let me = this;
let view = me.getView();
me.lookup('webAuthnWaiting').setVisible(true);
me.lookup('webAuthnError').setVisible(false);
let challenge = view.challenge.webauthn;
if (typeof challenge.string !== 'string') {
// Byte array fixup, keep challenge string:
challenge.string = challenge.publicKey.challenge;
challenge.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge.string);
for (const cred of challenge.publicKey.allowCredentials) {
cred.id = PBS.Utils.base64url_to_bytes(cred.id);
}
}
let controller = new AbortController();
challenge.signal = controller.signal;
let hwrsp;
try {
//Promise.race( ...
hwrsp = await navigator.credentials.get(challenge);
} catch (error) {
// we do NOT want to fail login because of canceling the challenge actively,
// in some browser that's the only way to switch over to another method as the
// disallow user input during the time the challenge is active
// checking for error.code === DOMException.ABORT_ERR only works in firefox -.-
this.getViewModel().set('canConfirm', true);
// FIXME: better handling, show some message, ...?
me.lookup('webAuthnError').setData({
error: Ext.htmlEncode(error.toString()),
});
me.lookup('webAuthnError').setVisible(true);
return;
} finally {
let waitingMessage = me.lookup('webAuthnWaiting');
if (waitingMessage) {
waitingMessage.setVisible(false);
}
}
let response = {
id: hwrsp.id,
type: hwrsp.type,
challenge: challenge.string,
rawId: PBS.Utils.bytes_to_base64url(hwrsp.rawId),
response: {
authenticatorData: PBS.Utils.bytes_to_base64url(
hwrsp.response.authenticatorData,
),
clientDataJSON: PBS.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON),
signature: PBS.Utils.bytes_to_base64url(hwrsp.response.signature),
},
};
await me.finishChallenge("webauthn:" + JSON.stringify(response));
},
loginRecovery: function() {
let me = this;
let key = me.lookup('recoveryKey').getValue();
let _promise = me.finishChallenge(`recovery:${key}`);
},
loginTFA: function() {
let me = this;
// avoid triggering more than once during challenge
me.getViewModel().set('canConfirm', false);
let view = me.getView();
let tfaPanel = view.down('tabpanel').getActiveTab();
me[tfaPanel.handler]();
},
finishChallenge: function(password) {
let me = this;
let view = me.getView();
view.cancelled = false;
let params = {
username: view.userid,
'tfa-challenge': view.ticket,
password,
};
let resolve = view.onResolve;
let reject = view.onReject;
view.close();
return Proxmox.Async.api2({
url: '/api2/extjs/access/ticket',
method: 'POST',
params,
})
.then(resolve)
.catch(reject);
},
},
listeners: {
close: 'onClose',
},
items: [{
xtype: 'tabpanel',
region: 'center',
layout: 'fit',
bodyPadding: 10,
items: [
{
xtype: 'panel',
title: 'WebAuthn',
iconCls: 'fa fa-fw fa-shield',
confirmText: gettext('Start WebAuthn challenge'),
handler: 'loginWebauthn',
bind: {
disabled: '{!availableChallenge.webauthn}',
},
items: [
{
xtype: 'box',
html: gettext('Please insert your authentication device and press its button'),
},
{
xtype: 'box',
html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
reference: 'webAuthnWaiting',
hidden: true,
},
{
xtype: 'box',
data: {
error: '',
},
tpl: '<i class="fa fa-warning warning"></i> {error}',
reference: 'webAuthnError',
hidden: true,
},
],
},
{
xtype: 'panel',
title: gettext('TOTP App'),
iconCls: 'fa fa-fw fa-clock-o',
handler: 'loginTotp',
bind: {
disabled: '{!availableChallenge.totp}',
},
items: [
{
xtype: 'textfield',
fieldLabel: gettext('Please enter your TOTP verification code'),
labelWidth: 300,
name: 'totp',
disabled: true,
reference: 'totp',
allowBlank: false,
regex: /^[0-9]{6}$/,
regexText: gettext('TOTP codes consist of six decimal digits'),
},
],
},
{
xtype: 'panel',
title: gettext('Recovery Key'),
iconCls: 'fa fa-fw fa-file-text-o',
handler: 'loginRecovery',
bind: {
disabled: '{!availableChallenge.recovery}',
},
items: [
{
xtype: 'box',
reference: 'availableRecovery',
hidden: true,
},
{
xtype: 'textfield',
fieldLabel: gettext('Please enter one of your single-use recovery keys'),
labelWidth: 300,
name: 'recoveryKey',
disabled: true,
reference: 'recoveryKey',
allowBlank: false,
regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/,
regexText: gettext('Does not look like a valid recovery key'),
},
{
xtype: 'box',
reference: 'recoveryLow',
hidden: true,
html: '<i class="fa fa-exclamation-triangle warning"></i>'
+ gettext('Less than {0} recovery keys available. Please generate a new set after login!'),
},
],
},
],
}],
buttons: [
{
handler: 'loginTFA',
reference: 'tfaButton',
disabled: true,
bind: {
text: '{confirmText}',
disabled: '{!canConfirm}',
},
},
],
});

View File

@ -36,7 +36,6 @@ TAPE_UI_FILES= \
JSSRC= \
Utils.js \
form/UserSelector.js \
form/TokenSelector.js \
form/AuthidSelector.js \
form/RemoteSelector.js \
@ -46,7 +45,6 @@ JSSRC= \
data/RunningTasksStore.js \
button/TaskButton.js \
config/UserView.js \
config/TfaView.js \
config/TokenView.js \
config/RemoteView.js \
config/ACLView.js \
@ -56,9 +54,6 @@ JSSRC= \
config/CertificateView.js \
config/NodeOptionView.js \
window/ACLEdit.js \
window/AddTfaRecovery.js \
window/AddTotp.js \
window/AddWebauthn.js \
window/BackupFileDownloader.js \
window/BackupGroupChangeOwner.js \
window/CreateDirectory.js \
@ -71,7 +66,6 @@ JSSRC= \
window/UserPassword.js \
window/Settings.js \
window/TokenEdit.js \
window/TfaEdit.js \
window/VerifyJobEdit.js \
window/ZFSCreate.js \
dashboard/DataStoreStatistics.js \

View File

@ -85,7 +85,7 @@ const proxmoxOnlineHelpInfo = {
},
"local-zfs-special-device": {
"link": "/docs/sysadmin.html#local-zfs-special-device",
"title": "ZFS Special Device"
"title": "ZFS special device"
},
"maintenance-pruning": {
"link": "/docs/maintenance.html#maintenance-pruning",
@ -115,6 +115,10 @@ const proxmoxOnlineHelpInfo = {
"link": "/docs/network-management.html#sysadmin-network-configuration",
"title": "Network Management"
},
"sysadmin-traffic-control": {
"link": "/docs/traffic-control.html#sysadmin-traffic-control",
"title": "Traffic Control"
},
"pve-integration": {
"link": "/docs/pve-integration.html#pve-integration",
"title": "`Proxmox VE`_ Integration"
@ -185,7 +189,7 @@ const proxmoxOnlineHelpInfo = {
},
"user-tfa": {
"link": "/docs/user-management.html#user-tfa",
"title": "Two-factor authentication"
"title": "Two-Factor Authentication"
},
"user-tfa-setup-totp": {
"link": "/docs/user-management.html#user-tfa-setup-totp",

View File

@ -1,402 +0,0 @@
Ext.define('pbs-tfa-users', {
extend: 'Ext.data.Model',
fields: ['userid'],
idProperty: 'userid',
proxy: {
type: 'proxmox',
url: '/api2/json/access/tfa',
},
});
Ext.define('pbs-tfa-entry', {
extend: 'Ext.data.Model',
fields: ['fullid', 'userid', 'type', 'description', 'created', 'enable'],
idProperty: 'fullid',
});
Ext.define('PBS.config.TfaView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pbsTfaView',
title: gettext('Second Factors'),
reference: 'tfaview',
store: {
type: 'diff',
autoDestroy: true,
autoDestroyRstore: true,
model: 'pbs-tfa-entry',
rstore: {
type: 'store',
proxy: 'memory',
storeid: 'pbs-tfa-entry',
model: 'pbs-tfa-entry',
},
},
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
let me = this;
view.tfaStore = Ext.create('Proxmox.data.UpdateStore', {
autoStart: true,
interval: 5 * 1000,
storeid: 'pbs-tfa-users',
model: 'pbs-tfa-users',
});
view.tfaStore.on('load', this.onLoad, this);
view.on('destroy', view.tfaStore.stopUpdate);
Proxmox.Utils.monStoreErrors(view, view.tfaStore);
},
reload: function() { this.getView().tfaStore.load(); },
onLoad: function(store, data, success) {
if (!success) return;
let records = [];
Ext.Array.each(data, user => {
Ext.Array.each(user.data.entries, entry => {
records.push({
fullid: `${user.id}/${entry.id}`,
userid: user.id,
type: entry.type,
description: entry.description,
created: entry.created,
enable: entry.enable,
});
});
});
let rstore = this.getView().store.rstore;
rstore.loadData(records);
rstore.fireEvent('load', rstore, records, true);
},
addTotp: function() {
let me = this;
Ext.create('PBS.window.AddTotp', {
isCreate: true,
listeners: {
destroy: function() {
me.reload();
},
},
}).show();
},
addWebauthn: function() {
let me = this;
Ext.create('PBS.window.AddWebauthn', {
isCreate: true,
listeners: {
destroy: function() {
me.reload();
},
},
}).show();
},
addRecovery: async function() {
let me = this;
Ext.create('PBS.window.AddTfaRecovery', {
listeners: {
destroy: function() {
me.reload();
},
},
}).show();
},
editItem: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (selection.length !== 1 || selection[0].id.endsWith("/recovery")) {
return;
}
Ext.create('PBS.window.TfaEdit', {
'tfa-id': selection[0].data.fullid,
listeners: {
destroy: function() {
me.reload();
},
},
}).show();
},
renderUser: fullid => fullid.split('/')[0],
renderEnabled: enabled => {
if (enabled === undefined) {
return Proxmox.Utils.yesText;
} else {
return Proxmox.Utils.format_boolean(enabled);
}
},
onRemoveButton: function(btn, event, record) {
let me = this;
Ext.create('PBS.tfa.confirmRemove', {
...record.data,
callback: password => me.removeItem(password, record),
})
.show();
},
removeItem: async function(password, record) {
let me = this;
let params = {};
if (password !== null) {
params.password = password;
}
try {
me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
await Proxmox.Async.api2({
url: `/api2/extjs/access/tfa/${record.id}`,
method: 'DELETE',
params,
});
me.reload();
} catch (response) {
Ext.Msg.alert(gettext('Error'), response.result.message);
} finally {
me.getView().unmask();
}
},
},
viewConfig: {
trackOver: false,
},
listeners: {
itemdblclick: 'editItem',
},
columns: [
{
header: gettext('User'),
width: 200,
sortable: true,
dataIndex: 'fullid',
renderer: 'renderUser',
},
{
header: gettext('Enabled'),
width: 80,
sortable: true,
dataIndex: 'enable',
renderer: 'renderEnabled',
},
{
header: gettext('TFA Type'),
width: 80,
sortable: true,
dataIndex: 'type',
},
{
header: gettext('Created'),
width: 150,
sortable: true,
dataIndex: 'created',
renderer: Proxmox.Utils.render_timestamp,
},
{
header: gettext('Description'),
width: 300,
sortable: true,
dataIndex: 'description',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
tbar: [
{
text: gettext('Add'),
menu: {
xtype: 'menu',
items: [
{
text: gettext('TOTP'),
itemId: 'totp',
iconCls: 'fa fa-fw fa-clock-o',
handler: 'addTotp',
},
{
text: gettext('Webauthn'),
itemId: 'webauthn',
iconCls: 'fa fa-fw fa-shield',
handler: 'addWebauthn',
},
{
text: gettext('Recovery Keys'),
itemId: 'recovery',
iconCls: 'fa fa-fw fa-file-text-o',
handler: 'addRecovery',
},
],
},
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
handler: 'editItem',
enableFn: rec => !rec.id.endsWith("/recovery"),
disabled: true,
},
{
xtype: 'proxmoxButton',
disabled: true,
text: gettext('Remove'),
getRecordName: rec => rec.data.description,
handler: 'onRemoveButton',
},
],
});
Ext.define('PBS.tfa.confirmRemove', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
title: gettext("Confirm TFA Removal"),
modal: true,
resizable: false,
width: 600,
isCreate: true, // logic
isRemove: true,
url: '/access/tfa',
initComponent: function() {
let me = this;
if (typeof me.type !== "string") {
throw "missing type";
}
if (!me.callback) {
throw "missing callback";
}
me.callParent();
if (Proxmox.UserName === 'root@pam') {
me.lookup('password').setVisible(false);
me.lookup('password').setDisabled(true);
}
},
submit: function() {
let me = this;
if (Proxmox.UserName === 'root@pam') {
me.callback(null);
} else {
me.callback(me.lookup('password').getValue());
}
me.close();
},
items: [
{
xtype: 'box',
padding: '0 0 10 0',
html: Ext.String.format(
gettext('Are you sure you want to remove this {0} entry?'),
'TFA',
),
},
{
xtype: 'container',
layout: {
type: 'hbox',
align: 'begin',
},
defaults: {
border: false,
layout: 'anchor',
flex: 1,
padding: 5,
},
items: [
{
xtype: 'container',
layout: {
type: 'vbox',
},
padding: '0 10 0 0',
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('User'),
cbind: {
value: '{userid}',
},
},
{
xtype: 'displayfield',
fieldLabel: gettext('Type'),
cbind: {
value: '{type}',
},
},
],
},
{
xtype: 'container',
layout: {
type: 'vbox',
},
padding: '0 0 0 10',
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('Created'),
renderer: v => Proxmox.Utils.render_timestamp(v),
cbind: {
value: '{created}',
},
},
{
xtype: 'textfield',
fieldLabel: gettext('Description'),
cbind: {
value: '{description}',
},
emptyText: Proxmox.Utils.NoneText,
submitValue: false,
editable: false,
},
],
},
],
},
{
xtype: 'textfield',
inputType: 'password',
fieldLabel: gettext('Password'),
minLength: 5,
reference: 'password',
name: 'password',
allowBlank: false,
validateBlank: true,
padding: '10 0 0 0',
cbind: {
emptyText: () =>
Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
},
},
],
});

View File

@ -1,50 +0,0 @@
Ext.define('PBS.form.UserSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pbsUserSelector',
allowBlank: false,
autoSelect: false,
valueField: 'userid',
displayField: 'userid',
editable: true,
anyMatch: true,
forceSelection: true,
store: {
model: 'pmx-users',
autoLoad: true,
params: {
enabled: 1,
},
sorters: 'userid',
},
listConfig: {
columns: [
{
header: gettext('User'),
sortable: true,
dataIndex: 'userid',
renderer: Ext.String.htmlEncode,
flex: 1,
},
{
header: gettext('Name'),
sortable: true,
renderer: (first, mD, rec) => Ext.String.htmlEncode(
`${first || ''} ${rec.data.lastname || ''}`,
),
dataIndex: 'firstname',
flex: 1,
},
{
header: gettext('Comment'),
sortable: false,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
},
});

View File

@ -20,7 +20,7 @@ Ext.define('PBS.AccessControlPanel', {
iconCls: 'fa fa-user',
},
{
xtype: 'pbsTfaView',
xtype: 'pmxTfaView',
title: gettext('Two Factor Authentication'),
itemId: 'tfa',
iconCls: 'fa fa-key',

View File

@ -55,7 +55,7 @@ Ext.define('PBS.TapeManagement.TapeBackupWindow', {
fieldLabel: gettext('Eject Media'),
},
{
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'notify-user',
fieldLabel: gettext('Notify User'),
emptyText: 'root@pam',

View File

@ -65,7 +65,7 @@ Ext.define('PBS.TapeManagement.BackupJobEdit', {
name: 'drive',
},
{
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'notify-user',
fieldLabel: gettext('Notify User'),
emptyText: 'root@pam',

View File

@ -388,7 +388,7 @@ Ext.define('PBS.TapeManagement.TapeRestoreWindow', {
},
column1: [
{
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'notify-user',
fieldLabel: gettext('Notify User'),
emptyText: gettext('Current User'),
@ -398,7 +398,7 @@ Ext.define('PBS.TapeManagement.TapeRestoreWindow', {
renderer: Ext.String.htmlEncode,
},
{
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'owner',
fieldLabel: gettext('Owner'),
emptyText: gettext('Current User'),

View File

@ -34,7 +34,7 @@ Ext.define('PBS.window.ACLEdit', {
if (me.aclType === 'user') {
me.subject = gettext('User Permission');
me.items.push({
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'auth-id',
fieldLabel: gettext('User'),
allowBlank: false,

View File

@ -1,224 +0,0 @@
Ext.define('PBS.window.AddTfaRecovery', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pbsAddTfaRecovery',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'user_mgmt',
isCreate: true,
isAdd: true,
subject: gettext('TFA recovery keys'),
width: 512,
method: 'POST',
fixedUser: false,
url: '/api2/extjs/access/tfa',
submitUrl: function(url, values) {
let userid = values.userid;
delete values.userid;
return `${url}/${userid}`;
},
apiCallDone: function(success, response) {
if (!success) {
return;
}
let values = response
.result
.data
.recovery
.map((v, i) => `${i}: ${v}`)
.join("\n");
Ext.create('PBS.window.TfaRecoveryShow', {
autoShow: true,
userid: this.getViewModel().get('userid'),
values,
});
},
viewModel: {
data: {
has_entry: false,
userid: null,
},
},
controller: {
xclass: 'Ext.app.ViewController',
hasEntry: async function(userid) {
let me = this;
let view = me.getView();
try {
await Proxmox.Async.api2({
url: `${view.url}/${userid}/recovery`,
method: 'GET',
});
return true;
} catch (_response) {
return false;
}
},
init: function(view) {
this.onUseridChange(null, Proxmox.UserName);
},
onUseridChange: async function(field, userid) {
let me = this;
let vm = me.getViewModel();
me.userid = userid;
vm.set('userid', userid);
let has_entry = await me.hasEntry(userid);
vm.set('has_entry', has_entry);
},
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'userid',
cbind: {
editable: (get) => !get('fixedUser'),
value: () => Proxmox.UserName,
},
fieldLabel: gettext('User'),
editConfig: {
xtype: 'pbsUserSelector',
allowBlank: false,
validator: function(_value) {
return !this.up('window').getViewModel().get('has_entry');
},
},
renderer: Ext.String.htmlEncode,
listeners: {
change: 'onUseridChange',
},
},
{
xtype: 'hiddenfield',
name: 'type',
value: 'recovery',
},
{
xtype: 'displayfield',
bind: {
hidden: '{!has_entry}',
},
hidden: true,
userCls: 'pmx-hint',
value: gettext('User already has recovery keys.'),
},
{
xtype: 'textfield',
name: 'password',
reference: 'password',
fieldLabel: gettext('Verify Password'),
inputType: 'password',
minLength: 5,
allowBlank: false,
validateBlank: true,
cbind: {
hidden: () => Proxmox.UserName === 'root@pam',
disabled: () => Proxmox.UserName === 'root@pam',
emptyText: () =>
Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
},
},
],
});
Ext.define('PBS.window.TfaRecoveryShow', {
extend: 'Ext.window.Window',
alias: ['widget.pbsTfaRecoveryShow'],
mixins: ['Proxmox.Mixin.CBind'],
width: 600,
modal: true,
resizable: false,
title: gettext('Recovery Keys'),
onEsc: Ext.emptyFn,
items: [
{
xtype: 'form',
layout: 'anchor',
bodyPadding: 10,
border: false,
fieldDefaults: {
anchor: '100%',
},
items: [
{
xtype: 'textarea',
editable: false,
inputId: 'token-secret-value',
cbind: {
value: '{values}',
},
fieldStyle: {
'fontFamily': 'monospace',
},
height: '160px',
},
{
xtype: 'displayfield',
border: false,
padding: '5 0 0 0',
userCls: 'pmx-hint',
value: gettext('Please record recovery keys - they will only be displayed now'),
},
],
},
],
buttons: [
{
handler: function(b) {
document.getElementById('token-secret-value').select();
document.execCommand("copy");
},
iconCls: 'fa fa-clipboard',
text: gettext('Copy Recovery Keys'),
},
{
handler: function(b) {
let win = this.up('window');
win.paperkeys(win.values, win.userid);
},
iconCls: 'fa fa-print',
text: gettext('Print Recovery Keys'),
},
],
paperkeys: function(keyString, userid) {
let me = this;
let printFrame = document.createElement("iframe");
Object.assign(printFrame.style, {
position: "fixed",
right: "0",
bottom: "0",
width: "0",
height: "0",
border: "0",
});
const host = document.location.host;
const title = document.title;
const html = `<html><head><script>
window.addEventListener('DOMContentLoaded', (ev) => window.print());
</script><style>@media print and (max-height: 150mm) {
h4, p { margin: 0; font-size: 1em; }
}</style></head><body style="padding: 5px;">
<h4>Recovery Keys for '${userid}' - ${title} (${host})</h4>
<p style="font-size:1.5em;line-height:1.5em;font-family:monospace;
white-space:pre-wrap;overflow-wrap:break-word;">
${keyString}
</p>
</body></html>`;
printFrame.src = "data:text/html;base64," + btoa(html);
document.body.appendChild(printFrame);
},
});

View File

@ -1,294 +0,0 @@
/*global QRCode*/
Ext.define('PBS.window.AddTotp', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pbsAddTotp',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'user_mgmt',
modal: true,
resizable: false,
title: gettext('Add a TOTP login factor'),
width: 512,
layout: {
type: 'vbox',
align: 'stretch',
},
isAdd: true,
userid: undefined,
tfa_id: undefined,
fixedUser: false,
updateQrCode: function() {
let me = this;
let values = me.lookup('totp_form').getValues();
let algorithm = values.algorithm;
if (!algorithm) {
algorithm = 'SHA1';
}
let otpuri =
'otpauth://totp/' +
encodeURIComponent(values.issuer) +
':' +
encodeURIComponent(values.userid) +
'?secret=' + values.secret +
'&period=' + values.step +
'&digits=' + values.digits +
'&algorithm=' + algorithm +
'&issuer=' + encodeURIComponent(values.issuer);
me.getController().getViewModel().set('otpuri', otpuri);
me.qrcode.makeCode(otpuri);
me.lookup('challenge').setVisible(true);
me.down('#qrbox').setVisible(true);
},
viewModel: {
data: {
valid: false,
secret: '',
otpuri: '',
userid: null,
},
formulas: {
secretEmpty: function(get) {
return get('secret').length === 0;
},
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'field[qrupdate=true]': {
change: function() {
this.getView().updateQrCode();
},
},
'field': {
validitychange: function(field, valid) {
let me = this;
let viewModel = me.getViewModel();
let form = me.lookup('totp_form');
let challenge = me.lookup('challenge');
let password = me.lookup('password');
viewModel.set('valid', form.isValid() && challenge.isValid() && password.isValid());
},
},
'#': {
show: function() {
let me = this;
let view = me.getView();
view.qrdiv = document.createElement('div');
view.qrcode = new QRCode(view.qrdiv, {
width: 256,
height: 256,
correctLevel: QRCode.CorrectLevel.M,
});
view.down('#qrbox').getEl().appendChild(view.qrdiv);
view.getController().randomizeSecret();
},
},
},
randomizeSecret: function() {
let me = this;
let rnd = new Uint8Array(32);
window.crypto.getRandomValues(rnd);
let data = '';
rnd.forEach(function(b) {
// secret must be base32, so just use the first 5 bits
b = b & 0x1f;
if (b < 26) {
// A..Z
data += String.fromCharCode(b + 0x41);
} else {
// 2..7
data += String.fromCharCode(b-26 + 0x32);
}
});
me.getViewModel().set('secret', data);
},
},
items: [
{
xtype: 'form',
layout: 'anchor',
border: false,
reference: 'totp_form',
fieldDefaults: {
anchor: '100%',
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'userid',
cbind: {
editable: (get) => get('isAdd') && !get('fixedUser'),
value: () => Proxmox.UserName,
},
fieldLabel: gettext('User'),
editConfig: {
xtype: 'pbsUserSelector',
allowBlank: false,
},
renderer: Ext.String.htmlEncode,
listeners: {
change: function(field, newValue, oldValue) {
let vm = this.up('window').getViewModel();
vm.set('userid', newValue);
},
},
qrupdate: true,
},
{
xtype: 'textfield',
fieldLabel: gettext('Description'),
emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
allowBlank: false,
name: 'description',
maxLength: 256,
},
{
layout: 'hbox',
border: false,
padding: '0 0 5 0',
items: [
{
xtype: 'textfield',
fieldLabel: gettext('Secret'),
emptyText: gettext('Unchanged'),
name: 'secret',
reference: 'tfa_secret',
regex: /^[A-Z2-7=]+$/,
regexText: 'Must be base32 [A-Z2-7=]',
maskRe: /[A-Z2-7=]/,
qrupdate: true,
bind: {
value: "{secret}",
},
flex: 4,
padding: '0 5 0 0',
},
{
xtype: 'button',
text: gettext('Randomize'),
reference: 'randomize_button',
handler: 'randomizeSecret',
flex: 1,
},
],
},
{
xtype: 'numberfield',
fieldLabel: gettext('Time period'),
name: 'step',
// Google Authenticator ignores this and generates bogus data
hidden: true,
value: 30,
minValue: 10,
qrupdate: true,
},
{
xtype: 'numberfield',
fieldLabel: gettext('Digits'),
name: 'digits',
value: 6,
// Google Authenticator ignores this and generates bogus data
hidden: true,
minValue: 6,
maxValue: 8,
qrupdate: true,
},
{
xtype: 'textfield',
fieldLabel: gettext('Issuer Name'),
name: 'issuer',
value: `Proxmox Backup Server - ${Proxmox.NodeName}`,
qrupdate: true,
},
{
xtype: 'box',
itemId: 'qrbox',
visible: false, // will be enabled when generating a qr code
bind: {
visible: '{!secretEmpty}',
},
style: {
'background-color': 'white',
'margin-left': 'auto',
'margin-right': 'auto',
padding: '5px',
width: '266px',
height: '266px',
},
},
{
xtype: 'textfield',
fieldLabel: gettext('Verify Code'),
allowBlank: false,
reference: 'challenge',
name: 'challenge',
bind: {
disabled: '{!showTOTPVerifiction}',
visible: '{showTOTPVerifiction}',
},
emptyText: gettext('Scan QR code in a TOTP app and enter an auth. code here'),
},
{
xtype: 'textfield',
name: 'password',
reference: 'password',
fieldLabel: gettext('Verify Password'),
inputType: 'password',
minLength: 5,
allowBlank: false,
validateBlank: true,
cbind: {
hidden: () => Proxmox.UserName === 'root@pam',
disabled: () => Proxmox.UserName === 'root@pam',
emptyText: () =>
Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
},
},
],
},
],
initComponent: function() {
let me = this;
me.url = '/api2/extjs/access/tfa/';
me.method = 'POST';
me.callParent();
},
getValues: function(dirtyOnly) {
let me = this;
let viewmodel = me.getController().getViewModel();
let values = me.callParent(arguments);
let uid = encodeURIComponent(values.userid);
me.url = `/api2/extjs/access/tfa/${uid}`;
delete values.userid;
let data = {
description: values.description,
type: "totp",
totp: viewmodel.get('otpuri'),
value: values.challenge,
};
if (values.password) {
data.password = values.password;
}
return data;
},
});

View File

@ -1,226 +0,0 @@
Ext.define('PBS.window.AddWebauthn', {
extend: 'Ext.window.Window',
alias: 'widget.pbsAddWebauthn',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'user_mgmt',
modal: true,
resizable: false,
title: gettext('Add a Webauthn login token'),
width: 512,
user: undefined,
fixedUser: false,
initComponent: function() {
let me = this;
me.callParent();
Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp);
},
viewModel: {
data: {
valid: false,
userid: null,
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'field': {
validitychange: function(field, valid) {
let me = this;
let viewmodel = me.getViewModel();
let form = me.lookup('webauthn_form');
viewmodel.set('valid', form.isValid());
},
},
'#': {
show: function() {
let me = this;
let view = me.getView();
if (Proxmox.UserName === 'root@pam') {
view.lookup('password').setVisible(false);
view.lookup('password').setDisabled(true);
}
},
},
},
registerWebauthn: async function() {
let me = this;
let values = me.lookup('webauthn_form').getValues();
values.type = "webauthn";
let userid = values.user;
delete values.user;
me.getView().mask(gettext('Please wait...'), 'x-mask-loading');
try {
let register_response = await Proxmox.Async.api2({
url: `/api2/extjs/access/tfa/${userid}`,
method: 'POST',
params: values,
});
let data = register_response.result.data;
if (!data.challenge) {
throw "server did not respond with a challenge";
}
let creds = JSON.parse(data.challenge);
// Fix this up before passing it to the browser, but keep a copy of the original
// string to pass in the response:
let challenge_str = creds.publicKey.challenge;
creds.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge_str);
creds.publicKey.user.id =
PBS.Utils.base64url_to_bytes(creds.publicKey.user.id);
// convert existing authenticators structure
creds.publicKey.excludeCredentials =
(creds.publicKey.excludeCredentials || [])
.map((credential) => ({
id: PBS.Utils.base64url_to_bytes(credential.id),
type: credential.type,
}));
let msg = Ext.Msg.show({
title: `Webauthn: ${gettext('Setup')}`,
message: gettext('Please press the button on your Webauthn Device'),
buttons: [],
});
let token_response;
try {
token_response = await navigator.credentials.create(creds);
} catch (error) {
let errmsg = error.message;
if (error.name === 'InvalidStateError') {
errmsg = gettext('Is this token already registered?');
}
throw gettext('An error occurred during token registration.') +
`<br>${error.name}: ${errmsg}`;
}
// We cannot pass ArrayBuffers to the API, so extract & convert the data.
let response = {
id: token_response.id,
type: token_response.type,
rawId: PBS.Utils.bytes_to_base64url(token_response.rawId),
response: {
attestationObject: PBS.Utils.bytes_to_base64url(
token_response.response.attestationObject,
),
clientDataJSON: PBS.Utils.bytes_to_base64url(
token_response.response.clientDataJSON,
),
},
};
msg.close();
let params = {
type: "webauthn",
challenge: challenge_str,
value: JSON.stringify(response),
};
if (values.password) {
params.password = values.password;
}
await Proxmox.Async.api2({
url: `/api2/extjs/access/tfa/${userid}`,
method: 'POST',
params,
});
} catch (response) {
let error = response.result.message;
console.error(error); // for debugging if it's not displayable...
Ext.Msg.alert(gettext('Error'), error);
}
me.getView().close();
},
},
items: [
{
xtype: 'form',
reference: 'webauthn_form',
layout: 'anchor',
border: false,
bodyPadding: 10,
fieldDefaults: {
anchor: '100%',
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'user',
cbind: {
editable: (get) => !get('fixedUser'),
value: () => Proxmox.UserName,
},
fieldLabel: gettext('User'),
editConfig: {
xtype: 'pbsUserSelector',
allowBlank: false,
},
renderer: Ext.String.htmlEncode,
listeners: {
change: function(field, newValue, oldValue) {
let vm = this.up('window').getViewModel();
vm.set('userid', newValue);
},
},
},
{
xtype: 'textfield',
fieldLabel: gettext('Description'),
allowBlank: false,
name: 'description',
maxLength: 256,
emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'),
},
{
xtype: 'textfield',
name: 'password',
reference: 'password',
fieldLabel: gettext('Verify Password'),
inputType: 'password',
minLength: 5,
allowBlank: false,
validateBlank: true,
cbind: {
hidden: () => Proxmox.UserName === 'root@pam',
disabled: () => Proxmox.UserName === 'root@pam',
emptyText: () =>
Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName),
},
},
],
},
],
buttons: [
{
xtype: 'proxmoxHelpButton',
},
'->',
{
xtype: 'button',
text: gettext('Register Webauthn Device'),
handler: 'registerWebauthn',
bind: {
disabled: '{!valid}',
},
},
],
});

View File

@ -49,7 +49,7 @@ Ext.define('PBS.window.NotifyOptions', {
},
items: [
{
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
name: 'notify-user',
fieldLabel: gettext('Notify User'),
emptyText: 'root@pam',

View File

@ -1,93 +0,0 @@
Ext.define('PBS.window.TfaEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pbsTfaEdit',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'user_mgmt',
modal: true,
resizable: false,
title: gettext("Modify a TFA entry's description"),
width: 512,
layout: {
type: 'vbox',
align: 'stretch',
},
cbindData: function(initialConfig) {
let me = this;
let tfa_id = initialConfig['tfa-id'];
me.tfa_id = tfa_id;
me.defaultFocus = 'textfield[name=description]';
me.url = `/api2/extjs/access/tfa/${tfa_id}`;
me.method = 'PUT';
me.autoLoad = true;
return {};
},
initComponent: function() {
let me = this;
me.callParent();
if (Proxmox.UserName === 'root@pam') {
me.lookup('password').setVisible(false);
me.lookup('password').setDisabled(true);
}
let userid = me.tfa_id.split('/')[0];
me.lookup('userid').setValue(userid);
},
items: [
{
xtype: 'displayfield',
reference: 'userid',
editable: false,
fieldLabel: gettext('User'),
editConfig: {
xtype: 'pbsUserSelector',
allowBlank: false,
},
cbind: {
value: () => Proxmox.UserName,
},
},
{
xtype: 'proxmoxtextfield',
name: 'description',
allowBlank: false,
fieldLabel: gettext('Description'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enabled'),
name: 'enable',
uncheckedValue: 0,
defaultValue: 1,
checked: true,
},
{
xtype: 'textfield',
inputType: 'password',
fieldLabel: gettext('Password'),
minLength: 5,
reference: 'password',
name: 'password',
allowBlank: false,
validateBlank: true,
emptyText: gettext('verify current password'),
},
],
getValues: function() {
var me = this;
var values = me.callParent(arguments);
delete values.userid;
return values;
},
});

View File

@ -26,7 +26,7 @@ Ext.define('PBS.window.TokenEdit', {
value: () => Proxmox.UserName,
},
editConfig: {
xtype: 'pbsUserSelector',
xtype: 'pmxUserSelector',
allowBlank: false,
},
name: 'user',