Commit ee05c9c4 authored by Jérome Perrin's avatar Jérome Perrin

Cleanup google and facebook login and cookie management

First,  cleanup `testERPSecurity`, moving related tests in sub-classes instead of big classes with lots of tests (diff is big, because methods are moved around)

Change google and facebook login to reuse `portal.setAuthCookie`, which is the central point to set a cookie securely so that it can be used for authentication.

Refactor management of oauth keys to fix [#20181121-1A36AE2](https://nexedi.erp5.net/bug_module/20181121-1A36AE2).

Some minor fixes in business template definition.

/reviewed-on !803
parents bab963e4 05deeb26
import facebook import facebook
from ZTUtils import make_query
from Products.ERP5Security.ERP5ExternalOauth2ExtractionPlugin import getFacebookUserEntry from Products.ERP5Security.ERP5ExternalOauth2ExtractionPlugin import getFacebookUserEntry
def _getFacebookClientIdAndSecretKey(portal, reference="default"):
"""Returns facebook client id and secret key.
Internal function.
"""
result_list = portal.portal_catalog.unrestrictedSearchResults(
portal_type="Facebook Connector",
reference=reference,
validation_state="validated",
limit=2,
)
assert result_list, "Facebook Connector not found"
if len(result_list) == 2:
raise ValueError("Impossible to select one Facebook Connector")
facebook_connector = result_list[0]
return facebook_connector.getClientId(), facebook_connector.getSecretKey()
def redirectToFacebookLoginPage(self, came_from=None):
client_id, _ = _getFacebookClientIdAndSecretKey(self.getPortalObject())
query = make_query({
# Call at he context of the appropriate web_service.
'client_id': client_id,
'redirect_uri': "{0}/ERP5Site_callbackFacebookLogin".format(came_from or self.absolute_url()),
'scope': 'email'
})
return self.REQUEST.RESPONSE.redirect("https://www.facebook.com/v2.10/dialog/oauth?{}".format(query))
def getAccessTokenFromCode(self, code, redirect_uri): def getAccessTokenFromCode(self, code, redirect_uri):
client_id, secret_key = self.ERP5Site_getFacebookClientIdAndSecretKey() client_id, secret_key = _getFacebookClientIdAndSecretKey(self.getPortalObject())
return facebook.GraphAPI(version="2.7").get_access_token_from_code( return facebook.GraphAPI(version="2.7").get_access_token_from_code(
code=code, redirect_uri=redirect_uri, code=code, redirect_uri=redirect_uri,
app_id=client_id, app_secret=secret_key) app_id=client_id, app_secret=secret_key)
......
import time import time
request = container.REQUEST
response = request.RESPONSE
def handleError(error): def handleError(error):
context.Base_redirect( context.Base_redirect(
'login_form', 'login_form',
...@@ -21,7 +24,7 @@ elif code is not None: ...@@ -21,7 +24,7 @@ elif code is not None:
access_token = response_dict['access_token'].encode('utf-8') access_token = response_dict['access_token'].encode('utf-8')
hash_str = context.Base_getHMAC(access_token, access_token) hash_str = context.Base_getHMAC(access_token, access_token)
context.REQUEST.RESPONSE.setCookie('__ac_facebook_hash', hash_str, path='/') context.setAuthCookie(response, '__ac_facebook_hash', hash_str)
# store timestamp in second since the epoch in UTC is enough # store timestamp in second since the epoch in UTC is enough
response_dict["response_timestamp"] = time.time() response_dict["response_timestamp"] = time.time()
...@@ -45,7 +48,7 @@ elif code is not None: ...@@ -45,7 +48,7 @@ elif code is not None:
# https://developers.facebook.com/support/bugs/318390728250352/?disable_redirect=0 # https://developers.facebook.com/support/bugs/318390728250352/?disable_redirect=0
# https://stackoverflow.com/questions/7131909/facebook-callback-appends-to-return-url/33257076#33257076 # https://stackoverflow.com/questions/7131909/facebook-callback-appends-to-return-url/33257076#33257076
# https://lab.nexedi.com/nexedi/erp5/merge_requests/417#note_64365 # https://lab.nexedi.com/nexedi/erp5/merge_requests/417#note_64365
came_from = context.REQUEST.get("came_from", portal.absolute_url() + "#") came_from = request.get("came_from", portal.absolute_url() + "#")
return context.REQUEST.RESPONSE.redirect(came_from) return response.redirect(came_from)
return handleError('') return handleError('')
if REQUEST is not None:
raise ValueError("This script can't be called in the URL")
result_list = context.getPortalObject().portal_catalog(
portal_type="Facebook Connector",
reference=reference,
validation_state="validated",
limit=2,
)
assert result_list, "Facebook Connector not found"
if len(result_list) == 2:
raise ValueError("Impossible to select one Facebook Connector")
facebook_connector = result_list[0]
return facebook_connector.getClientId(), facebook_connector.getSecretKey()
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>reference="default", REQUEST=None</string> </value>
</item>
<item>
<key> <string>_proxy_roles</string> </key>
<value>
<tuple>
<string>Manager</string>
</tuple>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>ERP5Site_getFacebookClientIdAndSecretKey</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
from ZTUtils import make_query
client_id, _ = context.ERP5Site_getFacebookClientIdAndSecretKey()
query = make_query({
# Call at he context of the appropriate web_service.
'client_id': client_id,
'redirect_uri': "{0}/ERP5Site_callbackFacebookLogin".format(came_from or context.absolute_url()),
'scope': 'email'
})
login_url = "https://www.facebook.com/v2.10/dialog/oauth"
if "?" not in login_url:
login_url += "?"
return context.REQUEST.RESPONSE.redirect("{0}{1}".format(login_url, query))
...@@ -2,68 +2,26 @@ ...@@ -2,68 +2,26 @@
<ZopeData> <ZopeData>
<record id="1" aka="AAAAAAAAAAE="> <record id="1" aka="AAAAAAAAAAE=">
<pickle> <pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/> <global name="ExternalMethod" module="Products.ExternalMethod.ExternalMethod"/>
</pickle> </pickle>
<pickle> <pickle>
<dictionary> <dictionary>
<item> <item>
<key> <string>Script_magic</string> </key> <key> <string>_function</string> </key>
<value> <int>3</int> </value> <value> <string>redirectToFacebookLoginPage</string> </value>
</item> </item>
<item> <item>
<key> <string>_bind_names</string> </key> <key> <string>_module</string> </key>
<value> <value> <string>FacebookLoginUtility</string> </value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>came_from=None</string> </value>
</item>
<item>
<key> <string>_proxy_roles</string> </key>
<value>
<tuple>
<string>Auditor</string>
</tuple>
</value>
</item> </item>
<item> <item>
<key> <string>id</string> </key> <key> <string>id</string> </key>
<value> <string>ERP5Site_redirectToFacebookLoginPage</string> </value> <value> <string>ERP5Site_redirectToFacebookLoginPage</string> </value>
</item> </item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary> </dictionary>
</pickle> </pickle>
</record> </record>
......
...@@ -74,7 +74,6 @@ class TestFacebookLogin(ERP5TypeTestCase): ...@@ -74,7 +74,6 @@ class TestFacebookLogin(ERP5TypeTestCase):
FacebookLoginUtility.getUserEntry = getUserEntry FacebookLoginUtility.getUserEntry = getUserEntry
self.dummy_connector_id = "test_facebook_connector" self.dummy_connector_id = "test_facebook_connector"
person_module = self.portal.person_module
portal_catalog = self.portal.portal_catalog portal_catalog = self.portal.portal_catalog
for obj in portal_catalog(portal_type=["Facebook Login", "Person"], for obj in portal_catalog(portal_type=["Facebook Login", "Person"],
reference=getUserId(None), reference=getUserId(None),
...@@ -112,6 +111,17 @@ class TestFacebookLogin(ERP5TypeTestCase): ...@@ -112,6 +111,17 @@ class TestFacebookLogin(ERP5TypeTestCase):
self.assertNotIn("secret_key=", location) self.assertNotIn("secret_key=", location)
self.assertIn("ERP5Site_callbackFacebookLogin", location) self.assertIn("ERP5Site_callbackFacebookLogin", location)
def test_auth_cookie(self):
request = self.portal.REQUEST
response = request.RESPONSE
# (the secure flag is only set if we accessed through https)
request.setServerURL('https', 'example.com')
self.portal.ERP5Site_callbackFacebookLogin(code=CODE)
ac_cookie, = [v for (k, v) in response.listHeaders() if k.lower() == 'set-cookie' and '__ac_facebook_hash=' in v]
self.assertIn('; Secure', ac_cookie)
self.assertIn('; HTTPOnly', ac_cookie)
def test_create_user_in_ERP5Site_createFacebookUserToOAuth(self): def test_create_user_in_ERP5Site_createFacebookUserToOAuth(self):
""" """
Check if ERP5 set cookie properly after receive code from external service Check if ERP5 set cookie properly after receive code from external service
......
...@@ -45,9 +45,7 @@ ...@@ -45,9 +45,7 @@
<item> <item>
<key> <string>text_content_warning_message</string> </key> <key> <string>text_content_warning_message</string> </key>
<value> <value>
<tuple> <tuple/>
<string>W: 77, 4: Unused variable \'person_module\' (unused-variable)</string>
</tuple>
</value> </value>
</item> </item>
<item> <item>
......
...@@ -5,8 +5,27 @@ from Products.ERP5Security.ERP5ExternalOauth2ExtractionPlugin import getGoogleUs ...@@ -5,8 +5,27 @@ from Products.ERP5Security.ERP5ExternalOauth2ExtractionPlugin import getGoogleUs
SCOPE_LIST = ['https://www.googleapis.com/auth/userinfo.profile', SCOPE_LIST = ['https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email'] 'https://www.googleapis.com/auth/userinfo.email']
def _getGoogleClientIdAndSecretKey(portal, reference="default"):
"""Returns google client id and secret key.
Internal function.
"""
result_list = portal.portal_catalog.unrestrictedSearchResults(
portal_type="Google Connector",
reference=reference,
validation_state="validated",
limit=2,
)
assert result_list, "Google Connector not found"
if len(result_list) == 2:
raise ValueError("Impossible to select one Google Connector")
google_connector = result_list[0].getObject()
return google_connector.getClientId(), google_connector.getSecretKey()
def redirectToGoogleLoginPage(self): def redirectToGoogleLoginPage(self):
client_id, secret_key = self.ERP5Site_getGoogleClientIdAndSecretKey() client_id, secret_key = _getGoogleClientIdAndSecretKey(self.getPortalObject())
flow = oauth2client.client.OAuth2WebServerFlow( flow = oauth2client.client.OAuth2WebServerFlow(
client_id=client_id, client_id=client_id,
client_secret=secret_key, client_secret=secret_key,
...@@ -18,8 +37,7 @@ def redirectToGoogleLoginPage(self): ...@@ -18,8 +37,7 @@ def redirectToGoogleLoginPage(self):
self.REQUEST.RESPONSE.redirect(flow.step1_get_authorize_url()) self.REQUEST.RESPONSE.redirect(flow.step1_get_authorize_url())
def getAccessTokenFromCode(self, code, redirect_uri): def getAccessTokenFromCode(self, code, redirect_uri):
portal = self.getPortalObject() client_id, secret_key = _getGoogleClientIdAndSecretKey(self.getPortalObject())
client_id, secret_key = portal.ERP5Site_getGoogleClientIdAndSecretKey()
flow = oauth2client.client.OAuth2WebServerFlow( flow = oauth2client.client.OAuth2WebServerFlow(
client_id=client_id, client_id=client_id,
client_secret=secret_key, client_secret=secret_key,
......
if REQUEST is not None:
raise ValueError("This script can't be called in the URL")
result_list = context.getPortalObject().portal_catalog(
portal_type="Google Connector",
reference=reference,
validation_state="validated",
limit=2,
)
assert result_list, "Google Connector not found"
if len(result_list) == 2:
raise ValueError("Impossible to select one Google Connector")
google_connector = result_list[0].getObject()
return google_connector.getClientId(), google_connector.getSecretKey()
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>reference="default", REQUEST=None</string> </value>
</item>
<item>
<key> <string>_proxy_roles</string> </key>
<value>
<tuple>
<string>Manager</string>
</tuple>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>ERP5Site_getGoogleClientIdAndSecretKey</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
import time import time
request = container.REQUEST
response = request.RESPONSE
def handleError(error): def handleError(error):
context.Base_redirect( context.Base_redirect(
'login_form', 'login_form',
...@@ -19,7 +22,7 @@ elif code is not None: ...@@ -19,7 +22,7 @@ elif code is not None:
if response_dict is not None: if response_dict is not None:
access_token = response_dict['access_token'].encode('utf-8') access_token = response_dict['access_token'].encode('utf-8')
hash_str = context.Base_getHMAC(access_token, access_token) hash_str = context.Base_getHMAC(access_token, access_token)
context.REQUEST.RESPONSE.setCookie('__ac_google_hash', hash_str, path='/') context.setAuthCookie(response, '__ac_google_hash', hash_str)
# store timestamp in second since the epoch in UTC is enough # store timestamp in second since the epoch in UTC is enough
response_dict["response_timestamp"] = time.time() response_dict["response_timestamp"] = time.time()
context.Base_setBearerToken(hash_str, context.Base_setBearerToken(hash_str,
...@@ -33,7 +36,6 @@ elif code is not None: ...@@ -33,7 +36,6 @@ elif code is not None:
method = getattr(context, "ERP5Site_createGoogleUserToOAuth", None) method = getattr(context, "ERP5Site_createGoogleUserToOAuth", None)
if method is not None: if method is not None:
method(user_reference, user_dict) method(user_reference, user_dict)
return context.REQUEST.RESPONSE.redirect( return response.redirect(request.get("came_from") or context.absolute_url())
context.REQUEST.get("came_from") or context.absolute_url())
return handleError('') return handleError('')
...@@ -111,7 +111,6 @@ class TestGoogleLogin(ERP5TypeTestCase): ...@@ -111,7 +111,6 @@ class TestGoogleLogin(ERP5TypeTestCase):
GoogleLoginUtility.getUserEntry = getUserEntry GoogleLoginUtility.getUserEntry = getUserEntry
self.dummy_connector_id = "test_google_connector" self.dummy_connector_id = "test_google_connector"
person_module = self.portal.person_module
portal_catalog = self.portal.portal_catalog portal_catalog = self.portal.portal_catalog
for obj in portal_catalog(portal_type=["Google Login", "Person"], for obj in portal_catalog(portal_type=["Google Login", "Person"],
reference=getUserId(None), reference=getUserId(None),
...@@ -149,6 +148,17 @@ class TestGoogleLogin(ERP5TypeTestCase): ...@@ -149,6 +148,17 @@ class TestGoogleLogin(ERP5TypeTestCase):
self.assertNotIn("secret_key=", location) self.assertNotIn("secret_key=", location)
self.assertIn("ERP5Site_receiveGoogleCallback", location) self.assertIn("ERP5Site_receiveGoogleCallback", location)
def test_auth_cookie(self):
request = self.portal.REQUEST
response = request.RESPONSE
# (the secure flag is only set if we accessed through https)
request.setServerURL('https', 'example.com')
self.portal.ERP5Site_receiveGoogleCallback(code=CODE)
ac_cookie, = [v for (k, v) in response.listHeaders() if k.lower() == 'set-cookie' and '__ac_google_hash=' in v]
self.assertIn('; Secure', ac_cookie)
self.assertIn('; HTTPOnly', ac_cookie)
def test_create_user_in_ERP5Site_createGoogleUserToOAuth(self): def test_create_user_in_ERP5Site_createGoogleUserToOAuth(self):
""" """
Check if ERP5 set cookie properly after receive code from external service Check if ERP5 set cookie properly after receive code from external service
......
TemplateToolERP5GoogleExtractionPluginConstraint
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment