Signed-off-by: Wolfgang Bumiller <w.bumil...@proxmox.com> --- PVE/HTTPServer.pm | 2 +- www/index.html.tpl | 2 + www/manager6/Makefile | 1 + www/manager6/Workspace.js | 6 +- www/manager6/dc/TFAEdit.js | 463 +++++++++++++++++++++++++++++++++++++ www/manager6/dc/UserView.js | 15 +- www/manager6/window/LoginWindow.js | 121 +++++++--- 7 files changed, 577 insertions(+), 33 deletions(-) create mode 100644 www/manager6/dc/TFAEdit.js
diff --git a/PVE/HTTPServer.pm b/PVE/HTTPServer.pm index ec970010..ec57cd09 100755 --- a/PVE/HTTPServer.pm +++ b/PVE/HTTPServer.pm @@ -85,7 +85,7 @@ sub auth_handler { if (defined($challenge)) { $rpcenv->set_u2f_challenge($challenge); die "No ticket\n" - if ($rel_uri ne '/access/u2f' || $method ne 'POST'); + if ($rel_uri ne '/access/tfa' || $method ne 'POST'); } $rpcenv->set_user($username); diff --git a/www/index.html.tpl b/www/index.html.tpl index ae7f610f..2cb6d0c7 100644 --- a/www/index.html.tpl +++ b/www/index.html.tpl @@ -22,6 +22,8 @@ [%- ELSE %] <script type="text/javascript" src="/pve2/ext6/ext-all.js"></script> <script type="text/javascript" src="/pve2/ext6/charts.js"></script> + <script type="text/javascript" src="/pve2/js/u2f-api.js"></script> + <script type="text/javascript" src="/pve2/js/qrcode.min.js"></script> [% END %] <script type="text/javascript"> Proxmox = { diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 5ad70933..853fcb4f 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -200,6 +200,7 @@ JSSRC= \ dc/Guests.js \ dc/OptionView.js \ dc/StorageView.js \ + dc/TFAEdit.js \ dc/UserEdit.js \ dc/UserView.js \ dc/PoolView.js \ diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index e88300f2..1d343525 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -19,8 +19,7 @@ Ext.define('PVE.Workspace', { updateLoginData: function(loginData) { var me = this; me.loginData = loginData; - Proxmox.CSRFPreventionToken = loginData.CSRFPreventionToken; - Proxmox.UserName = loginData.username; + Proxmox.Utils.setAuthData(loginData); var rt = me.down('pveResourceTree'); rt.setDatacenterText(loginData.clustername); @@ -29,9 +28,6 @@ Ext.define('PVE.Workspace', { Ext.state.Manager.set('GuiCap', loginData.cap); } - // creates a session cookie (expire = null) - // that way the cookie gets deleted after browser window close - Ext.util.Cookies.set('PVEAuthCookie', loginData.ticket, null, '/', null, true); me.onLogin(loginData); }, diff --git a/www/manager6/dc/TFAEdit.js b/www/manager6/dc/TFAEdit.js new file mode 100644 index 00000000..c92afa6b --- /dev/null +++ b/www/manager6/dc/TFAEdit.js @@ -0,0 +1,463 @@ +Ext.define('PVE.window.TFAEdit', { + extend: 'Ext.window.Window', + mixins: ['Proxmox.Mixin.CBind'], + + modal: true, + resizable: false, + title: gettext('Two Factor Authentication'), + subject: 'TFA', + url: '/api2/extjs/access/tfa', + width: 512, + + layout: { + type: 'vbox', + align: 'stretch' + }, + + updateQrCode: function() { + var me = this; + var values = me.lookup('totp-form').getValues(); + var algorithm = values.algorithm; + if (!algorithm) { + algorithm = 'SHA1'; + } + + me.qrcode.makeCode( + 'otpauth://totp/' + encodeURIComponent(values.name) + + '?secret=' + values.secret + + '&period=' + values.step + + '&digits=' + values.digits + + '&algorithm=' + algorithm + + '&issuer=' + encodeURIComponent(values.issuer) + ); + + me.lookup('challenge').setVisible(true); + me.down('#qrbox').setVisible(true); + }, + + showError: function(error) { + var ErrorNames = { + '1': gettext('Other Error'), + '2': gettext('Bad Request'), + '3': gettext('Configuration Unsupported'), + '4': gettext('Device Ineligible'), + '5': gettext('Timeout') + }; + Ext.Msg.alert( + gettext('Error'), + "U2F Error: " + (ErrorNames[error] || Proxmox.Utils.unknownText) + ); + }, + + doU2FChallenge: function(response) { + var me = this; + + var data = response.result.data; + me.lookup('password').setDisabled(true); + var msg = Ext.Msg.show({ + title: 'U2F: '+gettext('Setup'), + message: gettext('Please press the button on your U2F Device'), + buttons: [] + }); + Ext.Function.defer(function() { + u2f.register(data.appId, [data], [], function(data) { + msg.close(); + if (data.errorCode) { + me.showError(data.errorCode); + } else { + me.respondToU2FChallenge(data); + } + }); + }, 500, me); + }, + + respondToU2FChallenge: function(data) { + var me = this; + var params = { + userid: me.userid, + action: 'confirm', + response: JSON.stringify(data) + }; + if (Proxmox.UserName !== 'root@pam') { + params.password = me.lookup('password').value; + } + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: params, + method: 'PUT', + success: function() { + me.close(); + Ext.Msg.show({ + title: gettext('Success'), + message: gettext('U2F Device successfully connected.'), + buttons: Ext.Msg.OK + }); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + } + }); + }, + + viewModel: { + data: { + in_totp_tab: true, + tfa_required: false, + u2f_available: true, + } + }, + + afterLoadingRealm: function(realm_tfa_type) { + var me = this; + var viewmodel = me.getViewModel(); + if (!realm_tfa_type) { + // There's no TFA enforced by the realm, everything works. + viewmodel.set('u2f_available', true); + viewmodel.set('tfa_required', false); + } else if (realm_tfa_type === 'oath') { + // The realm explicitly requires TOTP + viewmodel.set('tfa_required', true); + viewmodel.set('u2f_available', false); + } else { + // The realm enforces some other TFA type (yubico) + me.close(); + Ext.Msg.alert( + gettext('Error'), + Ext.String.format( + gettext("Custom 2nd factor configuration is not supported on realms with '{0}' TFA."), + realm_tfa_type + ) + ); + } + //me.lookup('delete-button').setDisabled(has_tfa_configured); + //me.lookup('u2f-panel').setDisabled(has_tfa_configured); + }, + + controller: { + xclass: 'Ext.app.ViewController', + control: { + 'field[qrupdate=true]': { + change: function() { + var me = this.getView(); + me.updateQrCode(); + } + }, + '#': { + show: function() { + var me = this.getView(); + me.down('#qrbox').getEl().appendChild(me.qrdiv); + me.down('#qrbox').setVisible(false); + + if (Proxmox.UserName === 'root@pam') { + me.lookup('password').setVisible(false); + me.lookup('password').setDisabled(true); + } + me.lookup('challenge').setVisible(false); + } + }, + '#tfatabs': { + tabchange: function(panel, newcard) { + var viewmodel = this.getViewModel(); + viewmodel.set('in_totp_tab', newcard.itemId === 'totp-panel'); + } + } + }, + + applySettings: function() { + var me = this; + var values = me.lookup('totp-form').getValues(); + var params = { + userid: me.getView().userid, + action: 'new', + key: values.secret, + config: PVE.Parser.printPropertyString({ + type: 'oath', + digits: values.digits, + step: values.step, + }), + // this is used to verify that the client generates the correct codes: + response: me.lookup('challenge').value, + }; + + if (Proxmox.UserName !== 'root@pam') { + params.password = me.lookup('password').value; + } + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: params, + method: 'PUT', + waitMsgTarget: me.getView(), + success: function(response, opts) { + me.getView().close(); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + } + }); + }, + + deleteTFA: function() { + var me = this; + var values = me.lookup('totp-form').getValues(); + var params = { + userid: me.getView().userid, + action: 'delete', + }; + + if (Proxmox.UserName !== 'root@pam') { + params.password = me.lookup('password').value; + } + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: params, + method: 'PUT', + waitMsgTarget: me.getView(), + success: function(response, opts) { + me.getView().close(); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + } + }); + }, + + randomizeSecret: function() { + var me = this; + var rnd = new Uint8Array(16); + window.crypto.getRandomValues(rnd); + var data = ''; + rnd.forEach(function(b) { + // just use the first 5 bit + b = b & 0x1f; + if (b < 26) { + // A..Z + data += String.fromCharCode(b + 0x41); + } else { + // 2..7 + data += String.fromCharCode(b-26 + 0x32); + } + }); + me.lookup('tfa-secret').setValue(data); + }, + + startU2FRegistration: function() { + var me = this; + + var params = { + userid: me.getView().userid, + action: 'new' + }; + + if (Proxmox.UserName !== 'root@pam') { + params.password = me.lookup('password').value; + } + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: params, + method: 'PUT', + waitMsgTarget: me.getView(), + success: function(response) { + me.getView().doU2FChallenge(response); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + } + }); + } + }, + + items: [ + { + xtype: 'tabpanel', + itemId: 'tfatabs', + border: false, + items: [ + { + xtype: 'panel', + title: 'TOTP', + itemId: 'totp-panel', + border: false, + layout: { + type: 'vbox', + align: 'stretch' + }, + items: [ + { + xtype: 'form', + layout: 'anchor', + border: false, + reference: 'totp-form', + fieldDefaults: { + labelWidth: 120, + anchor: '100%', + padding: '0 5', + }, + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('Secret'), + name: 'secret', + reference: 'tfa-secret', + validateValue: function(value) { + return value.match(/^[A-Z2-7=]$/); + }, + qrupdate: true, + padding: '5 5', + }, + { + xtype: 'numberfield', + fieldLabel: gettext('Time period'), + name: 'step', + value: 30, + minValue: 10, + qrupdate: true, + }, + { + xtype: 'numberfield', + fieldLabel: gettext('Digits'), + name: 'digits', + value: 6, + minValue: 6, + maxValue: 8, + qrupdate: true, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Issuer Name'), + name: 'issuer', + value: 'Proxmox Web UI', + qrupdate: true, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Account Name'), + name: 'name', + cbind: { + value: '{userid}', + }, + qrupdate: true, + } + ] + }, + { + xtype: 'box', + itemId: 'qrbox', + visible: false, // will be enabled when generating a qr code + style: { + 'background-color': 'white', + padding: '5px', + width: '266px', + height: '266px', + } + }, + { + xtype: 'textfield', + fieldLabel: gettext('Code'), + labelWidth: 120, + reference: 'challenge', + padding: '0 5', + emptyText: gettext('verify TOTP authentication code') + } + ] + }, + { + title: 'U2F', + itemId: 'u2f-panel', + reference: 'u2f-panel', + border: false, + padding: '5 5', + layout: { + type: 'vbox', + align: 'middle' + }, + bind: { + disabled: '{!u2f_available}' + }, + items: [ + { + xtype: 'label', + width: 500, + text: gettext('To register a U2F device, connect the device, then click the button and follow the instructions.'), + } + ] + } + ] + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + minLength: 5, + reference: 'password', + padding: '0 5', + labelWidth: 120, + emptyText: gettext('verify current password') + } + ], + + buttons: [ + { + text: gettext('Randomize'), + reference: 'randomize-button', + handler: 'randomizeSecret', + bind: { + hidden: '{!in_totp_tab}', + disabled: '{!user_tfa}' + } + }, + { + text: gettext('Apply'), + handler: 'applySettings', + bind: { + hidden: '{!in_totp_tab}', + disabled: '{!user_tfa}' + } + }, + { + xtype: 'button', + text: gettext('Register U2F Device'), + handler: 'startU2FRegistration', + bind: { + hidden: '{in_totp_tab}' + } + }, + { + text: gettext('Delete'), + reference: 'delete-button', + handler: 'deleteTFA', + bind: { + disabled: '{tfa_required}' + } + } + ], + + initComponent: function() { + var me = this; + + me.qrdiv = document.createElement('center'); + me.qrcode = new QRCode(me.qrdiv, { + //text: "This is not the qr code you're looking for", + width: 256, + height: 256, + correctLevel: QRCode.CorrectLevel.M + }); + + var store = new Ext.data.Store({ + model: 'pve-domains', + autoLoad: true + }); + + store.on('load', function() { + var user_realm = me.userid.split('@')[1]; + var realm = me.store.findRecord('realm', user_realm); + me.afterLoadingRealm(realm && realm.data && realm.data.tfa); + }, me); + + Ext.apply(me, { store: store }); + + me.callParent(); + } +}); diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index 4d0c5595..f5c830ec 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -78,6 +78,19 @@ Ext.define('PVE.dc.UserView', { } }); + var tfachange_btn = new Proxmox.button.Button({ + text: gettext('TFA'), + disabled: true, + selModel: sm, + handler: function(btn, event, rec) { + var win = Ext.create('PVE.window.TFAEdit',{ + userid: rec.data.userid + }); + win.on('destroy', reload); + win.show(); + } + }); + var tbar = [ { text: gettext('Add'), @@ -89,7 +102,7 @@ Ext.define('PVE.dc.UserView', { win.show(); } }, - edit_btn, remove_btn, pwchange_btn + edit_btn, remove_btn, pwchange_btn, tfachange_btn ]; var render_username = function(userid) { diff --git a/www/manager6/window/LoginWindow.js b/www/manager6/window/LoginWindow.js index 5967c92f..93b06061 100644 --- a/www/manager6/window/LoginWindow.js +++ b/www/manager6/window/LoginWindow.js @@ -13,39 +13,108 @@ Ext.define('PVE.window.LoginWindow', { var saveunField = this.lookupReference('saveunField'); var view = this.getView(); - if(form.isValid()){ - view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + if (!form.isValid()) { + return; + } + + var perform_u2f_fn; + var finish_u2f_fn; + + var failure_fn = function(resp) { + view.el.unmask(); + var handler = function() { + var uf = me.lookupReference('usernameField'); + uf.focus(true, true); + }; + + Ext.MessageBox.alert(gettext('Error'), + gettext("Login failed. Please try again"), + handler); + }; + + var success_fn = function(data) { + var handler = view.handler || Ext.emptyFn; + handler.call(me, data); + view.close(); + }; + + view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + + // set or clear username + var sp = Ext.state.Manager.getProvider(); + if (saveunField.getValue() === true) { + sp.set(unField.getStateId(), unField.getValue()); + } else { + sp.clear(unField.getStateId()); + } + sp.set(saveunField.getStateId(), saveunField.getValue()); + + form.submit({ + failure: function(f, resp){ + failure_fn(resp); + }, + success: function(f, resp){ + view.el.unmask(); - // set or clear username - var sp = Ext.state.Manager.getProvider(); - if (saveunField.getValue() === true) { - sp.set(unField.getStateId(), unField.getValue()); - } else { - sp.clear(unField.getStateId()); + var data = resp.result.data; + if (Ext.isDefined(data.U2FChallenge)) { + perform_u2f_fn(data); + } else { + success_fn(data); + } } - sp.set(saveunField.getStateId(), saveunField.getValue()); + }); + + perform_u2f_fn = function(data) { + // Store first factor login information first: + data.LoggedOut = true; + Proxmox.Utils.setAuthData(data); + // Show the message: + var msg = Ext.Msg.show({ + title: 'U2F: '+gettext('Verification'), + message: gettext('Please press the button on your U2F Device'), + buttons: [] + }); + var chlg = data.U2FChallenge; + var key = { + version: chlg.version, + keyHandle: chlg.keyHandle + }; + u2f.sign(chlg.appId, chlg.challenge, [key], function(res) { + msg.close(); + if (res.errorCode) { + Proxmox.Utils.authClear(); + Ext.Msg.alert(gettext('Error'), "U2F Error: "+res.errorCode); + return; + } + delete res.errorCode; + finish_u2f_fn(res); + }); + }; - form.submit({ - failure: function(f, resp){ + finish_u2f_fn = function(res) { + view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + var params = { response: JSON.stringify(res) }; + Proxmox.Utils.API2Request({ + url: '/api2/extjs/access/tfa', + params: params, + method: 'POST', + timeout: 5000, // it'll delay both success & failure + success: function(resp, opts) { view.el.unmask(); - var handler = function() { - var uf = me.lookupReference('usernameField'); - uf.focus(true, true); - }; - - Ext.MessageBox.alert(gettext('Error'), - gettext("Login failed. Please try again"), - handler); + // Fill in what we copy over from the 1st factor: + var data = resp.result.data; + data.CSRFPreventionToken = Proxmox.CSRFPreventionToken; + data.username = Proxmox.UserName; + // Finish logging in: + success_fn(data); }, - success: function(f, resp){ - view.el.unmask(); - - var handler = view.handler || Ext.emptyFn; - handler.call(me, resp.result.data); - view.close(); + failure: function(resp, opts) { + Proxmox.Utils.authClear(); + failure_fn(resp); } }); - } + }; }, control: { -- 2.11.0 _______________________________________________ pve-devel mailing list pve-devel@pve.proxmox.com https://pve.proxmox.com/cgi-bin/mailman/listinfo/pve-devel