diff --git a/www/LoginView.js b/www/LoginView.js index 164e4b4f..a9538ded 100644 --- a/www/LoginView.js +++ b/www/LoginView.js @@ -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.') +``, - reference: 'webAuthnWaiting', - hidden: true, - }, - { - xtype: 'box', - data: { - error: '', - }, - tpl: ' {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: '' - + 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}', - }, - }, - ], -}); diff --git a/www/Makefile b/www/Makefile index 4aec6e2c..32a6d7d5 100644 --- a/www/Makefile +++ b/www/Makefile @@ -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 \ diff --git a/www/OnlineHelpInfo.js b/www/OnlineHelpInfo.js index 4611ee4f..216d11f4 100644 --- a/www/OnlineHelpInfo.js +++ b/www/OnlineHelpInfo.js @@ -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", diff --git a/www/config/TfaView.js b/www/config/TfaView.js deleted file mode 100644 index b2480a7a..00000000 --- a/www/config/TfaView.js +++ /dev/null @@ -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), - }, - }, - ], -}); diff --git a/www/form/UserSelector.js b/www/form/UserSelector.js deleted file mode 100644 index b1beb4b3..00000000 --- a/www/form/UserSelector.js +++ /dev/null @@ -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, - }, - ], - }, -}); diff --git a/www/panel/AccessControl.js b/www/panel/AccessControl.js index 2e65cc2e..d10d0891 100644 --- a/www/panel/AccessControl.js +++ b/www/panel/AccessControl.js @@ -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', diff --git a/www/tape/window/TapeBackup.js b/www/tape/window/TapeBackup.js index 2c22cc64..8a48897f 100644 --- a/www/tape/window/TapeBackup.js +++ b/www/tape/window/TapeBackup.js @@ -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', diff --git a/www/tape/window/TapeBackupJob.js b/www/tape/window/TapeBackupJob.js index c5541d87..c2cc60e4 100644 --- a/www/tape/window/TapeBackupJob.js +++ b/www/tape/window/TapeBackupJob.js @@ -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', diff --git a/www/tape/window/TapeRestore.js b/www/tape/window/TapeRestore.js index d4bf43bb..7a5d3cbf 100644 --- a/www/tape/window/TapeRestore.js +++ b/www/tape/window/TapeRestore.js @@ -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'), diff --git a/www/window/ACLEdit.js b/www/window/ACLEdit.js index b4a352c4..b3de3e8e 100644 --- a/www/window/ACLEdit.js +++ b/www/window/ACLEdit.js @@ -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, diff --git a/www/window/AddTfaRecovery.js b/www/window/AddTfaRecovery.js deleted file mode 100644 index c98deddf..00000000 --- a/www/window/AddTfaRecovery.js +++ /dev/null @@ -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 = `
--${keyString} -
- `; - - printFrame.src = "data:text/html;base64," + btoa(html); - document.body.appendChild(printFrame); - }, -}); diff --git a/www/window/AddTotp.js b/www/window/AddTotp.js deleted file mode 100644 index 3e834c34..00000000 --- a/www/window/AddTotp.js +++ /dev/null @@ -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; - }, -}); diff --git a/www/window/AddWebauthn.js b/www/window/AddWebauthn.js deleted file mode 100644 index 6117ebcc..00000000 --- a/www/window/AddWebauthn.js +++ /dev/null @@ -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.') + - `