From 8e92345f6bdcd63ec21003994f76f5c2005edffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 21 Jun 2016 18:39:12 +0000 Subject: [PATCH 01/14] ERP5Security: Introduce to JSON Web Token Authentication Plugin ERP5Security: A small introduction to JSON Web Token Authentication ERP5Security: Expand JWT to add and process extra data while verifying password ERP5Security: Use processDataScript to validate user + clean code ERP5Security: Improve JSONWeb Token, do not set cookie on all request and handle bad signature ERP5Security: JWT Token uses HTTPOnly and Secure cookies ERP5Security: code improvments to ERP5JSONWebTokenPlugin ERP5Security: JSONWebToken Plugin use Timed Token + Various Code improvment erp5_officejs: Data is editable on spreadsheet only if it is the correct type else HTML preview is provided ERP5Security: Add testERP5JSONWebTokenPlugin ERP5Security: JSONWebTokenPlugin do not depend on python scripts + code improvments ERP5Security: Extand Test for ERP5 JSON Web Token Plugin --- .../ERP5Security/ERP5JSONWebTokenPlugin.py | 257 ++++++++++++ product/ERP5Security/ERP5UserManager.py | 81 ++-- product/ERP5Security/__init__.py | 11 + .../tests/testERP5JSONWebTokenPlugin.py | 375 ++++++++++++++++++ ...ERP5Security_addERP5JSONWebTokenPlugin.zpt | 36 ++ ...5Security_updateERP5JSONWebTokenPlugin.zpt | 24 ++ 6 files changed, 748 insertions(+), 36 deletions(-) create mode 100644 product/ERP5Security/ERP5JSONWebTokenPlugin.py create mode 100644 product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py create mode 100644 product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt create mode 100644 product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py new file mode 100644 index 00000000000..189e45c19cc --- /dev/null +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Copyright (c) 2012 Nexedi SA and Contributors. All Rights Reserved. +# +# WARNING: This program as such is intended to be used by professional +# programmers who take the whole responsibility of assessing all potential +# consequences resulting from its eventual inadequacies and bugs +# End users who are looking for a ready-to-use solution with commercial +# guarantees and support are strongly advised to contract a Free Software +# Service Company +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +############################################################################## + +from datetime import datetime +from urlparse import urlparse +from os import urandom +from zLOG import LOG, INFO, ERROR + +from Products.ERP5Type.Globals import InitializeClass +from AccessControl import ClassSecurityInfo + +from Products.PageTemplates.PageTemplateFile import PageTemplateFile +from Products.PluggableAuthService.interfaces import plugins +from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin +from Products.ERP5Security.ERP5UserManager import ERP5UserManager +from Products.PluggableAuthService.permissions import ManageUsers +from Products.PluggableAuthService.PluggableAuthService import DumbHTTPExtractor +from ZODB.utils import u64 +from zope.interface import implementer + +try: + import jwt +except ImportError: + jwt = None + + +#Form for new plugin in ZMI +manage_addERP5JSONWebTokenPluginForm = PageTemplateFile( + 'www/ERP5Security_addERP5JSONWebTokenPlugin', globals(), + __name__='manage_addERP5JSONWebTokenPluginForm') + +def addERP5JSONWebTokenPlugin(dispatcher, id, title=None, REQUEST=None): + """ Add a ERP5JSONWebTokenPlugin to a Pluggable Auth Service. """ + + plugin = ERP5JSONWebTokenPlugin(id, title) + dispatcher._setObject(plugin.getId(), plugin) + + if REQUEST is not None: + REQUEST['RESPONSE'].redirect( + '%s/manage_workspace' + '?manage_tabs_message=' + 'ERP5JSONWebTokenPlugin+added.' + % dispatcher.absolute_url()) + +@implementer( + plugins.ILoginPasswordHostExtractionPlugin, + ) +class ERP5JSONWebTokenPlugin(ERP5UserManager): + + meta_type = "ERP5 JSON Web Token Plugin" + security = ClassSecurityInfo() + same_site_cookie = "erp5_jwt" + cors_cookie = "erp5_cors_jwt" + + manage_options = ( ( { 'label': 'Update Secret', + 'action': 'manage_updateERP5JSONWebTokenPluginForm', } + , + ) + + BasePlugin.manage_options + ) + + + def __init__(self, *args, **kw): + super(ERP5JSONWebTokenPlugin, self).__init__(*args, **kw) + self.manage_updateERP5JSONWebTokenPlugin() + + #################################### + #ILoginPasswordHostExtractionPlugin# + #################################### + security.declarePrivate('extractCredentials') + def extractCredentials(self, request): + """ Extract JWT from the request header. """ + if jwt is None: + LOG('ERP5JSONWebTokenPlugin', INFO, + 'No jwt module, install pyjwt package. ' + 'Authentication disabled.') + return DumbHTTPExtractor().extractCredentials(request) + + creds = {} + + login_pw = request._authUserPW() + if login_pw is not None: + creds[ 'login' ], creds[ 'password' ] = login_pw + else: + # SameSite Policy is implemented serverside + origin = request.getHeader("Origin", None) + if origin is None: + referer_url = request.getHeader("Referer", None) + if referer_url is not None: + # Extract origin from Referer Header + referer_url = urlparse(referer_url) + origin = referer_url.scheme + "://" + referer_url.netloc + + # If the Origin is None or match the current URL it is ignored + if origin is None or origin == request.get('BASE0'): + cookie = self.same_site_cookie + origin = None + else: + cookie = self.cors_cookie + + token = request.cookies.get(cookie) + if not token: + return None + + try: + data = jwt.decode(token, self._secret) + except ( + jwt.InvalidIssuedAtError, + jwt.ExpiredSignatureError, + jwt.InvalidTokenError, + jwt.DecodeError, + ): + request.response.expireCookie(self.same_site_cookie, path='/') + request.response.expireCookie(self.cors_cookie, path='/') + return None + + person_relative_url = data["sub"].encode() + user = self.getPortalObject().unrestrictedTraverse(person_relative_url) + + user.password._p_activate() + if data["ptid"] == u64(user.password._p_serial) \ + and (not origin or data and \ + origin in data.get('cors', ())): + creds['person_relative_url'] = person_relative_url + + creds['remote_host'] = request.get('REMOTE_HOST', '') + try: + creds['remote_address'] = request.getClientAddr() + except AttributeError: + creds['remote_address'] = request.get('REMOTE_ADDR', '') + return creds + + # + # IAuthenticationPlugin implementation + # + security.declarePrivate( 'authenticateCredentials' ) + def authenticateCredentials(self, credentials): + authentication_result = super( + ERP5JSONWebTokenPlugin, + self + ).authenticateCredentials(credentials) + + # In case the password is present in the request, the token is updated + if authentication_result is not None and "password" in credentials: + if jwt is None: + LOG('ERP5JSONWebTokenPlugin', INFO, + 'No jwt module, install pyjwt package. ' + 'Authentication disabled.') + return authentication_result + + if "person_relative_url" not in credentials: + user = self.getUserByLogin(authentication_result[0])[0] + else: + user = self.getPortalObject().unrestrictedTraverse( + credentials["person_relative_url"] + ) + + user.password._p_activate() + data = { + "sub": user.getRelativeUrl(), + "iat": datetime.utcnow(), + "ptid": u64(user.password._p_serial) + } + cookie_parameters = { + "path": '/', + "secure": True, + "http_only": True, + } + + request = self.REQUEST + + new_cors_origin = request.form.get('new_cors_origin') + if new_cors_origin is not None: + cookie = self.cors_cookie + + authorized_cors_origin_list = [] + token = request.cookies.get(cookie) + if token is not None: + try: + authorized_cors_origin_list = jwt.decode(token, self._secret)[ + "cors"] + except ( + jwt.InvalidIssuedAtError, + jwt.ExpiredSignatureError, + jwt.InvalidTokenError, + jwt.DecodeError, + ): + # Mistakes of the past should stay in the past + pass + authorized_cors_origin_list.append(new_cors_origin) + data["cors"] = authorized_cors_origin_list + else: + cookie = self.same_site_cookie + cookie_parameters["same_site"] = "Lax" + + request.response.setCookie( + cookie, + jwt.encode(data, self._secret), + **cookie_parameters + ) + + # Expire default cookie set by default + # (even with plugin deactivated) + request.response.expireCookie('__ac') + + return authentication_result + + ################################ + # Properties for ZMI managment # + ################################ + + #'Edit' option form + manage_updateERP5JSONWebTokenPluginForm = PageTemplateFile( + 'www/ERP5Security_updateERP5JSONWebTokenPlugin', + globals(), + __name__='manage_updateERP5JSONWebTokenPlugin') + + security.declareProtected(ManageUsers, 'manage_updateERP5JSONWebTokenPlugin') + def manage_updateERP5JSONWebTokenPlugin(self, RESPONSE=None): + """Edit the object""" + + self._secret = urandom(16) + + #Redirect + if RESPONSE is not None: + message = "Secret Updated" + RESPONSE.redirect('%s/manage_updateERP5JSONWebTokenPluginForm' + '?manage_tabs_message=%s' + % (self.absolute_url(), message) + ) + +InitializeClass(ERP5JSONWebTokenPlugin) diff --git a/product/ERP5Security/ERP5UserManager.py b/product/ERP5Security/ERP5UserManager.py index 29a45aa55eb..04b8c1d126b 100644 --- a/product/ERP5Security/ERP5UserManager.py +++ b/product/ERP5Security/ERP5UserManager.py @@ -132,7 +132,7 @@ class ERP5UserManager(BasePlugin): """ login = credentials.get('login') ignore_password = False - if not login: + if not login and "external_login" in credentials: # fallback to support plugins using external tools to extract login # those are not using login/password pair, they just extract login # from remote system (eg. SSL certificates) @@ -142,42 +142,50 @@ class ERP5UserManager(BasePlugin): if login == SUPER_USER: return None - @UnrestrictedMethod - def _authenticateCredentials(login, password, path, - ignore_password=False): - if not login or not (password or ignore_password): - return None + if not login and "person_relative_url" in credentials: + user = self.getPortalObject().unrestrictedTraverse( + credentials["person_relative_url"] + ) + login = user.getReference() + authentication_result = login, login - user_list = self.getUserByLogin(login) + else: + @UnrestrictedMethod + def _authenticateCredentials(login, password, path, + ignore_password=False): + if not login or not (password or ignore_password): + return None - if not user_list: - raise _AuthenticationFailure() + user_list = self.getUserByLogin(login) - user = user_list[0] + if not user_list: + raise _AuthenticationFailure() - try: + user = user_list[0] - if (ignore_password or pw_validate(user.getPassword(), password)) and \ - len(getValidAssignmentList(user)) and user \ - .getValidationState() != 'deleted': #user.getCareerRole() == 'internal': - return login, login # use same for user_id and login - finally: - pass - raise _AuthenticationFailure() - - _authenticateCredentials = CachingMethod( - _authenticateCredentials, - id='ERP5UserManager_authenticateCredentials', - cache_factory='erp5_content_short') - try: - authentication_result = _authenticateCredentials( - login=login, - password=credentials.get('password'), - path=self.getPhysicalPath(), - ignore_password=ignore_password) + try: - except _AuthenticationFailure: - authentication_result = None + if (ignore_password or pw_validate(user.getPassword(), password)) and \ + len(getValidAssignmentList(user)) and user \ + .getValidationState() != 'deleted': #user.getCareerRole() == 'internal': + return login, login # use same for user_id and login + finally: + pass + raise _AuthenticationFailure() + + _authenticateCredentials = CachingMethod( + _authenticateCredentials, + id='ERP5UserManager_authenticateCredentials', + cache_factory='erp5_content_short') + try: + authentication_result = _authenticateCredentials( + login=login, + password=credentials.get('password'), + path=self.getPhysicalPath(), + ignore_password=ignore_password) + + except _AuthenticationFailure: + authentication_result = None if not self.getPortalObject().portal_preferences.isAuthenticationPolicyEnabled(): # stop here, no authentication policy enabled @@ -185,11 +193,12 @@ class ERP5UserManager(BasePlugin): return authentication_result # authentication policy enabled, we need person object anyway - user_list = self.getUserByLogin(credentials.get('login')) - if not user_list: - # not an ERP5 Person object - return None - user = user_list[0] + if not user: + user_list = self.getUserByLogin(credentials.get('login')) + if not user_list: + # not an ERP5 Person object + return None + user = user_list[0] if authentication_result is None: # file a failed authentication attempt diff --git a/product/ERP5Security/__init__.py b/product/ERP5Security/__init__.py index 6d3516890db..3321890af74 100644 --- a/product/ERP5Security/__init__.py +++ b/product/ERP5Security/__init__.py @@ -28,6 +28,7 @@ import ERP5UserFactory import ERP5KeyAuthPlugin import ERP5ExternalAuthenticationPlugin import ERP5BearerExtractionPlugin +import ERP5JSONWebTokenPlugin import ERP5ExternalOauth2ExtractionPlugin import ERP5AccessTokenExtractionPlugin import ERP5DumbHTTPExtractionPlugin @@ -67,6 +68,7 @@ registerMultiPlugin(ERP5UserFactory.ERP5UserFactory.meta_type) registerMultiPlugin(ERP5KeyAuthPlugin.ERP5KeyAuthPlugin.meta_type) registerMultiPlugin(ERP5ExternalAuthenticationPlugin.ERP5ExternalAuthenticationPlugin.meta_type) registerMultiPlugin(ERP5BearerExtractionPlugin.ERP5BearerExtractionPlugin.meta_type) +registerMultiPlugin(ERP5JSONWebTokenPlugin.ERP5JSONWebTokenPlugin.meta_type) registerMultiPlugin(ERP5ExternalOauth2ExtractionPlugin.ERP5FacebookExtractionPlugin.meta_type) registerMultiPlugin(ERP5ExternalOauth2ExtractionPlugin.ERP5GoogleExtractionPlugin.meta_type) registerMultiPlugin(ERP5AccessTokenExtractionPlugin.ERP5AccessTokenExtractionPlugin.meta_type) @@ -137,6 +139,15 @@ def initialize(context): , icon='www/portal.gif' ) + context.registerClass( ERP5JSONWebTokenPlugin.ERP5JSONWebTokenPlugin + , permission=ManageUsers + , constructors=( + ERP5JSONWebTokenPlugin.manage_addERP5JSONWebTokenPluginForm, + ERP5JSONWebTokenPlugin.addERP5JSONWebTokenPlugin, ) + , visibility=None + , icon='www/portal.gif' + ) + context.registerClass( ERP5ExternalOauth2ExtractionPlugin.ERP5FacebookExtractionPlugin , permission=ManageUsers , constructors=( diff --git a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py new file mode 100644 index 00000000000..12fe912ac0c --- /dev/null +++ b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Copyright (c) 2016 Nexedi SA and Contributors. All Rights Reserved. +# Cédric Le Ninivin +# +# WARNING: This program as such is intended to be used by professional +# programmers who take the whole responsability of assessing all potential +# consequences resulting from its eventual inadequacies and bugs +# End users who are looking for a ready-to-use solution with commercial +# garantees and support are strongly adviced to contract a Free Software +# Service Company +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +############################################################################## + +import base64 +from Products.ERP5Type.tests.ERP5TypeTestCase import ERP5TypeTestCase +import random +import StringIO +import transaction +import unittest +from ZPublisher.HTTPRequest import HTTPRequest +from ZPublisher.HTTPResponse import HTTPResponse + +class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): + + test_id = 'test_erp5_json_web_token_plugin' + + def getBusinessTemplateList(self): + return ( + 'erp5_full_text_mroonga_catalog', + 'erp5_core_proxy_field_legacy', + 'erp5_base', + ) + + def generateNewId(self): + return str(self.portal.portal_ids.generateNewId( + id_group=('test_erp5_json_web_token_plugin_id'))) + + def afterSetUp(self): + """ + This is ran before anything, used to set the environment + """ + self.portal = self.getPortalObject() + self.new_id = self.generateNewId() + self._setupJSONWebTokenPLugin() + transaction.commit() + self.tic() + + def do_fake_request(self, request_method, headers={}): + __version__ = "0.1" + env={} + env['SERVER_NAME']='bobo.server' + env['SERVER_PORT']='80' + env['REQUEST_METHOD']=request_method + env['REMOTE_ADDR']='204.183.226.81 ' + env['REMOTE_HOST']='bobo.remote.host' + env['HTTP_USER_AGENT']='Bobo/%s' % __version__ + env['HTTP_HOST']='127.0.0.1' + env['SERVER_SOFTWARE']='Bobo/%s' % __version__ + env['SERVER_PROTOCOL']='HTTP/1.0 ' + env['HTTP_ACCEPT']='image/gif, image/x-xbitmap, image/jpeg, */* ' + env['SERVER_HOSTNAME']='bobo.server.host' + env['GATEWAY_INTERFACE']='CGI/1.1 ' + env['SCRIPT_NAME']='Main' + env.update(headers) + return HTTPRequest(StringIO.StringIO(), env, HTTPResponse()) + + def _setupJSONWebTokenPLugin(self): + pas = self.portal.acl_users + access_extraction_list = [q for q in pas.objectValues() \ + if q.meta_type == 'ERP5 JSON Web Token Plugin'] + if len(access_extraction_list) == 0: + dispacher = pas.manage_addProduct['ERP5Security'] + dispacher.addERP5JSONWebTokenPlugin(self.test_id) + getattr(pas, self.test_id).manage_activateInterfaces( + ('IExtractionPlugin', 'IAuthenticationPlugin')) + elif len(access_extraction_list) == 1: + self.test_id = access_extraction_list[0].getId() + elif len(access_extraction_list) > 1: + raise ValueError + transaction.commit() + + def _createPerson(self, new_id, password=None): + """Creates a person in person module, and returns the object, after + indexing is done. """ + person_module = self.getPersonModule() + person = person_module.newContent(portal_type='Person', + reference='TESTP-' + new_id) + if password: + person.setPassword(password) + person.newContent(portal_type = 'Assignment').open() + transaction.commit() + return person + + def test_working_authentication(self): + """ + Test the normal authentication process for JWT Plugin + Step 1: Login with password + Step 2: Login with cookies provided by the response + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.assertEquals(ret, (person.getReference(), person.getReference())) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + self.assertIsNotNone(erp5_jwt_cookie) + self.assertTrue(erp5_jwt_cookie['http_only']) + self.assertTrue(erp5_jwt_cookie['secure']) + self.assertTrue(erp5_jwt_cookie['same_site']) + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.assertEquals(ret, (person.getReference(), person.getReference())) + + def test_invalid_signature(self): + """ + Test authentication will fail if a wrong signature is provided in the token + in the same site cookie + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.assertEquals(ret, (person.getReference(), person.getReference())) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + "A" + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertIsNone(ret) + self.assertEquals(request.response.cookies['erp5_jwt']['value'], 'deleted') + + def test_origin_equal_base_url(self): + """ + Test user is authenticated if same site cookie is valid and + origin equal base url + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + origin = "https://www.example.com" + ret = self.portal.acl_users[self.test_id].authenticateCredentials({ + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + }) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + request = self.do_fake_request("GET") + request.environ["ORIGIN"] = request.get("BASE0") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + + def test_valid_cors_domain(self): + """ + Test user is authenticated if origin is in authorized cors domain list + of a valid erp5 jwt cors cookie + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + origin = "https://www.example.com" + self.REQUEST.form['new_cors_origin'] = origin + ret = self.portal.acl_users[self.test_id].authenticateCredentials({ + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + }) + response_cookie_dict = self.REQUEST.response.cookies + erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') + request = self.do_fake_request( + "GET", + { + "ORIGIN": origin, + } + ) + request.cookies['erp5_cors_jwt'] = erp5_cors_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + def test_invalid_cors_domain(self): + """ + Test user is not authenticated if origin is not in authorized cors domain + list of the erp5 jwt cors cookie + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + origin = "https://www.example.com" + origin2 = "https://www.counter-exmaple.org" + self.REQUEST.form['new_cors_origin'] = origin + ret = self.portal.acl_users[self.test_id].authenticateCredentials({ + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + }) + response_cookie_dict = self.REQUEST.response.cookies + erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') + request = self.do_fake_request( + "GET", + { + "ORIGIN": origin2, + } + ) + request.cookies['erp5_cors_jwt'] = erp5_cors_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + def test_valid_referer_domain(self): + """ + Test user is authenticated if referer is in authorized cors domain list + of a valid erp5 jwt cors cookie + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + origin = "https://www.example.com" + self.REQUEST.form['new_cors_origin'] = origin + ret = self.portal.acl_users[self.test_id].authenticateCredentials({ + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + }) + response_cookie_dict = self.REQUEST.response.cookies + erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') + request = self.do_fake_request( + "GET", + { + "REFERER": origin + "/couscous/erp5?foo=bar", + } + ) + request.cookies['erp5_cors_jwt'] = erp5_cors_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + def test_invalid_referer_domain(self): + """ + Test user is not authenticated if referer is not in authorized cors domain + list of the erp5 jwt cors cookie + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + origin = "https://www.example.com" + origin2 = "https://www.counter-exmaple.org" + self.REQUEST.form['new_cors_origin'] = origin + ret = self.portal.acl_users[self.test_id].authenticateCredentials({ + 'login': person.getReference(), + 'password': password, + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + }) + response_cookie_dict = self.REQUEST.response.cookies + erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') + request = self.do_fake_request( + "GET", + { + "REFERER": origin2 + "/couscous/erp5?foo=bar", + } + ) + request.cookies['erp5_cors_jwt'] = erp5_cors_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestERP5JSONWebTokenPlugin)) + return suite diff --git a/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt b/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt new file mode 100644 index 00000000000..fb739c06fd5 --- /dev/null +++ b/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt @@ -0,0 +1,36 @@ +

