[pbs-devel] [RFC backup 6/6] gui: tfa support

Wolfgang Bumiller w.bumiller at proxmox.com
Thu Nov 19 15:56:08 CET 2020


Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
 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: '<i class="fa fa-exclamation-triangle warning"></i>'
+		+ gettext('Only few recovery keys available. Please generate a new set!')
+		+ '<i class="fa fa-exclamation-triangle warning"></i>',
+	    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 at 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 at 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 @@
     <script type="text/javascript">
       Ext.History.fieldid = 'x-history-field';
     </script>
+    <script type="text/javascript" src="/qrcodejs/qrcode.min.js"></script>
     <script type="text/javascript" src="/js/proxmox-backup-gui.js"></script>
   </head>
   <body>
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 at 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 at 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 at 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 at 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






More information about the pbs-devel mailing list