[pve-devel] [PATCH v2 manager 5/5] ui: support u2f authentication and configuration

Wolfgang Bumiller w.bumiller at proxmox.com
Tue Apr 2 12:22:08 CEST 2019


Signed-off-by: Wolfgang Bumiller <w.bumiller at 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 at 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 at 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 at 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 at 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 at 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





More information about the pve-devel mailing list