PAGE HEADER

+

FORM TITLE

+ +

Please input the configuration

+ +
+ + + + + + + + + + + + +
+
+ Id +
+
+ +
+
+ Title +
+
+ +
+
+
+ +

PAGE FOOTER

diff --git a/product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt b/product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt new file mode 100644 index 00000000000..748cf6c9156 --- /dev/null +++ b/product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt @@ -0,0 +1,24 @@ +

PAGE HEADER

+

TABS

+

FORM TITLE

+ +

Press Update to update Plugin secret. +Please note it will invalidate all token issued with the former secret and may +disconnect a large part of your users.

+ +
+ + + + + + + +
+ +
+ +
+ +

PAGE FOOTER

-- 2.30.9 From 696d59692d9ab25b61b8e97377e4d1bac9644a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Thu, 29 Sep 2016 10:16:38 +0000 Subject: [PATCH 02/14] ERP5Site: Use JWT for Extraction and Authentication --- product/ERP5/ERP5Site.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/product/ERP5/ERP5Site.py b/product/ERP5/ERP5Site.py index 680ceefd661..9386e20bbaa 100644 --- a/product/ERP5/ERP5Site.py +++ b/product/ERP5/ERP5Site.py @@ -2147,18 +2147,17 @@ class ERP5Generator(PortalGenerator): erp5security_dispatcher.addERP5GroupManager('erp5_groups') erp5security_dispatcher.addERP5RoleManager('erp5_roles') erp5security_dispatcher.addERP5UserFactory('erp5_user_factory') - erp5security_dispatcher.addERP5DumbHTTPExtractionPlugin( - 'erp5_dumb_http_extraction') + erp5security_dispatcher.addERP5JSONWebTokenPlugin('erp5_jwt_extraction') # Register ERP5UserManager Interface p.acl_users.erp5_users.manage_activateInterfaces( - ('IAuthenticationPlugin', - 'IUserEnumerationPlugin',)) + ('IUserEnumerationPlugin',)) p.acl_users.erp5_groups.manage_activateInterfaces(('IGroupsPlugin',)) p.acl_users.erp5_roles.manage_activateInterfaces(('IRolesPlugin',)) p.acl_users.erp5_user_factory.manage_activateInterfaces( ('IUserFactoryPlugin',)) - p.acl_users.erp5_dumb_http_extraction.manage_activateInterfaces( - ('IExtractionPlugin',)) + p.acl_users.erp5_jwt_extraction.manage_activateInterfaces( + ('IExtractionPlugin', + 'IAuthenticationPlugin')) def setupPermissions(self, p): permission_dict = { -- 2.30.9 From 3ae54663bfdd2fcf4c7af7ba48a70b20b454a296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Thu, 22 Sep 2016 15:12:46 +0000 Subject: [PATCH 03/14] WIP ERP5Security: JWT improvments --- product/ERP5Security/ERP5JSONWebTokenPlugin.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py index 189e45c19cc..ce39744cacb 100644 --- a/product/ERP5Security/ERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -99,7 +99,7 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): LOG('ERP5JSONWebTokenPlugin', INFO, 'No jwt module, install pyjwt package. ' 'Authentication disabled.') - return DumbHTTPExtractor().extractCredentials(request) + return None creds = {} @@ -142,6 +142,7 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): person_relative_url = data["sub"].encode() user = self.getPortalObject().unrestrictedTraverse(person_relative_url) + # Activate password to have the real tid user.password._p_activate() if data["ptid"] == u64(user.password._p_serial) \ and (not origin or data and \ @@ -173,13 +174,9 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): 'Authentication disabled.') return authentication_result - if "person_relative_url" not in credentials: - user = self.getUserByLogin(authentication_result[0])[0] - else: - user = self.getPortalObject().unrestrictedTraverse( - credentials["person_relative_url"] - ) + user = self.getUserByLogin(authentication_result[0])[0] + # Activate password to have the real tid user.password._p_activate() data = { "sub": user.getRelativeUrl(), @@ -226,7 +223,7 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): # Expire default cookie set by default # (even with plugin deactivated) - request.response.expireCookie('__ac') + # request.response.expireCookie('__ac') return authentication_result -- 2.30.9 From 04006d3596ad87829b33e0437dbf6d03e59d71a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Fri, 7 Oct 2016 11:24:54 -0400 Subject: [PATCH 04/14] ERP5Security: Add optionnal expiration time on JSON Web Token --- .../ERP5Security/ERP5JSONWebTokenPlugin.py | 30 ++++++- .../tests/testERP5JSONWebTokenPlugin.py | 90 +++++++++++++++++++ ...ERP5JSONWebTokenPluginExtpirationDelay.zpt | 30 +++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py index ce39744cacb..a3ad4333ff7 100644 --- a/product/ERP5Security/ERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -26,7 +26,7 @@ # ############################################################################## -from datetime import datetime +from datetime import datetime, timedelta from urlparse import urlparse from os import urandom from zLOG import LOG, INFO, ERROR @@ -80,6 +80,8 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): manage_options = ( ( { 'label': 'Update Secret', 'action': 'manage_updateERP5JSONWebTokenPluginForm', } , + { 'label': 'Set Expiration Time', + 'action': 'manage_setERP5JSONWebTokenPluginExtpirationDelayForm', } ) + BasePlugin.manage_options ) @@ -88,6 +90,7 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): def __init__(self, *args, **kw): super(ERP5JSONWebTokenPlugin, self).__init__(*args, **kw) self.manage_updateERP5JSONWebTokenPlugin() + self.manage_setERP5JSONWebTokenPluginExtpirationDelay(0) #################################### #ILoginPasswordHostExtractionPlugin# @@ -189,6 +192,9 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): "http_only": True, } + if self.expiration_delay: + data["exp"] = datetime.utcnow() + timedelta(seconds=self.expiration_delay) + request = self.REQUEST new_cors_origin = request.form.get('new_cors_origin') @@ -251,4 +257,26 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): % (self.absolute_url(), message) ) + manage_setERP5JSONWebTokenPluginExtpirationDelayForm = PageTemplateFile( + 'www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay', + globals(), + __name__='manage_setERP5JSONWebTokenPluginExtpirationDelayForm') + + security.declareProtected(ManageUsers, 'manage_setERP5JSONWebTokenPluginExtpirationDelay') + def manage_setERP5JSONWebTokenPluginExtpirationDelay( + self, + expiration_delay, + RESPONSE=None): + """Edit the object""" + + self.expiration_delay = int(float(expiration_delay)) + + #Redirect + if RESPONSE is not None: + message = "Expiration Delay Set" + RESPONSE.redirect('%s/manage_setERP5JSONWebTokenPluginExtpirationDelayForm' + '?manage_tabs_message=%s' + % (self.absolute_url(), message) + ) + InitializeClass(ERP5JSONWebTokenPlugin) diff --git a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py index 12fe912ac0c..5e542a68356 100644 --- a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py @@ -28,10 +28,12 @@ ############################################################################## import base64 +import jwt from Products.ERP5Type.tests.ERP5TypeTestCase import ERP5TypeTestCase import random import StringIO import transaction +import time import unittest from ZPublisher.HTTPRequest import HTTPRequest from ZPublisher.HTTPResponse import HTTPResponse @@ -368,6 +370,94 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): } ) + def test_expiration_delay(self): + """ + Test an expiration delay. + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + self.portal.acl_users[self.test_id].manage_setERP5JSONWebTokenPluginExtpirationDelay(2) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + time.sleep(3) + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertIsNone(ret) + + def test_expiration_delay_deactivated_by_default(self): + """ + Test an expiration delay is deactivated by default + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) + self.assertTrue("exp" not in decoded_value) + + def test_expiration_delay_deactivated_when_set_to_0(self): + """ + Test an expiration delay is deactivated by default + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + self.portal.acl_users[self.test_id].manage_setERP5JSONWebTokenPluginExtpirationDelay(2) + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) + self.assertTrue("exp" in decoded_value) + self.portal.acl_users[self.test_id].manage_setERP5JSONWebTokenPluginExtpirationDelay(0) + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) + self.assertTrue("exp" not in decoded_value) def test_suite(): suite = unittest.TestSuite() diff --git a/product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt b/product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt new file mode 100644 index 00000000000..3b1c8f5b8d1 --- /dev/null +++ b/product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt @@ -0,0 +1,30 @@ +

