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 D07A37D8A4 for ; Tue, 9 Nov 2021 12:28:23 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8F580B08C for ; Tue, 9 Nov 2021 12:27:38 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (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 87DE0B030 for ; Tue, 9 Nov 2021 12:27:29 +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 5E0E64257F for ; Tue, 9 Nov 2021 12:27:29 +0100 (CET) From: Wolfgang Bumiller To: pve-devel@lists.proxmox.com Date: Tue, 9 Nov 2021 12:27:18 +0100 Message-Id: <20211109112721.130935-30-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20211109112721.130935-1-w.bumiller@proxmox.com> References: <20211109112721.130935-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.459 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes 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. [cred.id] Subject: [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 09 Nov 2021 11:28:24 -0000 copied from pbs and added u2f tab Signed-off-by: Wolfgang Bumiller --- src/Makefile | 1 + src/window/TfaWindow.js | 429 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 src/window/TfaWindow.js diff --git a/src/Makefile b/src/Makefile index fe915dd..afb0cb2 100644 --- a/src/Makefile +++ b/src/Makefile @@ -78,6 +78,7 @@ JSSRC= \ window/FileBrowser.js \ window/AuthEditBase.js \ window/AuthEditOpenId.js \ + window/TfaWindow.js \ node/APT.js \ node/APTRepositories.js \ node/NetworkEdit.js \ diff --git a/src/window/TfaWindow.js b/src/window/TfaWindow.js new file mode 100644 index 0000000..5026fb8 --- /dev/null +++ b/src/window/TfaWindow.js @@ -0,0 +1,429 @@ +/*global u2f*/ +Ext.define('Proxmox.window.TfaLoginWindow', { + 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', 'u2f']) { + 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(); + } else if (challenge.u2f && initialTab === 3) { + let _promise = me.loginU2F(); + } + }, + 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('Proxmox.TFALogin.lastTab', JSON.stringify({ id })); + }, + + getLastTabUsed: function() { + let data = window.localStorage.getItem('Proxmox.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 = Proxmox.Utils.base64url_to_bytes(challenge.string); + for (const cred of challenge.publicKey.allowCredentials) { + cred.id = Proxmox.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: Proxmox.Utils.bytes_to_base64url(hwrsp.rawId), + response: { + authenticatorData: Proxmox.Utils.bytes_to_base64url( + hwrsp.response.authenticatorData, + ), + clientDataJSON: Proxmox.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON), + signature: Proxmox.Utils.bytes_to_base64url(hwrsp.response.signature), + }, + }; + + await me.finishChallenge("webauthn:" + JSON.stringify(response)); + }, + + loginU2F: async function() { + let me = this; + let view = me.getView(); + + me.lookup('u2fWaiting').setVisible(true); + me.lookup('u2fError').setVisible(false); + + let hwrsp; + try { + hwrsp = await new Promise((resolve, reject) => { + try { + let data = view.challenge.u2f; + let chlg = data.challenge; + u2f.sign(chlg.appId, chlg.challenge, data.keys, resolve); + } catch (error) { + reject(error); + } + }); + if (hwrsp.errorCode) { + throw Proxmox.Utils.render_u2f_error(hwrsp.errorCode); + } + delete hwrsp.errorCode; + } catch (error) { + this.getViewModel().set('canConfirm', true); + me.lookup('u2fError').setData({ + error: Ext.htmlEncode(error.toString()), + }); + me.lookup('u2fError').setVisible(true); + return; + } finally { + let waitingMessage = me.lookup('u2fWaiting'); + if (waitingMessage) { + waitingMessage.setVisible(false); + } + } + + await me.finishChallenge("u2f:" + JSON.stringify(hwrsp)); + }, + + 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]{2,16}$/, + regexText: gettext('TOTP codes usually 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!'), + }, + ], + }, + { + xtype: 'panel', + title: 'U2F', + iconCls: 'fa fa-fw fa-shield', + confirmText: gettext('Start U2F challenge'), + handler: 'loginU2F', + bind: { + disabled: '{!availableChallenge.u2f}', + }, + items: [ + { + xtype: 'box', + html: gettext('Please insert your authentication device and press its button'), + }, + { + xtype: 'box', + html: gettext('Waiting for second factor.') +``, + reference: 'u2fWaiting', + hidden: true, + }, + { + xtype: 'box', + data: { + error: '', + }, + tpl: ' {error}', + reference: 'u2fError', + hidden: true, + }, + ], + }, + ], + }], + + buttons: [ + { + handler: 'loginTFA', + reference: 'tfaButton', + disabled: true, + bind: { + text: '{confirmText}', + disabled: '{!canConfirm}', + }, + }, + ], +}); -- 2.30.2