gui: tfa support
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
7f066a9b21
commit
fbeac4ea28
323
www/LoginView.js
323
www/LoginView.js
@ -5,7 +5,7 @@ Ext.define('PBS.LoginView', {
|
|||||||
controller: {
|
controller: {
|
||||||
xclass: 'Ext.app.ViewController',
|
xclass: 'Ext.app.ViewController',
|
||||||
|
|
||||||
submitForm: function() {
|
submitForm: async function() {
|
||||||
var me = this;
|
var me = this;
|
||||||
var loginForm = me.lookupReference('loginForm');
|
var loginForm = me.lookupReference('loginForm');
|
||||||
var unField = me.lookupReference('usernameField');
|
var unField = me.lookupReference('usernameField');
|
||||||
@ -33,24 +33,51 @@ Ext.define('PBS.LoginView', {
|
|||||||
}
|
}
|
||||||
sp.set(saveunField.getStateId(), saveunField.getValue());
|
sp.set(saveunField.getStateId(), saveunField.getValue());
|
||||||
|
|
||||||
Proxmox.Utils.API2Request({
|
try {
|
||||||
url: '/api2/extjs/access/ticket',
|
let resp = await PBS.Async.api2({
|
||||||
params: params,
|
url: '/api2/extjs/access/ticket',
|
||||||
method: 'POST',
|
params: params,
|
||||||
success: function(resp, opts) {
|
method: 'POST',
|
||||||
// save login data and create cookie
|
});
|
||||||
PBS.Utils.updateLoginData(resp.result.data);
|
|
||||||
PBS.app.changeView('mainview');
|
let data = resp.result.data;
|
||||||
},
|
if (data.ticket.startsWith("PBS:!tfa!")) {
|
||||||
failure: function(resp, opts) {
|
data = await me.performTFAChallenge(data);
|
||||||
Proxmox.Utils.authClear();
|
}
|
||||||
loginForm.unmask();
|
|
||||||
Ext.MessageBox.alert(
|
PBS.Utils.updateLoginData(data);
|
||||||
gettext('Error'),
|
PBS.app.changeView('mainview');
|
||||||
gettext('Login failed. Please try again'),
|
} 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: {
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
@ -24,12 +24,16 @@ JSSRC= \
|
|||||||
data/RunningTasksStore.js \
|
data/RunningTasksStore.js \
|
||||||
button/TaskButton.js \
|
button/TaskButton.js \
|
||||||
config/UserView.js \
|
config/UserView.js \
|
||||||
|
config/TfaView.js \
|
||||||
config/TokenView.js \
|
config/TokenView.js \
|
||||||
config/RemoteView.js \
|
config/RemoteView.js \
|
||||||
config/ACLView.js \
|
config/ACLView.js \
|
||||||
config/SyncView.js \
|
config/SyncView.js \
|
||||||
config/VerifyView.js \
|
config/VerifyView.js \
|
||||||
window/ACLEdit.js \
|
window/ACLEdit.js \
|
||||||
|
window/AddTfaRecovery.js \
|
||||||
|
window/AddTotp.js \
|
||||||
|
window/AddWebauthn.js \
|
||||||
window/BackupFileDownloader.js \
|
window/BackupFileDownloader.js \
|
||||||
window/BackupGroupChangeOwner.js \
|
window/BackupGroupChangeOwner.js \
|
||||||
window/CreateDirectory.js \
|
window/CreateDirectory.js \
|
||||||
@ -42,6 +46,7 @@ JSSRC= \
|
|||||||
window/UserEdit.js \
|
window/UserEdit.js \
|
||||||
window/UserPassword.js \
|
window/UserPassword.js \
|
||||||
window/TokenEdit.js \
|
window/TokenEdit.js \
|
||||||
|
window/TfaEdit.js \
|
||||||
window/VerifyJobEdit.js \
|
window/VerifyJobEdit.js \
|
||||||
window/ZFSCreate.js \
|
window/ZFSCreate.js \
|
||||||
dashboard/DataStoreStatistics.js \
|
dashboard/DataStoreStatistics.js \
|
||||||
|
59
www/Utils.js
59
www/Utils.js
@ -297,4 +297,63 @@ Ext.define('PBS.Utils', {
|
|||||||
zfscreate: [gettext('ZFS Storage'), gettext('Create')],
|
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));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
322
www/config/TfaView.js
Normal file
322
www/config/TfaView.js
Normal file
@ -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@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@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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
@ -37,6 +37,7 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
Ext.History.fieldid = 'x-history-field';
|
Ext.History.fieldid = 'x-history-field';
|
||||||
</script>
|
</script>
|
||||||
|
<script type="text/javascript" src="/qrcodejs/qrcode.min.js"></script>
|
||||||
<script type="text/javascript" src="/js/proxmox-backup-gui.js"></script>
|
<script type="text/javascript" src="/js/proxmox-backup-gui.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -19,6 +19,12 @@ Ext.define('PBS.AccessControlPanel', {
|
|||||||
itemId: 'users',
|
itemId: 'users',
|
||||||
iconCls: 'fa fa-user',
|
iconCls: 'fa fa-user',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
xtype: 'pbsTfaView',
|
||||||
|
title: gettext('Two Factor Authentication'),
|
||||||
|
itemId: 'tfa',
|
||||||
|
iconCls: 'fa fa-key',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
xtype: 'pbsTokenView',
|
xtype: 'pbsTokenView',
|
||||||
title: gettext('API Token'),
|
title: gettext('API Token'),
|
||||||
|
211
www/window/AddTfaRecovery.js
Normal file
211
www/window/AddTfaRecovery.js
Normal file
@ -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@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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
283
www/window/AddTotp.js
Normal file
283
www/window/AddTotp.js
Normal file
@ -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@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;
|
||||||
|
},
|
||||||
|
});
|
193
www/window/AddWebauthn.js
Normal file
193
www/window/AddWebauthn.js
Normal file
@ -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@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}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
92
www/window/TfaEdit.js
Normal file
92
www/window/TfaEdit.js
Normal file
@ -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@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;
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user