From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id C82556117E for ; Thu, 19 Nov 2020 15:56:53 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BEAD084C9 for ; Thu, 19 Nov 2020 15:56:23 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id CCBDC8464 for ; Thu, 19 Nov 2020 15:56:17 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 9645F43C43 for ; Thu, 19 Nov 2020 15:56:17 +0100 (CET) From: Wolfgang Bumiller To: pbs-devel@lists.proxmox.com Date: Thu, 19 Nov 2020 15:56:08 +0100 Message-Id: <20201119145608.16866-7-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20201119145608.16866-1-w.bumiller@proxmox.com> References: <20201119145608.16866-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.026 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [user.id, entry.id, record.id, cred.id] Subject: [pbs-devel] [RFC backup 6/6] gui: tfa support X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 19 Nov 2020 14:56:53 -0000 Signed-off-by: Wolfgang Bumiller --- www/LoginView.js | 323 +++++++++++++++++++++++++++++++++-- www/Makefile | 6 + www/OnlineHelpInfo.js | 36 ---- www/Utils.js | 59 +++++++ www/config/TfaView.js | 322 ++++++++++++++++++++++++++++++++++ www/index.hbs | 1 + www/panel/AccessControl.js | 6 + www/window/AddTfaRecovery.js | 211 +++++++++++++++++++++++ www/window/AddTotp.js | 283 ++++++++++++++++++++++++++++++ www/window/AddWebauthn.js | 193 +++++++++++++++++++++ www/window/TfaEdit.js | 92 ++++++++++ 11 files changed, 1478 insertions(+), 54 deletions(-) create mode 100644 www/config/TfaView.js create mode 100644 www/window/AddTfaRecovery.js create mode 100644 www/window/AddTotp.js create mode 100644 www/window/AddWebauthn.js create mode 100644 www/window/TfaEdit.js diff --git a/www/LoginView.js b/www/LoginView.js index 1deba415..305f4c0d 100644 --- a/www/LoginView.js +++ b/www/LoginView.js @@ -5,7 +5,7 @@ Ext.define('PBS.LoginView', { controller: { xclass: 'Ext.app.ViewController', - submitForm: function() { + submitForm: async function() { var me = this; var loginForm = me.lookupReference('loginForm'); var unField = me.lookupReference('usernameField'); @@ -33,24 +33,51 @@ Ext.define('PBS.LoginView', { } sp.set(saveunField.getStateId(), saveunField.getValue()); - Proxmox.Utils.API2Request({ - url: '/api2/extjs/access/ticket', - params: params, - method: 'POST', - success: function(resp, opts) { - // save login data and create cookie - PBS.Utils.updateLoginData(resp.result.data); - PBS.app.changeView('mainview'); - }, - failure: function(resp, opts) { - Proxmox.Utils.authClear(); - loginForm.unmask(); - Ext.MessageBox.alert( - gettext('Error'), - gettext('Login failed. Please try again'), - ); - }, + try { + let resp = await PBS.Async.api2({ + url: '/api2/extjs/access/ticket', + params: params, + method: 'POST', + }); + + let data = resp.result.data; + if (data.ticket.startsWith("PBS:!tfa!")) { + data = await me.performTFAChallenge(data); + } + + PBS.Utils.updateLoginData(data); + PBS.app.changeView('mainview'); + } catch (error) { + console.error(error); // for debugging + Proxmox.Utils.authClear(); + loginForm.unmask(); + Ext.MessageBox.alert( + gettext('Error'), + gettext('Login failed. Please try again'), + ); + } + }, + + performTFAChallenge: async function(data) { + let me = this; + + let userid = data.username; + let ticket = data.ticket; + let challenge = JSON.parse(decodeURIComponent( + ticket.split(':')[1].slice("!tfa!".length), + )); + + let resp = await new Promise((resolve, reject) => { + Ext.create('PBS.login.TfaWindow', { + userid, + ticket, + challenge, + onResolve: value => resolve(value), + onReject: reject, + }).show(); }); + + return resp.result.data; }, control: { @@ -209,3 +236,263 @@ Ext.define('PBS.LoginView', { }, ], }); + +Ext.define('PBS.login.TfaWindow', { + extend: 'Ext.window.Window', + mixins: ['Proxmox.Mixin.CBind'], + + modal: true, + resizable: false, + title: gettext("Second login factor required"), + + cancelled: true, + + width: 512, + layout: { + type: 'vbox', + align: 'stretch', + }, + + defaultButton: 'totpButton', + + viewModel: { + data: { + userid: undefined, + ticket: undefined, + challenge: undefined, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function(view) { + let me = this; + + if (!view.userid) { + throw "no userid given"; + } + + if (!view.ticket) { + throw "no ticket given"; + } + + if (!view.challenge) { + throw "no challenge given"; + } + + if (!view.challenge.webauthn) { + me.lookup('webauthnButton').setVisible(false); + } + + if (!view.challenge.totp) { + me.lookup('totpButton').setVisible(false); + } + + if (!view.challenge.recovery) { + me.lookup('recoveryButton').setVisible(false); + } else if (view.challenge.recovery === "low") { + me.lookup('recoveryButton') + .setIconCls('fa fa-fw fa-exclamation-triangle'); + } + + + if (!view.challenge.totp && !view.challenge.recovery) { + // only webauthn tokens available, maybe skip ahead? + me.lookup('totp').setVisible(false); + me.lookup('waiting').setVisible(true); + let _promise = me.loginWebauthn(); + } + }, + + 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 _promise = me.finishChallenge('totp:' + me.lookup('totp').value); + }, + + loginWebauthn: async function() { + let me = this; + let view = me.getView(); + + // avoid this window ending up above the tfa popup if we got triggered from init(). + await PBS.Async.sleep(100); + + // FIXME: With webauthn the browser provides a popup (since it doesn't necessarily need + // to require pressing a button, but eg. use a fingerprint scanner or face detection + // etc., so should we just trust that that happens and skip the popup?) + let msg = Ext.Msg.show({ + title: `Webauthn: ${gettext('Login')}`, + message: gettext('Please press the button on your Authenticator Device'), + buttons: [], + }); + + let challenge = view.challenge.webauthn; + + // Byte array fixup, keep challenge string: + let challenge_str = challenge.publicKey.challenge; + challenge.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge_str); + for (const cred of challenge.publicKey.allowCredentials) { + cred.id = PBS.Utils.base64url_to_bytes(cred.id); + } + + let hwrsp; + try { + hwrsp = await navigator.credentials.get(challenge); + } catch (error) { + view.onReject(error); + return; + } finally { + msg.close(); + } + + let response = { + id: hwrsp.id, + type: hwrsp.type, + challenge: challenge_str, + 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), + }, + }; + + msg.close(); + + await me.finishChallenge("webauthn:" + JSON.stringify(response)); + }, + + loginRecovery: function() { + let me = this; + let view = me.getView(); + + if (me.login_recovery_confirm) { + let _promise = me.finishChallenge('recovery:' + me.lookup('totp').value); + } else { + me.login_recovery_confirm = true; + me.lookup('totpButton').setVisible(false); + me.lookup('webauthnButton').setVisible(false); + me.lookup('recoveryButton').setText(gettext("Confirm")); + me.lookup('recoveryInfo').setVisible(true); + if (view.challenge.recovery === "low") { + me.lookup('recoveryLow').setVisible(true); + } + } + }, + + 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 PBS.Async.api2({ + url: '/api2/extjs/access/ticket', + method: 'POST', + params, + }) + .then(resolve) + .catch(reject); + }, + }, + + listeners: { + close: 'onClose', + }, + + items: [ + { + xtype: 'form', + layout: 'anchor', + border: false, + fieldDefaults: { + anchor: '100%', + padding: '0 5', + }, + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Please enter your OTP verification code:'), + labelWidth: '300px', + name: 'totp', + reference: 'totp', + allowBlank: false, + }, + ], + }, + { + xtype: 'box', + html: gettext('Waiting for second factor.'), + reference: 'waiting', + padding: '0 5', + hidden: true, + }, + { + xtype: 'box', + padding: '0 5', + reference: 'recoveryInfo', + hidden: true, + html: gettext('Please note that each recovery code can only be used once!'), + style: { + textAlign: "center", + }, + }, + { + xtype: 'box', + padding: '0 5', + reference: 'recoveryLow', + hidden: true, + html: '' + + gettext('Only few recovery keys available. Please generate a new set!') + + '', + style: { + textAlign: "center", + }, + }, + ], + + buttons: [ + { + text: gettext('Login with TOTP'), + handler: 'loginTotp', + reference: 'totpButton', + }, + { + text: gettext('Login with a recovery key'), + handler: 'loginRecovery', + reference: 'recoveryButton', + }, + { + text: gettext('Use a Webauthn token'), + handler: 'loginWebauthn', + reference: 'webauthnButton', + }, + ], +}); diff --git a/www/Makefile b/www/Makefile index 1df2195a..86e0516e 100644 --- a/www/Makefile +++ b/www/Makefile @@ -16,12 +16,16 @@ JSSRC= \ data/RunningTasksStore.js \ button/TaskButton.js \ config/UserView.js \ + config/TfaView.js \ config/TokenView.js \ config/RemoteView.js \ config/ACLView.js \ config/SyncView.js \ config/VerifyView.js \ window/ACLEdit.js \ + window/AddTfaRecovery.js \ + window/AddTotp.js \ + window/AddWebauthn.js \ window/BackupFileDownloader.js \ window/BackupGroupChangeOwner.js \ window/CreateDirectory.js \ @@ -34,6 +38,7 @@ JSSRC= \ window/UserEdit.js \ window/UserPassword.js \ window/TokenEdit.js \ + window/TfaEdit.js \ window/VerifyJobEdit.js \ window/ZFSCreate.js \ dashboard/DataStoreStatistics.js \ @@ -100,6 +105,7 @@ install: js/proxmox-backup-gui.js css/ext6-pbs.css index.hbs install -m644 index.hbs $(DESTDIR)$(JSDIR)/ install -dm755 $(DESTDIR)$(JSDIR)/js install -m644 js/proxmox-backup-gui.js $(DESTDIR)$(JSDIR)/js/ + install -m 0644 qrcode.min.js $(DESTDIR)$(JSDIR)/ install -dm755 $(DESTDIR)$(JSDIR)/css install -m644 css/ext6-pbs.css $(DESTDIR)$(JSDIR)/css/ install -dm755 $(DESTDIR)$(JSDIR)/images diff --git a/www/OnlineHelpInfo.js b/www/OnlineHelpInfo.js index aee73bf6..c54912d8 100644 --- a/www/OnlineHelpInfo.js +++ b/www/OnlineHelpInfo.js @@ -75,42 +75,6 @@ const proxmoxOnlineHelpInfo = { "link": "/docs/pve-integration.html#pve-integration", "title": "`Proxmox VE`_ Integration" }, - "rst-primer": { - "link": "/docs/reStructuredText-primer.html#rst-primer", - "title": "reStructuredText Primer" - }, - "rst-inline-markup": { - "link": "/docs/reStructuredText-primer.html#rst-inline-markup", - "title": "Inline markup" - }, - "rst-literal-blocks": { - "link": "/docs/reStructuredText-primer.html#rst-literal-blocks", - "title": "Literal blocks" - }, - "rst-doctest-blocks": { - "link": "/docs/reStructuredText-primer.html#rst-doctest-blocks", - "title": "Doctest blocks" - }, - "rst-tables": { - "link": "/docs/reStructuredText-primer.html#rst-tables", - "title": "Tables" - }, - "rst-field-lists": { - "link": "/docs/reStructuredText-primer.html#rst-field-lists", - "title": "Field Lists" - }, - "rst-roles-alt": { - "link": "/docs/reStructuredText-primer.html#rst-roles-alt", - "title": "Roles" - }, - "rst-directives": { - "link": "/docs/reStructuredText-primer.html#rst-directives", - "title": "Directives" - }, - "html-meta": { - "link": "/docs/reStructuredText-primer.html#html-meta", - "title": "HTML Metadata" - }, "storage-disk-management": { "link": "/docs/storage.html#storage-disk-management", "title": "Disk Management" diff --git a/www/Utils.js b/www/Utils.js index ab48bdcf..1d2810bf 100644 --- a/www/Utils.js +++ b/www/Utils.js @@ -284,4 +284,63 @@ Ext.define('PBS.Utils', { zfscreate: [gettext('ZFS Storage'), gettext('Create')], }); }, + + // Convert an ArrayBuffer to a base64url encoded string. + // A `null` value will be preserved for convenience. + bytes_to_base64url: function(bytes) { + if (bytes === null) { + return null; + } + + return btoa(Array + .from(new Uint8Array(bytes)) + .map(val => String.fromCharCode(val)) + .join(''), + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/[=]/g, ''); + }, + + // Convert an a base64url string to an ArrayBuffer. + // A `null` value will be preserved for convenience. + base64url_to_bytes: function(b64u) { + if (b64u === null) { + return null; + } + + return new Uint8Array( + atob(b64u + .replace(/-/g, '+') + .replace(/_/g, '/'), + ) + .split('') + .map(val => val.charCodeAt(0)), + ); + }, +}); + +Ext.define('PBS.Async', { + singleton: true, + + // Returns a Promise resolving to the result of an `API2Request`. + api2: function(reqOpts) { + return new Promise((resolve, reject) => { + delete reqOpts.callback; // not allowed in this api + reqOpts.success = response => resolve(response); + reqOpts.failure = response => { + if (response.result && response.result.message) { + reject(response.result.message); + } else { + reject("api call failed"); + } + }; + Proxmox.Utils.API2Request(reqOpts); + }); + }, + + // Delay for a number of milliseconds. + sleep: function(millis) { + return new Promise((resolve, _reject) => setTimeout(resolve, millis)); + }, }); diff --git a/www/config/TfaView.js b/www/config/TfaView.js new file mode 100644 index 00000000..350c98a7 --- /dev/null +++ b/www/config/TfaView.js @@ -0,0 +1,322 @@ +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', 'type', 'description', '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}`, + type: entry.type, + description: entry.description, + 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', { + message: Ext.String.format( + gettext('Are you sure you want to remove entry {0}'), + record.data.description, + ), + callback: password => me.removeItem(password, record), + }) + .show(); + }, + + removeItem: async function(password, record) { + let me = this; + + let params = {}; + if (password !== null) { + params.password = password; + } + + try { + await PBS.Async.api2({ + url: `/api2/extjs/access/tfa/${record.id}`, + method: 'DELETE', + params, + }); + me.reload(); + } catch (error) { + Ext.Msg.alert(gettext('Error'), error); + } + }, + }, + + 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('Description'), + width: 300, + sortable: true, + dataIndex: 'description', + }, + ], + + 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', + text: gettext('Remove'), + getRecordName: rec => rec.data.description, + handler: 'onRemoveButton', + }, + ], +}); + +Ext.define('PBS.tfa.confirmRemove', { + extend: 'Proxmox.window.Edit', + + modal: true, + resizable: false, + title: gettext("Confirm Password"), + width: 512, + isCreate: true, // logic + isRemove: true, + + url: '/access/tfa', + + initComponent: function() { + let me = this; + + if (!me.message) { + throw "missing message"; + } + + if (!me.callback) { + throw "missing callback"; + } + + me.callParent(); + + if (Proxmox.UserName === 'root@pam') { + me.lookup('password').setVisible(false); + me.lookup('password').setDisabled(true); + } + + me.lookup('message').setHtml(Ext.String.htmlEncode(me.message)); + }, + + 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: '5 5', + reference: 'message', + html: gettext(''), + style: { + textAlign: "center", + }, + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + name: 'password', + allowBlank: false, + validateBlank: true, + padding: '0 0 5 5', + emptyText: gettext('verify current password'), + }, + ], +}); diff --git a/www/index.hbs b/www/index.hbs index 008e2410..665bef23 100644 --- a/www/index.hbs +++ b/www/index.hbs @@ -37,6 +37,7 @@ + diff --git a/www/panel/AccessControl.js b/www/panel/AccessControl.js index dfb63b60..94690cfe 100644 --- a/www/panel/AccessControl.js +++ b/www/panel/AccessControl.js @@ -19,6 +19,12 @@ Ext.define('PBS.AccessControlPanel', { itemId: 'users', iconCls: 'fa fa-user', }, + { + xtype: 'pbsTfaView', + title: gettext('Two Factor Authentication'), + itemId: 'tfa', + iconCls: 'fa fa-key', + }, { xtype: 'pbsTokenView', title: gettext('API Token'), diff --git a/www/window/AddTfaRecovery.js b/www/window/AddTfaRecovery.js new file mode 100644 index 00000000..df94884b --- /dev/null +++ b/www/window/AddTfaRecovery.js @@ -0,0 +1,211 @@ +Ext.define('PBS.window.AddTfaRecovery', { + extend: 'Ext.window.Window', + alias: 'widget.pbsAddTfaRecovery', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'user_mgmt', + + modal: true, + resizable: false, + title: gettext('Add TFA recovery keys'), + width: 512, + + fixedUser: false, + + baseurl: '/api2/extjs/access/tfa', + + initComponent: function() { + let me = this; + me.callParent(); + Ext.GlobalEvents.fireEvent('proxmoxShowHelp', me.onlineHelp); + }, + + viewModel: { + data: { + has_entry: false, + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + '#': { + show: function() { + let me = this; + let view = me.getView(); + + if (Proxmox.UserName === 'root@pam') { + view.lookup('password').setVisible(false); + view.lookup('password').setDisabled(true); + } + }, + }, + }, + + hasEntry: async function(userid) { + let me = this; + let view = me.getView(); + + try { + await PBS.Async.api2({ + url: `${view.baseurl}/${userid}/recovery`, + method: 'GET', + }); + return true; + } catch (_ex) { + return false; + } + }, + + init: function() { + this.onUseridChange(null, Proxmox.UserName); + }, + + onUseridChange: async function(_field, userid) { + let me = this; + + me.userid = userid; + + let has_entry = await me.hasEntry(userid); + me.getViewModel().set('has_entry', has_entry); + }, + + onAdd: async function() { + let me = this; + let view = me.getView(); + + let baseurl = view.baseurl; + + let userid = me.userid; + if (userid === undefined) { + throw "no userid set"; + } + + me.getView().close(); + + try { + let response = await PBS.Async.api2({ + url: `${baseurl}/${userid}`, + method: 'POST', + params: { type: 'recovery' }, + }); + let values = response.result.data.recovery.join("\n"); + Ext.create('PBS.window.TfaRecoveryShow', { + autoShow: true, + values, + }); + } catch (ex) { + Ext.Msg.alert(gettext('Error'), ex); + } + }, + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'userid', + cbind: { + editable: (get) => !get('fixedUser'), + }, + fieldLabel: gettext('User'), + editConfig: { + xtype: 'pbsUserSelector', + allowBlank: false, + }, + renderer: Ext.String.htmlEncode, + value: Proxmox.UserName, + listeners: { + change: 'onUseridChange', + }, + }, + { + xtype: 'displayfield', + bind: { + hidden: '{!has_entry}', + }, + value: gettext('User already has recovery keys.'), + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + name: 'password', + allowBlank: false, + validateBlank: true, + padding: '0 0 5 5', + emptyText: gettext('verify current password'), + }, + ], + + buttons: [ + { + xtype: 'proxmoxHelpButton', + }, + '->', + { + xtype: 'button', + text: gettext('Add'), + handler: 'onAdd', + bind: { + disabled: '{has_entry}', + }, + }, + ], +}); + +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'), + + items: [ + { + xtype: 'container', + layout: 'form', + bodyPadding: 10, + border: false, + fieldDefaults: { + labelWidth: 100, + anchor: '100%', + }, + padding: '0 10 10 10', + items: [ + { + xtype: 'textarea', + editable: false, + inputId: 'token-secret-value', + cbind: { + value: '{values}', + }, + fieldStyle: { + 'fontFamily': 'monospace', + }, + height: '160px', + }, + ], + }, + { + xtype: 'component', + border: false, + padding: '10 10 10 10', + userCls: 'pmx-hint', + html: gettext('Please record recovery keys - they will only be displayed now'), + }, + ], + buttons: [ + { + handler: function(b) { + document.getElementById('token-secret-value').select(); + document.execCommand("copy"); + }, + text: gettext('Copy Secret Value'), + }, + ], +}); diff --git a/www/window/AddTotp.js b/www/window/AddTotp.js new file mode 100644 index 00000000..40417340 --- /dev/null +++ b/www/window/AddTotp.js @@ -0,0 +1,283 @@ +/*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.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: '', + }, + + 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(); + + if (Proxmox.UserName === 'root@pam') { + view.lookup('password').setVisible(false); + view.lookup('password').setDisabled(true); + } + }, + }, + }, + + 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%', + padding: '0 5', + }, + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'userid', + cbind: { + editable: (get) => get('isAdd') && !get('fixedUser'), + }, + fieldLabel: gettext('User'), + editConfig: { + xtype: 'pbsUserSelector', + allowBlank: false, + }, + renderer: Ext.String.htmlEncode, + value: Proxmox.UserName, + qrupdate: true, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Description'), + 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, + }, + { + 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('Verification Code'), + allowBlank: false, + reference: 'challenge', + name: 'challenge', + bind: { + disabled: '{!showTOTPVerifiction}', + visible: '{showTOTPVerifiction}', + }, + padding: '0 5', + emptyText: gettext('Scan QR code and enter TOTP auth. code to verify'), + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + name: 'password', + allowBlank: false, + validateBlank: true, + padding: '0 0 5 5', + emptyText: gettext('verify current password'), + }, + ], + + 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 new file mode 100644 index 00000000..2c64dd0c --- /dev/null +++ b/www/window/AddWebauthn.js @@ -0,0 +1,193 @@ +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, + }, + }, + + 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; + + try { + let register_response = await PBS.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 challenge_obj = 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 = challenge_obj.publicKey.challenge; + challenge_obj.publicKey.challenge = PBS.Utils.base64url_to_bytes(challenge_str); + challenge_obj.publicKey.user.id = + PBS.Utils.base64url_to_bytes(challenge_obj.publicKey.user.id); + + let msg = Ext.Msg.show({ + title: `Webauthn: ${gettext('Setup')}`, + message: gettext('Please press the button on your Webauthn Device'), + buttons: [], + }); + + let token_response = await navigator.credentials.create(challenge_obj); + + // 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 PBS.Async.api2({ + url: `/api2/extjs/access/tfa/${userid}`, + method: 'POST', + params, + }); + } catch (error) { + 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', + bodyPadding: 10, + fieldDefaults: { + anchor: '100%', + }, + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'user', + cbind: { + editable: (get) => !get('fixedUser'), + }, + fieldLabel: gettext('User'), + editConfig: { + xtype: 'pbsUserSelector', + allowBlank: false, + }, + renderer: Ext.String.htmlEncode, + value: Proxmox.UserName, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Description'), + allowBlank: false, + name: 'description', + maxLength: 256, + emptyText: gettext('a short distinguishing description'), + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + name: 'password', + allowBlank: false, + validateBlank: true, + padding: '0 0 5 5', + emptyText: gettext('verify current password'), + }, + ], + }, + ], + + buttons: [ + { + xtype: 'proxmoxHelpButton', + }, + '->', + { + xtype: 'button', + text: gettext('Register Webauthn Device'), + handler: 'registerWebauthn', + bind: { + disabled: '{!valid}', + }, + }, + ], +}); diff --git a/www/window/TfaEdit.js b/www/window/TfaEdit.js new file mode 100644 index 00000000..182da33b --- /dev/null +++ b/www/window/TfaEdit.js @@ -0,0 +1,92 @@ +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, + }, + 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, + padding: '0 0 5 5', + emptyText: gettext('verify current password'), + }, + ], + + getValues: function() { + var me = this; + + var values = me.callParent(arguments); + + delete values.userid; + + return values; + }, +}); -- 2.20.1