diff --git a/www/LoginView.js b/www/LoginView.js index 55c5e584..948c83ad 100644 --- a/www/LoginView.js +++ b/www/LoginView.js @@ -241,61 +241,95 @@ 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, - + modal: true, + resizable: false, width: 512, layout: { type: 'vbox', align: 'stretch', }, - defaultButton: 'totpButton', + defaultButton: 'tfaButton', + + viewModel: { + data: { + canConfirm: false, + availabelChallenge: {}, + }, + }, + + 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"; } - - if (!view.challenge) { + const challenge = view.challenge; + if (!challenge) { throw "no challenge given"; } - if (!view.challenge.webauthn) { - me.lookup('webauthnButton').setVisible(false); + let firstAvailableTab = -1, i = 0; + for (const k of ['webauthn', 'totp', 'recovery']) { + const available = !!challenge[k]; + vm.set(`availabelChallenge.${k}`, available); + + if (firstAvailableTab < 0 && available) { + firstAvailableTab = i; + } + i++; + } + view.down('tabpanel').setActiveTab(firstAvailableTab); + + 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 (!view.challenge.totp) { - me.lookup('totpButton').setVisible(false); - } - - if (!view.challenge.recovery || !view.challenge.recovery.length) { - me.lookup('recoveryButton').setVisible(false); - } else if (view.challenge.recovery.length <= 3) { - 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); + if (challenge.webauthn) { 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(); + } + }, + }, + '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); + }, + }, + }, onClose: function() { let me = this; @@ -315,24 +349,15 @@ Ext.define('PBS.login.TfaWindow', { loginTotp: function() { let me = this; - let _promise = me.finishChallenge('totp:' + me.lookup('totp').value); + let code = me.lookup('totp').getValue(); + let _promise = me.finishChallenge(`totp:${code}`); }, 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: [], - }); + me.lookup('webAuthnWaiting').setVisible(true); let challenge = view.challenge.webauthn; @@ -343,14 +368,21 @@ Ext.define('PBS.login.TfaWindow', { 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) { view.onReject(error); return; } finally { - msg.close(); + let waitingMessage = me.lookup('webAuthnWaiting'); + if (waitingMessage) { + waitingMessage.setVisible(false); + } } let response = { @@ -367,32 +399,21 @@ Ext.define('PBS.login.TfaWindow', { }, }; - 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); - console.log("RECOVERY:", view.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); - } - } + let key = me.lookup('recoveryKey').getValue(); + let _promise = me.finishChallenge(`recovery:${key}`); + }, + + loginTFA: function() { + let me = this; + let view = me.getView(); + let tfaPanel = view.down('tabpanel').getActiveTab(); + me[tfaPanel.handler](); }, finishChallenge: function(password) { @@ -424,81 +445,111 @@ Ext.define('PBS.login.TfaWindow', { 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, + items: [{ + xtype: 'tabpanel', + region: 'center', + layout: 'fit', + bodyPadding: 10, + stateId: 'pbs-tfa-login-panel', // FIXME: do manually - get/setState miss + stateful: true, + stateEvents: ['tabchange'], + items: [ + { + xtype: 'panel', + title: 'WebAuthn', + iconCls: 'fa fa-fw fa-shield', + handler: 'loginWebauthn', + bind: { + disabled: '{!availabelChallenge.webauthn}', }, - ], - }, - { - 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", + items: [ + { + xtype: 'box', + html: `` + + gettext('Please insert your authenticator device and press its button'), + }, + { + xtype: 'box', + html: gettext('Waiting for second factor.'), + reference: 'webAuthnWaiting', + hidden: true, + }, + ], }, - }, - { - xtype: 'box', - padding: '0 5', - reference: 'availableRecovery', - hidden: true, - style: { - textAlign: "center", + { + xtype: 'panel', + title: gettext('TOTP App'), + iconCls: 'fa fa-fw fa-clock-o', + handler: 'loginTotp', + bind: { + disabled: '{!availabelChallenge.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: 'TOTP codes consist of six decimal digits', + }, + ], }, - }, - { - xtype: 'box', - padding: '0 5', - reference: 'recoveryLow', - hidden: true, - html: '' - + gettext('Only few recovery keys available. Please generate a new set!') - + '', - style: { - textAlign: "center", + { + xtype: 'panel', + title: gettext('Recovery Key'), + iconCls: 'fa fa-fw fa-file-text-o', + handler: 'loginRecovery', + bind: { + disabled: '{!availabelChallenge.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: 'Does not looks like a valid recovery key', + }, + { + xtype: 'box', + reference: 'recoveryInfo', + hidden: true, // FIXME: remove this? + html: gettext('Note that each recovery code can only be used once!'), + }, + { + xtype: 'box', + reference: 'recoveryLow', + hidden: true, + html: '' + + gettext('Less than {0} recovery keys available. Please generate a new set!'), + }, + ], }, - }, - ], + ], + }], 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', + text: gettext('Confirm Second Factor'), + handler: 'loginTFA', + reference: 'tfaButton', + disabled: true, + bind: { + disabled: '{!canConfirm}', + }, }, ], });