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 DB35A7D85A 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 BA361B091 for ; Tue, 9 Nov 2021 12:27:42 +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 ACD9CB05A for ; Tue, 9 Nov 2021 12:27:30 +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 7C9994257F for ; Tue, 9 Nov 2021 12:27:30 +0100 (CET) From: Wolfgang Bumiller To: pve-devel@lists.proxmox.com Date: Tue, 9 Nov 2021 12:27:19 +0100 Message-Id: <20211109112721.130935-31-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.498 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 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. [error.name, user.id] Subject: [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows 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 plain copy from pbs with s/pbs/pmx/ and s/PBS/Proxmox/ Signed-off-by: Wolfgang Bumiller --- src/Makefile | 4 + src/window/AddTfaRecovery.js | 224 ++++++++++++++++++++++++++ src/window/AddTotp.js | 297 +++++++++++++++++++++++++++++++++++ src/window/AddWebauthn.js | 226 ++++++++++++++++++++++++++ src/window/TfaEdit.js | 93 +++++++++++ 5 files changed, 844 insertions(+) create mode 100644 src/window/AddTfaRecovery.js create mode 100644 src/window/AddTotp.js create mode 100644 src/window/AddWebauthn.js create mode 100644 src/window/TfaEdit.js diff --git a/src/Makefile b/src/Makefile index afb0cb2..ad7a3c2 100644 --- a/src/Makefile +++ b/src/Makefile @@ -79,6 +79,10 @@ JSSRC= \ window/AuthEditBase.js \ window/AuthEditOpenId.js \ window/TfaWindow.js \ + window/AddTfaRecovery.js \ + window/AddTotp.js \ + window/AddWebauthn.js \ + window/TfaEdit.js \ node/APT.js \ node/APTRepositories.js \ node/NetworkEdit.js \ diff --git a/src/window/AddTfaRecovery.js b/src/window/AddTfaRecovery.js new file mode 100644 index 0000000..174d553 --- /dev/null +++ b/src/window/AddTfaRecovery.js @@ -0,0 +1,224 @@ +Ext.define('Proxmox.window.AddTfaRecovery', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pmxAddTfaRecovery', + 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('Proxmox.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: 'pmxUserSelector', + 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('Proxmox.window.TfaRecoveryShow', { + extend: 'Ext.window.Window', + alias: ['widget.pmxTfaRecoveryShow'], + 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 = ` +

Recovery Keys for '${userid}' - ${title} (${host})

+

+${keyString} +

+ `; + + printFrame.src = "data:text/html;base64," + btoa(html); + document.body.appendChild(printFrame); + }, +}); diff --git a/src/window/AddTotp.js b/src/window/AddTotp.js new file mode 100644 index 0000000..3e0f5b5 --- /dev/null +++ b/src/window/AddTotp.js @@ -0,0 +1,297 @@ +/*global QRCode*/ +Ext.define('Proxmox.window.AddTotp', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pmxAddTotp', + 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, + issuerName: 'Proxmox', + 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: 'pmxUserSelector', + 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', + cbind: { + value: '{issuerName}', + }, + 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/src/window/AddWebauthn.js b/src/window/AddWebauthn.js new file mode 100644 index 0000000..f4a0b10 --- /dev/null +++ b/src/window/AddWebauthn.js @@ -0,0 +1,226 @@ +Ext.define('Proxmox.window.AddWebauthn', { + extend: 'Ext.window.Window', + alias: 'widget.pmxAddWebauthn', + 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 = Proxmox.Utils.base64url_to_bytes(challenge_str); + creds.publicKey.user.id = + Proxmox.Utils.base64url_to_bytes(creds.publicKey.user.id); + + // convert existing authenticators structure + creds.publicKey.excludeCredentials = + (creds.publicKey.excludeCredentials || []) + .map((credential) => ({ + id: Proxmox.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.') + + `
${error.name}: ${errmsg}`; + } + + // We cannot pass ArrayBuffers to the API, so extract & convert the data. + let response = { + id: token_response.id, + type: token_response.type, + rawId: Proxmox.Utils.bytes_to_base64url(token_response.rawId), + response: { + attestationObject: Proxmox.Utils.bytes_to_base64url( + token_response.response.attestationObject, + ), + clientDataJSON: Proxmox.Utils.bytes_to_base64url( + token_response.response.clientDataJSON, + ), + }, + }; + + msg.close(); + + let params = { + type: "webauthn", + challenge: challenge_str, + value: JSON.stringify(response), + }; + + if (values.password) { + params.password = values.password; + } + + await Proxmox.Async.api2({ + url: `/api2/extjs/access/tfa/${userid}`, + method: 'POST', + params, + }); + } catch (response) { + let error = response.result.message; + console.error(error); // for debugging if it's not displayable... + Ext.Msg.alert(gettext('Error'), error); + } + + me.getView().close(); + }, + }, + + items: [ + { + xtype: 'form', + reference: 'webauthn_form', + layout: 'anchor', + border: false, + bodyPadding: 10, + fieldDefaults: { + anchor: '100%', + }, + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'user', + cbind: { + editable: (get) => !get('fixedUser'), + value: () => Proxmox.UserName, + }, + fieldLabel: gettext('User'), + editConfig: { + xtype: 'pmxUserSelector', + allowBlank: false, + }, + renderer: Ext.String.htmlEncode, + listeners: { + change: function(field, newValue, oldValue) { + let vm = this.up('window').getViewModel(); + vm.set('userid', newValue); + }, + }, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Description'), + allowBlank: false, + name: 'description', + maxLength: 256, + emptyText: gettext('For example: TFA device ID, required to identify multiple factors.'), + }, + { + xtype: 'textfield', + name: 'password', + reference: 'password', + fieldLabel: gettext('Verify Password'), + inputType: 'password', + minLength: 5, + allowBlank: false, + validateBlank: true, + cbind: { + hidden: () => Proxmox.UserName === 'root@pam', + disabled: () => Proxmox.UserName === 'root@pam', + emptyText: () => + Ext.String.format(gettext("Confirm your ({0}) password"), Proxmox.UserName), + }, + }, + ], + }, + ], + + buttons: [ + { + xtype: 'proxmoxHelpButton', + }, + '->', + { + xtype: 'button', + text: gettext('Register Webauthn Device'), + handler: 'registerWebauthn', + bind: { + disabled: '{!valid}', + }, + }, + ], +}); diff --git a/src/window/TfaEdit.js b/src/window/TfaEdit.js new file mode 100644 index 0000000..710f2b9 --- /dev/null +++ b/src/window/TfaEdit.js @@ -0,0 +1,93 @@ +Ext.define('Proxmox.window.TfaEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pmxTfaEdit', + 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: 'pmxUserSelector', + allowBlank: false, + }, + cbind: { + value: () => Proxmox.UserName, + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'description', + allowBlank: false, + fieldLabel: gettext('Description'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enabled'), + name: 'enable', + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + name: 'password', + allowBlank: false, + validateBlank: true, + emptyText: gettext('verify current password'), + }, + ], + + getValues: function() { + var me = this; + + var values = me.callParent(arguments); + + delete values.userid; + + return values; + }, +}); -- 2.30.2