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

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