mirror of
https://git.proxmox.com/git/proxmox-widget-toolkit
synced 2025-05-26 03:52:40 +00:00
add Proxmox.window.TfaLoginWindow
copied from pbs and added u2f tab Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
c7f2b15ac9
commit
9e1f1ef616
@ -78,6 +78,7 @@ JSSRC= \
|
|||||||
window/FileBrowser.js \
|
window/FileBrowser.js \
|
||||||
window/AuthEditBase.js \
|
window/AuthEditBase.js \
|
||||||
window/AuthEditOpenId.js \
|
window/AuthEditOpenId.js \
|
||||||
|
window/TfaWindow.js \
|
||||||
node/APT.js \
|
node/APT.js \
|
||||||
node/APTRepositories.js \
|
node/APTRepositories.js \
|
||||||
node/NetworkEdit.js \
|
node/NetworkEdit.js \
|
||||||
|
429
src/window/TfaWindow.js
Normal file
429
src/window/TfaWindow.js
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
/*global u2f*/
|
||||||
|
Ext.define('Proxmox.window.TfaLoginWindow', {
|
||||||
|
extend: 'Ext.window.Window',
|
||||||
|
mixins: ['Proxmox.Mixin.CBind'],
|
||||||
|
|
||||||
|
title: gettext("Second login factor required"),
|
||||||
|
|
||||||
|
modal: true,
|
||||||
|
resizable: false,
|
||||||
|
width: 512,
|
||||||
|
layout: {
|
||||||
|
type: 'vbox',
|
||||||
|
align: 'stretch',
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultButton: 'tfaButton',
|
||||||
|
|
||||||
|
viewModel: {
|
||||||
|
data: {
|
||||||
|
confirmText: gettext('Confirm Second Factor'),
|
||||||
|
canConfirm: false,
|
||||||
|
availableChallenge: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelled: true,
|
||||||
|
|
||||||
|
controller: {
|
||||||
|
xclass: 'Ext.app.ViewController',
|
||||||
|
|
||||||
|
init: function(view) {
|
||||||
|
let me = this;
|
||||||
|
let vm = me.getViewModel();
|
||||||
|
|
||||||
|
if (!view.userid) {
|
||||||
|
throw "no userid given";
|
||||||
|
}
|
||||||
|
if (!view.ticket) {
|
||||||
|
throw "no ticket given";
|
||||||
|
}
|
||||||
|
const challenge = view.challenge;
|
||||||
|
if (!challenge) {
|
||||||
|
throw "no challenge given";
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastTabId = me.getLastTabUsed();
|
||||||
|
let initialTab = -1, i = 0;
|
||||||
|
for (const k of ['webauthn', 'totp', 'recovery', 'u2f']) {
|
||||||
|
const available = !!challenge[k];
|
||||||
|
vm.set(`availableChallenge.${k}`, available);
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
if (i === lastTabId) {
|
||||||
|
initialTab = i;
|
||||||
|
} else if (initialTab < 0) {
|
||||||
|
initialTab = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
view.down('tabpanel').setActiveTab(initialTab);
|
||||||
|
|
||||||
|
if (challenge.recovery) {
|
||||||
|
me.lookup('availableRecovery').update(Ext.String.htmlEncode(
|
||||||
|
gettext('Available recovery keys: ') + view.challenge.recovery.join(', '),
|
||||||
|
));
|
||||||
|
me.lookup('availableRecovery').setVisible(true);
|
||||||
|
if (view.challenge.recovery.length <= 3) {
|
||||||
|
me.lookup('recoveryLow').setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.webauthn && initialTab === 0) {
|
||||||
|
let _promise = me.loginWebauthn();
|
||||||
|
} else if (challenge.u2f && initialTab === 3) {
|
||||||
|
let _promise = me.loginU2F();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
'tabpanel': {
|
||||||
|
tabchange: function(tabPanel, newCard, oldCard) {
|
||||||
|
// for now every TFA method has at max one field, so keep it simple..
|
||||||
|
let oldField = oldCard.down('field');
|
||||||
|
if (oldField) {
|
||||||
|
oldField.setDisabled(true);
|
||||||
|
}
|
||||||
|
let newField = newCard.down('field');
|
||||||
|
if (newField) {
|
||||||
|
newField.setDisabled(false);
|
||||||
|
newField.focus();
|
||||||
|
newField.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmText = newCard.confirmText || gettext('Confirm Second Factor');
|
||||||
|
this.getViewModel().set('confirmText', confirmText);
|
||||||
|
|
||||||
|
this.saveLastTabUsed(tabPanel, newCard);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'field': {
|
||||||
|
validitychange: function(field, valid) {
|
||||||
|
// triggers only for enabled fields and we disable the one from the
|
||||||
|
// non-visible tab, so we can just directly use the valid param
|
||||||
|
this.getViewModel().set('canConfirm', valid);
|
||||||
|
},
|
||||||
|
afterrender: field => field.focus(), // ensure focus after initial render
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
saveLastTabUsed: function(tabPanel, card) {
|
||||||
|
let id = tabPanel.items.indexOf(card);
|
||||||
|
window.localStorage.setItem('Proxmox.TFALogin.lastTab', JSON.stringify({ id }));
|
||||||
|
},
|
||||||
|
|
||||||
|
getLastTabUsed: function() {
|
||||||
|
let data = window.localStorage.getItem('Proxmox.TFALogin.lastTab');
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
let last = JSON.parse(data);
|
||||||
|
return last.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onClose: function() {
|
||||||
|
let me = this;
|
||||||
|
let view = me.getView();
|
||||||
|
|
||||||
|
if (!view.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.onReject();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: function() {
|
||||||
|
this.getView().close();
|
||||||
|
},
|
||||||
|
|
||||||
|
loginTotp: function() {
|
||||||
|
let me = this;
|
||||||
|
|
||||||
|
let code = me.lookup('totp').getValue();
|
||||||
|
let _promise = me.finishChallenge(`totp:${code}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
loginWebauthn: async function() {
|
||||||
|
let me = this;
|
||||||
|
let view = me.getView();
|
||||||
|
|
||||||
|
me.lookup('webAuthnWaiting').setVisible(true);
|
||||||
|
me.lookup('webAuthnError').setVisible(false);
|
||||||
|
|
||||||
|
let challenge = view.challenge.webauthn;
|
||||||
|
|
||||||
|
if (typeof challenge.string !== 'string') {
|
||||||
|
// Byte array fixup, keep challenge string:
|
||||||
|
challenge.string = challenge.publicKey.challenge;
|
||||||
|
challenge.publicKey.challenge = Proxmox.Utils.base64url_to_bytes(challenge.string);
|
||||||
|
for (const cred of challenge.publicKey.allowCredentials) {
|
||||||
|
cred.id = Proxmox.Utils.base64url_to_bytes(cred.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = new AbortController();
|
||||||
|
challenge.signal = controller.signal;
|
||||||
|
|
||||||
|
let hwrsp;
|
||||||
|
try {
|
||||||
|
//Promise.race( ...
|
||||||
|
hwrsp = await navigator.credentials.get(challenge);
|
||||||
|
} catch (error) {
|
||||||
|
// we do NOT want to fail login because of canceling the challenge actively,
|
||||||
|
// in some browser that's the only way to switch over to another method as the
|
||||||
|
// disallow user input during the time the challenge is active
|
||||||
|
// checking for error.code === DOMException.ABORT_ERR only works in firefox -.-
|
||||||
|
this.getViewModel().set('canConfirm', true);
|
||||||
|
// FIXME: better handling, show some message, ...?
|
||||||
|
me.lookup('webAuthnError').setData({
|
||||||
|
error: Ext.htmlEncode(error.toString()),
|
||||||
|
});
|
||||||
|
me.lookup('webAuthnError').setVisible(true);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
let waitingMessage = me.lookup('webAuthnWaiting');
|
||||||
|
if (waitingMessage) {
|
||||||
|
waitingMessage.setVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = {
|
||||||
|
id: hwrsp.id,
|
||||||
|
type: hwrsp.type,
|
||||||
|
challenge: challenge.string,
|
||||||
|
rawId: Proxmox.Utils.bytes_to_base64url(hwrsp.rawId),
|
||||||
|
response: {
|
||||||
|
authenticatorData: Proxmox.Utils.bytes_to_base64url(
|
||||||
|
hwrsp.response.authenticatorData,
|
||||||
|
),
|
||||||
|
clientDataJSON: Proxmox.Utils.bytes_to_base64url(hwrsp.response.clientDataJSON),
|
||||||
|
signature: Proxmox.Utils.bytes_to_base64url(hwrsp.response.signature),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await me.finishChallenge("webauthn:" + JSON.stringify(response));
|
||||||
|
},
|
||||||
|
|
||||||
|
loginU2F: async function() {
|
||||||
|
let me = this;
|
||||||
|
let view = me.getView();
|
||||||
|
|
||||||
|
me.lookup('u2fWaiting').setVisible(true);
|
||||||
|
me.lookup('u2fError').setVisible(false);
|
||||||
|
|
||||||
|
let hwrsp;
|
||||||
|
try {
|
||||||
|
hwrsp = await new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let data = view.challenge.u2f;
|
||||||
|
let chlg = data.challenge;
|
||||||
|
u2f.sign(chlg.appId, chlg.challenge, data.keys, resolve);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hwrsp.errorCode) {
|
||||||
|
throw Proxmox.Utils.render_u2f_error(hwrsp.errorCode);
|
||||||
|
}
|
||||||
|
delete hwrsp.errorCode;
|
||||||
|
} catch (error) {
|
||||||
|
this.getViewModel().set('canConfirm', true);
|
||||||
|
me.lookup('u2fError').setData({
|
||||||
|
error: Ext.htmlEncode(error.toString()),
|
||||||
|
});
|
||||||
|
me.lookup('u2fError').setVisible(true);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
let waitingMessage = me.lookup('u2fWaiting');
|
||||||
|
if (waitingMessage) {
|
||||||
|
waitingMessage.setVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await me.finishChallenge("u2f:" + JSON.stringify(hwrsp));
|
||||||
|
},
|
||||||
|
|
||||||
|
loginRecovery: function() {
|
||||||
|
let me = this;
|
||||||
|
|
||||||
|
let key = me.lookup('recoveryKey').getValue();
|
||||||
|
let _promise = me.finishChallenge(`recovery:${key}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
loginTFA: function() {
|
||||||
|
let me = this;
|
||||||
|
// avoid triggering more than once during challenge
|
||||||
|
me.getViewModel().set('canConfirm', false);
|
||||||
|
let view = me.getView();
|
||||||
|
let tfaPanel = view.down('tabpanel').getActiveTab();
|
||||||
|
me[tfaPanel.handler]();
|
||||||
|
},
|
||||||
|
|
||||||
|
finishChallenge: function(password) {
|
||||||
|
let me = this;
|
||||||
|
let view = me.getView();
|
||||||
|
view.cancelled = false;
|
||||||
|
|
||||||
|
let params = {
|
||||||
|
username: view.userid,
|
||||||
|
'tfa-challenge': view.ticket,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolve = view.onResolve;
|
||||||
|
let reject = view.onReject;
|
||||||
|
view.close();
|
||||||
|
|
||||||
|
return Proxmox.Async.api2({
|
||||||
|
url: '/api2/extjs/access/ticket',
|
||||||
|
method: 'POST',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
listeners: {
|
||||||
|
close: 'onClose',
|
||||||
|
},
|
||||||
|
|
||||||
|
items: [{
|
||||||
|
xtype: 'tabpanel',
|
||||||
|
region: 'center',
|
||||||
|
layout: 'fit',
|
||||||
|
bodyPadding: 10,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
xtype: 'panel',
|
||||||
|
title: 'WebAuthn',
|
||||||
|
iconCls: 'fa fa-fw fa-shield',
|
||||||
|
confirmText: gettext('Start WebAuthn challenge'),
|
||||||
|
handler: 'loginWebauthn',
|
||||||
|
bind: {
|
||||||
|
disabled: '{!availableChallenge.webauthn}',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
html: gettext('Please insert your authentication device and press its button'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
|
||||||
|
reference: 'webAuthnWaiting',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
data: {
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
tpl: '<i class="fa fa-warning warning"></i> {error}',
|
||||||
|
reference: 'webAuthnError',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'panel',
|
||||||
|
title: gettext('TOTP App'),
|
||||||
|
iconCls: 'fa fa-fw fa-clock-o',
|
||||||
|
handler: 'loginTotp',
|
||||||
|
bind: {
|
||||||
|
disabled: '{!availableChallenge.totp}',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
xtype: 'textfield',
|
||||||
|
fieldLabel: gettext('Please enter your TOTP verification code'),
|
||||||
|
labelWidth: 300,
|
||||||
|
name: 'totp',
|
||||||
|
disabled: true,
|
||||||
|
reference: 'totp',
|
||||||
|
allowBlank: false,
|
||||||
|
regex: /^[0-9]{2,16}$/,
|
||||||
|
regexText: gettext('TOTP codes usually consist of six decimal digits'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'panel',
|
||||||
|
title: gettext('Recovery Key'),
|
||||||
|
iconCls: 'fa fa-fw fa-file-text-o',
|
||||||
|
handler: 'loginRecovery',
|
||||||
|
bind: {
|
||||||
|
disabled: '{!availableChallenge.recovery}',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
reference: 'availableRecovery',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'textfield',
|
||||||
|
fieldLabel: gettext('Please enter one of your single-use recovery keys'),
|
||||||
|
labelWidth: 300,
|
||||||
|
name: 'recoveryKey',
|
||||||
|
disabled: true,
|
||||||
|
reference: 'recoveryKey',
|
||||||
|
allowBlank: false,
|
||||||
|
regex: /^[0-9a-f]{4}(-[0-9a-f]{4}){3}$/,
|
||||||
|
regexText: gettext('Does not look like a valid recovery key'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
reference: 'recoveryLow',
|
||||||
|
hidden: true,
|
||||||
|
html: '<i class="fa fa-exclamation-triangle warning"></i>'
|
||||||
|
+ gettext('Less than {0} recovery keys available. Please generate a new set after login!'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'panel',
|
||||||
|
title: 'U2F',
|
||||||
|
iconCls: 'fa fa-fw fa-shield',
|
||||||
|
confirmText: gettext('Start U2F challenge'),
|
||||||
|
handler: 'loginU2F',
|
||||||
|
bind: {
|
||||||
|
disabled: '{!availableChallenge.u2f}',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
html: gettext('Please insert your authentication device and press its button'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
html: gettext('Waiting for second factor.') +`<i class="fa fa-refresh fa-spin fa-fw"></i>`,
|
||||||
|
reference: 'u2fWaiting',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xtype: 'box',
|
||||||
|
data: {
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
tpl: '<i class="fa fa-warning warning"></i> {error}',
|
||||||
|
reference: 'u2fError',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
handler: 'loginTFA',
|
||||||
|
reference: 'tfaButton',
|
||||||
|
disabled: true,
|
||||||
|
bind: {
|
||||||
|
text: '{confirmText}',
|
||||||
|
disabled: '{!canConfirm}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user