PAGE HEADER

+

TABS

+

FORM TITLE

+ +

Please input the expiration delay in seconds of the token. + The value 0 will deactivate it.

+ +
+ + + + + + + + + + + +
Expiration delay in seconds of the Token. Deactivated if set to 0 + +
+ +
+ +
+ +

PAGE FOOTER

-- 2.30.9 From 1019745468e3269134941ba9cb57b8680e95e70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Fri, 7 Oct 2016 12:07:23 -0400 Subject: [PATCH 05/14] encrypted_password: Add serializePassword method --- product/ERP5/mixin/encrypted_password.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/product/ERP5/mixin/encrypted_password.py b/product/ERP5/mixin/encrypted_password.py index dd4821f80bc..580576965c9 100644 --- a/product/ERP5/mixin/encrypted_password.py +++ b/product/ERP5/mixin/encrypted_password.py @@ -128,4 +128,10 @@ class EncryptedPasswordMixin: password = default_password return password + security.declarePublic('serializePassword') + def serializePassword(self): + """ + """ + self.password._p_changed = 1 + InitializeClass(EncryptedPasswordMixin) -- 2.30.9 From c71e2b431b412bf33501dedf10af644edca2f08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Fri, 7 Oct 2016 12:07:35 -0400 Subject: [PATCH 06/14] ERP5Security: Add Test that updating person password tid invalidate JWT --- .../tests/testERP5JSONWebTokenPlugin.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py index 5e542a68356..82ec3b2692c 100644 --- a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py @@ -459,6 +459,48 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) self.assertTrue("exp" not in decoded_value) + def test_update_password_tid_invalidate_token(self): + """ + Test update Password TID invalide JWT + """ + password = "%s" % random.random() + person = self.person = self._createPerson( + self.new_id, + password=password, + ) + self.tic() + self.portal.acl_users[self.test_id].manage_setERP5JSONWebTokenPluginExtpirationDelay(2) + request = self.do_fake_request( + "GET", + {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( + person.getReference(), password))}) + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + response_cookie_dict = self.REQUEST.response.cookies + erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'person_relative_url': person.getRelativeUrl(), + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + person.serializePassword() + self.commit() + request = self.do_fake_request("GET") + request.cookies['erp5_jwt'] = erp5_jwt_cookie['value'] + ret = self.portal.acl_users[self.test_id].extractCredentials(request) + self.assertEquals(ret, + { + 'remote_host': 'bobo.remote.host', + 'remote_address': '204.183.226.81 ' + } + ) + + def test_suite(): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(TestERP5JSONWebTokenPlugin)) -- 2.30.9 From 02fb5fe7f7cd9b8aee12e562e93e68ef9b3b7b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Wed, 12 Oct 2016 14:11:12 +0000 Subject: [PATCH 07/14] erp5_base: Add invalidate token action on a person --- .../portal_types/Person/invalidate_token.xml | 81 +++++++++++++++++++ bt5/erp5_base/bt/template_action_path_list | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 bt5/erp5_base/ActionTemplateItem/portal_types/Person/invalidate_token.xml diff --git a/bt5/erp5_base/ActionTemplateItem/portal_types/Person/invalidate_token.xml b/bt5/erp5_base/ActionTemplateItem/portal_types/Person/invalidate_token.xml new file mode 100644 index 00000000000..20d11fc65a5 --- /dev/null +++ b/bt5/erp5_base/ActionTemplateItem/portal_types/Person/invalidate_token.xml @@ -0,0 +1,81 @@ + + + + + + + + + + action + + AAAAAAAAAAI= + + + + categories + + + action_type/object_action + + + + + category + object_action + + + condition + + + + description + + + + + + icon + + + + id + invalidate_token + + + permissions + + + Manage portal + + + + + priority + 20.0 + + + title + Invalidate User Token + + + visible + 1 + + + + + + + + + + + + text + string:${object_url}/serializePassword + + + + + diff --git a/bt5/erp5_base/bt/template_action_path_list b/bt5/erp5_base/bt/template_action_path_list index 86f32110dbe..006d5fc720c 100644 --- a/bt5/erp5_base/bt/template_action_path_list +++ b/bt5/erp5_base/bt/template_action_path_list @@ -26,7 +26,6 @@ Delivery Causality Assignment Movement Group | view Delivery Tool | view Delivery Type | action_view Delivery Type | copy_roles -Delivery Type | delivery_view Delivery Type | jump_property_sheets Delivery Type | role_view Delivery Type | translation_view @@ -83,6 +82,7 @@ Person | assignment Person | details Person | document_view Person | financial_view +Person | invalidate_token Person | person_detailed_report Person | terminate_career Person | view -- 2.30.9 From 5eb3606612d321bc099fa2fde2520d00f505936d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 8 Nov 2016 04:35:25 -0500 Subject: [PATCH 08/14] ERP5Security: JWT and DumbExtractor support reset and update Credentials --- .../ERP5DumbHTTPExtractionPlugin.py | 46 +++++++++++++++- .../ERP5Security/ERP5JSONWebTokenPlugin.py | 53 ++++++++++--------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py b/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py index efc633de8df..777675523e7 100644 --- a/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py +++ b/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py @@ -27,6 +27,8 @@ # ############################################################################## +from base64 import standard_b64encode + from Products.ERP5Type.Globals import InitializeClass from AccessControl import ClassSecurityInfo @@ -51,12 +53,52 @@ class ERP5DumbHTTPExtractionPlugin(BasePlugin): #Register value self._setId(id) self.title = title + self.cookie_name = "__ac" security.declarePrivate('extractCredentials') @UnrestrictedMethod def extractCredentials(self, request): return DumbHTTPExtractor().extractCredentials(request); + ################################ + # ICredentialsUpdatePlugin # + ################################ + security.declarePrivate('updateCredentials') + def updateCredentials(self, request, response, login, password): + """ Respond to change of credentials""" + kw = {} + portal = self.getPortalObject() + expire_interval = portal.portal_preferences.getPreferredMaxUserInactivityDuration() + if expire_interval in ('', None): + ac_renew = float('inf') + else: + expire_interval /= 86400. # seconds -> days + now = DateTime() + kw['expires'] = (now + expire_interval).toZone('GMT').rfc822() + ac_renew = (now + expire_interval / 2).millis() + portal.portal_sessions[ + portal.Base_getAutoLogoutSessionKey(username=login) + ]['ac_renew'] = ac_renew + response.setCookie( + name="__ac", + value=standard_b64encode('%s:%s' % (login, password)), + path='/', + secure=getattr(portal, 'REQUEST', {}).get('SERVER_URL', '').startswith('https:'), + http_only=True, + **kw + ) + + ################################ + # ICredentialsResetPlugin # + ################################ + security.declarePrivate( 'resetCredentials' ) + def resetCredentials( self, request, response ): + + """ Logout + """ + response.expireCookie("__ac", path="/") + + #Form for new plugin in ZMI manage_addERP5DumbHTTPExtractionPluginForm = PageTemplateFile( 'www/ERP5Security_addERP5DumbHTTPExtractionPlugin', globals(), @@ -77,6 +119,8 @@ def addERP5DumbHTTPExtractionPlugin(dispatcher, id, title=None, REQUEST=None): #List implementation of class classImplements(ERP5DumbHTTPExtractionPlugin, - plugins.ILoginPasswordHostExtractionPlugin + plugins.ILoginPasswordHostExtractionPlugin, + plugins.ICredentialsResetPlugin, + plugins.ICredentialsUpdatePlugin, ) InitializeClass(ERP5DumbHTTPExtractionPlugin) diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py index a3ad4333ff7..167701482d4 100644 --- a/product/ERP5Security/ERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -37,7 +37,7 @@ from AccessControl import ClassSecurityInfo from Products.PageTemplates.PageTemplateFile import PageTemplateFile from Products.PluggableAuthService.interfaces import plugins from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin -from Products.ERP5Security.ERP5UserManager import ERP5UserManager +from Products.ERP5Security.ERP5UserManager import getUserByLogin from Products.PluggableAuthService.permissions import ManageUsers from Products.PluggableAuthService.PluggableAuthService import DumbHTTPExtractor from ZODB.utils import u64 @@ -69,8 +69,10 @@ def addERP5JSONWebTokenPlugin(dispatcher, id, title=None, REQUEST=None): @implementer( plugins.ILoginPasswordHostExtractionPlugin, + plugins.ICredentialsResetPlugin, + plugins.ICredentialsUpdatePlugin, ) -class ERP5JSONWebTokenPlugin(ERP5UserManager): +class ERP5JSONWebTokenPlugin(BasePlugin): meta_type = "ERP5 JSON Web Token Plugin" security = ClassSecurityInfo() @@ -88,7 +90,6 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): def __init__(self, *args, **kw): - super(ERP5JSONWebTokenPlugin, self).__init__(*args, **kw) self.manage_updateERP5JSONWebTokenPlugin() self.manage_setERP5JSONWebTokenPluginExtpirationDelay(0) @@ -138,8 +139,7 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): jwt.InvalidTokenError, jwt.DecodeError, ): - request.response.expireCookie(self.same_site_cookie, path='/') - request.response.expireCookie(self.cors_cookie, path='/') + self.resetCredentials(request, request.response) return None person_relative_url = data["sub"].encode() @@ -159,25 +159,23 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): creds['remote_address'] = request.get('REMOTE_ADDR', '') return creds - # - # IAuthenticationPlugin implementation - # - security.declarePrivate( 'authenticateCredentials' ) - def authenticateCredentials(self, credentials): - authentication_result = super( - ERP5JSONWebTokenPlugin, - self - ).authenticateCredentials(credentials) - - # In case the password is present in the request, the token is updated - if authentication_result is not None and "password" in credentials: + ################################ + # ICredentialsUpdatePlugin # + ################################ + security.declarePrivate('updateCredentials') + def updateCredentials(self, request, response, login, new_password): + """ Respond to change of credentials""" + + #Update credential for key auth or standard of. + #Remove conflict between both systems + if login is not None: if jwt is None: LOG('ERP5JSONWebTokenPlugin', INFO, 'No jwt module, install pyjwt package. ' 'Authentication disabled.') return authentication_result - user = self.getUserByLogin(authentication_result[0])[0] + user = getUserByLogin(self.getPortalObject(), login)[0] # Activate password to have the real tid user.password._p_activate() @@ -221,17 +219,24 @@ class ERP5JSONWebTokenPlugin(ERP5UserManager): cookie = self.same_site_cookie cookie_parameters["same_site"] = "Lax" - request.response.setCookie( + response.setCookie( cookie, jwt.encode(data, self._secret), **cookie_parameters ) - # Expire default cookie set by default - # (even with plugin deactivated) - # request.response.expireCookie('__ac') - - return authentication_result + ################################ + # ICredentialsResetPlugin # + ################################ + security.declarePrivate( 'resetCredentials' ) + def resetCredentials( self, request, response ): + + """ Logout + """ + for cookie in (self.same_site_cookie, + self.cors_cookie): + if request.cookies.get(cookie) is not None: + response.expireCookie(cookie, path="/") ################################ # Properties for ZMI managment # -- 2.30.9 From ef35e421d9d26dcfed4da48f57f05878bd923b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 8 Nov 2016 09:50:44 +0000 Subject: [PATCH 09/14] erp5_core: ERP5 Auto Logout use PAS to set and revoke credential --- .../portal_skins/erp5_auto_logout/logout.py | 2 +- .../erp5_auto_logout/setAuthCookie.py | 30 ++++--------------- .../erp5_auto_logout/setAuthCookie.xml | 2 +- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/logout.py b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/logout.py index 562258fbf90..5cc5338906f 100644 --- a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/logout.py +++ b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/logout.py @@ -8,5 +8,5 @@ portal.portal_sessions.manage_delObjects( REQUEST = portal.REQUEST if REQUEST.has_key('portal_skin'): portal.portal_skins.clearSkinCookie() -REQUEST.RESPONSE.expireCookie('__ac', path='/') +portal.acl_users.logout(REQUEST) return REQUEST.RESPONSE.redirect(REQUEST.URL1 + '/logged_out') diff --git a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.py b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.py index 8e0d73867eb..51e298581d6 100644 --- a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.py +++ b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.py @@ -1,25 +1,7 @@ +from base64 import standard_b64encode, standard_b64decode +if cookie_value is not None and login is None: + from urllib import unquote + login, password = unquote(cookie_value).decode('base64').split(':', 1) + portal = context.getPortalObject() -kw = {} -expire_interval = portal.portal_preferences.getPreferredMaxUserInactivityDuration() -if expire_interval in ('', None): - ac_renew = float('inf') -else: - expire_interval /= 86400. # seconds -> days - now = DateTime() - kw['expires'] = (now + expire_interval).toZone('GMT').rfc822() - ac_renew = (now + expire_interval / 2).millis() -portal.portal_sessions[ - portal.Base_getAutoLogoutSessionKey( - username=portal.Base_getUsernameFromAuthenticationCookie( - cookie_value, - ) - ) -]['ac_renew'] = ac_renew -resp.setCookie( - name=cookie_name, - value=cookie_value, - path='/', - secure=getattr(portal, 'REQUEST', {}).get('SERVER_URL', '').startswith('https:'), - http_only=True, - **kw -) +portal.acl_users.updateCredentials(context.REQUEST, resp, login, password) diff --git a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.xml b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.xml index 4d5d0667263..923b65617d5 100644 --- a/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.xml +++ b/product/ERP5/bootstrap/erp5_core/SkinTemplateItem/portal_skins/erp5_auto_logout/setAuthCookie.xml @@ -170,7 +170,7 @@ _params - resp, cookie_name, cookie_value + resp, cookie_name, cookie_value=None, login=None, password=None id -- 2.30.9 From d1c77b2f666139edd4b93392e5e4c8abf272975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 8 Nov 2016 04:58:24 -0500 Subject: [PATCH 10/14] ERP5Site: update default Plugins set up --- product/ERP5/ERP5Site.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/product/ERP5/ERP5Site.py b/product/ERP5/ERP5Site.py index 9386e20bbaa..cc7bf189a3e 100644 --- a/product/ERP5/ERP5Site.py +++ b/product/ERP5/ERP5Site.py @@ -2150,14 +2150,16 @@ class ERP5Generator(PortalGenerator): erp5security_dispatcher.addERP5JSONWebTokenPlugin('erp5_jwt_extraction') # Register ERP5UserManager Interface p.acl_users.erp5_users.manage_activateInterfaces( - ('IUserEnumerationPlugin',)) + ('IAuthenticationPlugin', + 'IUserEnumerationPlugin',)) p.acl_users.erp5_groups.manage_activateInterfaces(('IGroupsPlugin',)) p.acl_users.erp5_roles.manage_activateInterfaces(('IRolesPlugin',)) p.acl_users.erp5_user_factory.manage_activateInterfaces( ('IUserFactoryPlugin',)) p.acl_users.erp5_jwt_extraction.manage_activateInterfaces( ('IExtractionPlugin', - 'IAuthenticationPlugin')) + 'ICredentialsResetPlugin', + 'ICredentialsUpdatePlugin,')) def setupPermissions(self, p): permission_dict = { -- 2.30.9 From e33b01fccc402bde811ec6152030588d3995d78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 8 Nov 2016 09:11:30 -0500 Subject: [PATCH 11/14] ERP5Site / ERP5Security: Update / Fix --- product/ERP5/ERP5Site.py | 2 +- product/ERP5Security/ERP5JSONWebTokenPlugin.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/product/ERP5/ERP5Site.py b/product/ERP5/ERP5Site.py index cc7bf189a3e..44802eb9025 100644 --- a/product/ERP5/ERP5Site.py +++ b/product/ERP5/ERP5Site.py @@ -2159,7 +2159,7 @@ class ERP5Generator(PortalGenerator): p.acl_users.erp5_jwt_extraction.manage_activateInterfaces( ('IExtractionPlugin', 'ICredentialsResetPlugin', - 'ICredentialsUpdatePlugin,')) + 'ICredentialsUpdatePlugin',)) def setupPermissions(self, p): permission_dict = { diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py index 167701482d4..785a4b63a14 100644 --- a/product/ERP5Security/ERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -89,7 +89,9 @@ class ERP5JSONWebTokenPlugin(BasePlugin): ) - def __init__(self, *args, **kw): + def __init__(self, id, title=None, *args, **kw): + self._setId(id) + self.title = title self.manage_updateERP5JSONWebTokenPlugin() self.manage_setERP5JSONWebTokenPluginExtpirationDelay(0) -- 2.30.9 From 92bb8d7d8a6c217ebad2b0ae6a2a8c269e2e6f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Tue, 8 Nov 2016 09:13:39 -0500 Subject: [PATCH 12/14] ERP5Security: Update JWT Tests --- .../tests/testERP5JSONWebTokenPlugin.py | 113 ++++++++++++------ 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py index 82ec3b2692c..24d40d77f14 100644 --- a/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py @@ -134,8 +134,14 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): 'remote_address': '204.183.226.81 ' } ) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + ret = self.portal.acl_users["erp5_users"].authenticateCredentials(ret) self.assertEquals(ret, (person.getReference(), person.getReference())) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + ret[0], + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') self.assertIsNotNone(erp5_jwt_cookie) @@ -152,7 +158,7 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): 'remote_address': '204.183.226.81 ' } ) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + ret = self.portal.acl_users["erp5_users"].authenticateCredentials(ret) self.assertEquals(ret, (person.getReference(), person.getReference())) def test_invalid_signature(self): @@ -179,8 +185,14 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): 'remote_address': '204.183.226.81 ' } ) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + ret = self.portal.acl_users["erp5_users"].authenticateCredentials(ret) self.assertEquals(ret, (person.getReference(), person.getReference())) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + ret[0], + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') request = self.do_fake_request("GET") @@ -201,12 +213,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): ) self.tic() origin = "https://www.example.com" - ret = self.portal.acl_users[self.test_id].authenticateCredentials({ - 'login': person.getReference(), - 'password': password, - 'remote_host': 'bobo.remote.host', - 'remote_address': '204.183.226.81 ' - }) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') request = self.do_fake_request("GET") @@ -235,12 +247,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): self.tic() origin = "https://www.example.com" self.REQUEST.form['new_cors_origin'] = origin - ret = self.portal.acl_users[self.test_id].authenticateCredentials({ - 'login': person.getReference(), - 'password': password, - 'remote_host': 'bobo.remote.host', - 'remote_address': '204.183.226.81 ' - }) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') request = self.do_fake_request( @@ -273,12 +285,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): origin = "https://www.example.com" origin2 = "https://www.counter-exmaple.org" self.REQUEST.form['new_cors_origin'] = origin - ret = self.portal.acl_users[self.test_id].authenticateCredentials({ - 'login': person.getReference(), - 'password': password, - 'remote_host': 'bobo.remote.host', - 'remote_address': '204.183.226.81 ' - }) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') request = self.do_fake_request( @@ -309,12 +321,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): self.tic() origin = "https://www.example.com" self.REQUEST.form['new_cors_origin'] = origin - ret = self.portal.acl_users[self.test_id].authenticateCredentials({ - 'login': person.getReference(), - 'password': password, - 'remote_host': 'bobo.remote.host', - 'remote_address': '204.183.226.81 ' - }) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') request = self.do_fake_request( @@ -347,12 +359,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): origin = "https://www.example.com" origin2 = "https://www.counter-exmaple.org" self.REQUEST.form['new_cors_origin'] = origin - ret = self.portal.acl_users[self.test_id].authenticateCredentials({ - 'login': person.getReference(), - 'password': password, - 'remote_host': 'bobo.remote.host', - 'remote_address': '204.183.226.81 ' - }) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_cors_jwt_cookie = response_cookie_dict.get('erp5_cors_jwt') request = self.do_fake_request( @@ -386,7 +398,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): person.getReference(), password))}) self.portal.acl_users[self.test_id].manage_setERP5JSONWebTokenPluginExtpirationDelay(2) ret = self.portal.acl_users[self.test_id].extractCredentials(request) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') request = self.do_fake_request("GET") @@ -420,7 +437,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( person.getReference(), password))}) ret = self.portal.acl_users[self.test_id].extractCredentials(request) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) @@ -442,7 +464,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( person.getReference(), password))}) ret = self.portal.acl_users[self.test_id].extractCredentials(request) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) @@ -453,7 +480,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( person.getReference(), password))}) ret = self.portal.acl_users[self.test_id].extractCredentials(request) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') decoded_value = jwt.decode(erp5_jwt_cookie["value"], verify=False) @@ -475,7 +507,12 @@ class TestERP5JSONWebTokenPlugin(ERP5TypeTestCase): {"HTTP_AUTHORIZATION": "Basic " + base64.b64encode("%s:%s" % ( person.getReference(), password))}) ret = self.portal.acl_users[self.test_id].extractCredentials(request) - ret = self.portal.acl_users[self.test_id].authenticateCredentials(ret) + self.portal.acl_users[self.test_id].updateCredentials( + self.REQUEST, + self.REQUEST.response, + person.getReference(), + password + ) response_cookie_dict = self.REQUEST.response.cookies erp5_jwt_cookie = response_cookie_dict.get('erp5_jwt') request = self.do_fake_request("GET") -- 2.30.9 From 06f2139999c2474fc1e1426391f8bcb92da88906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Thu, 17 Nov 2016 10:25:03 +0000 Subject: [PATCH 13/14] erp5_web_renderjs_ui: Update login form to authorised CORS --- .../web_site_module/renderjs_runner/hateoas.xml | 2 +- .../portal_skins/erp5_web_renderjs_ui/login_form.zpt | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_site_module/renderjs_runner/hateoas.xml b/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_site_module/renderjs_runner/hateoas.xml index ec1c8dc52c1..8ee8dce75e4 100644 --- a/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_site_module/renderjs_runner/hateoas.xml +++ b/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_site_module/renderjs_runner/hateoas.xml @@ -201,7 +201,7 @@ configuration_login - connection/login_form{?came_from} + connection/login_form{?came_from,cors_origin} configuration_logout diff --git a/bt5/erp5_web_renderjs_ui/SkinTemplateItem/portal_skins/erp5_web_renderjs_ui/login_form.zpt b/bt5/erp5_web_renderjs_ui/SkinTemplateItem/portal_skins/erp5_web_renderjs_ui/login_form.zpt index 1219e320209..e2227c9898c 100644 --- a/bt5/erp5_web_renderjs_ui/SkinTemplateItem/portal_skins/erp5_web_renderjs_ui/login_form.zpt +++ b/bt5/erp5_web_renderjs_ui/SkinTemplateItem/portal_skins/erp5_web_renderjs_ui/login_form.zpt @@ -33,6 +33,17 @@
+
+

