[pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow

Wolfgang Bumiller w.bumiller at proxmox.com
Tue Nov 9 12:27:18 CET 2021


copied from pbs and added u2f tab

Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
 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.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'webAuthnWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {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: '<i class="fa fa-exclamation-triangle warning"></i>'
+			    + 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.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
+			reference: 'u2fWaiting',
+			hidden: true,
+		    },
+		    {
+			xtype: 'box',
+			data: {
+			    error: '',
+			},
+			tpl: '<i class="fa fa-warning warning"></i> {error}',
+			reference: 'u2fError',
+			hidden: true,
+		    },
+		],
+	    },
+	],
+    }],
+
+    buttons: [
+	{
+	    handler: 'loginTFA',
+	    reference: 'tfaButton',
+	    disabled: true,
+	    bind: {
+		text: '{confirmText}',
+		disabled: '{!canConfirm}',
+	    },
+	},
+    ],
+});
-- 
2.30.2





More information about the pve-devel mailing list