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:
Wolfgang Bumiller 2021-11-09 12:27:18 +01:00 committed by Dominik Csapak
parent c7f2b15ac9
commit 9e1f1ef616
2 changed files with 430 additions and 0 deletions

View File

@ -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
View 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}',
},
},
],
});