+ Please authenticate to allow this application to access your data in ERP5 +

+
+
+ +
+
-- 2.30.9 From 35bffe85fee651c070007922552605272e308152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Le=20Ninivin?= Date: Thu, 17 Nov 2016 11:37:58 -0500 Subject: [PATCH 14/14] ERP5Security: JWT always set CORS Headers --- .../ERP5Security/ERP5JSONWebTokenPlugin.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/product/ERP5Security/ERP5JSONWebTokenPlugin.py b/product/ERP5Security/ERP5JSONWebTokenPlugin.py index 785a4b63a14..4878b693cbd 100644 --- a/product/ERP5Security/ERP5JSONWebTokenPlugin.py +++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py @@ -113,8 +113,9 @@ class ERP5JSONWebTokenPlugin(BasePlugin): if login_pw is not None: creds[ 'login' ], creds[ 'password' ] = login_pw else: - # SameSite Policy is implemented serverside origin = request.getHeader("Origin", None) + + # SameSite Policy is implemented serverside if origin is None: referer_url = request.getHeader("Referer", None) if referer_url is not None: @@ -127,6 +128,22 @@ class ERP5JSONWebTokenPlugin(BasePlugin): cookie = self.same_site_cookie origin = None else: + # Always allow CORS when credentials are not in the request + request.response.setHeader("Access-Control-Allow-Credentials", "true") + request.response.setHeader( + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept" + ) + request.response.setHeader( + "Access-Control-Allow-Methods", + "GET, OPTIONS, HEAD, DELETE, PUT, POST" + ) + request.response.setHeader("Access-Control-Allow-Origin", origin) + request.response.setHeader( + "Access-Control-Expose-Headers", + "Content-Type, Content-Length, WWW-Authenticate, X-Location" + ) + # For CORS use a different token cookie = self.cors_cookie token = request.cookies.get(cookie) -- 2.30.9