[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