gui: tfa support

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller
2020-11-02 14:36:10 +01:00
parent 7f066a9b21
commit fbeac4ea28
10 changed files with 1477 additions and 18 deletions

View File

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