diff --git a/product/ERP5/ERP5Site.py b/product/ERP5/ERP5Site.py
index 680ceefd661cc6652e77e87547e03837076b305c..44802eb9025f91449bff32453b916d95c466aac5 100644
--- a/product/ERP5/ERP5Site.py
+++ b/product/ERP5/ERP5Site.py
@@ -2147,18 +2147,19 @@ 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',
+ 'ICredentialsResetPlugin',
+ 'ICredentialsUpdatePlugin',))
def setupPermissions(self, p):
permission_dict = {
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 562258fbf909c4f2347fc17851ad92b44c6fdf95..5cc5338906f661a6f16909b844ca5aa38dc57ae4 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 8e0d73867eb29366b489e05c017c5cae35b4db1c..51e298581d6364707b69c6af61b09740917ae09a 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 4d5d06672633b376559ad0678258b8620a60c98e..923b65617d5e0d65810b9bd0f98ac43225f311ab 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
diff --git a/product/ERP5/mixin/encrypted_password.py b/product/ERP5/mixin/encrypted_password.py
index dd4821f80bc6fbefd59f629bbb59e35a422123f9..580576965c9b3663a7b8b41ca1338cf3d1d22318 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)
diff --git a/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py b/product/ERP5Security/ERP5DumbHTTPExtractionPlugin.py
index efc633de8df493db9c82770799668ad054f575c7..777675523e7b9ac508418b4bfd360a354fdd805e 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
new file mode 100644
index 0000000000000000000000000000000000000000..4878b693cbda689d30fd008fa3b79e2e577b4a4e
--- /dev/null
+++ b/product/ERP5Security/ERP5JSONWebTokenPlugin.py
@@ -0,0 +1,306 @@
+# -*- 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, timedelta
+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 getUserByLogin
+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,
+ plugins.ICredentialsResetPlugin,
+ plugins.ICredentialsUpdatePlugin,
+ )
+class ERP5JSONWebTokenPlugin(BasePlugin):
+
+ 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', }
+ ,
+ { 'label': 'Set Expiration Time',
+ 'action': 'manage_setERP5JSONWebTokenPluginExtpirationDelayForm', }
+ )
+ + BasePlugin.manage_options
+ )
+
+
+ def __init__(self, id, title=None, *args, **kw):
+ self._setId(id)
+ self.title = title
+ self.manage_updateERP5JSONWebTokenPlugin()
+ self.manage_setERP5JSONWebTokenPluginExtpirationDelay(0)
+
+ ####################################
+ #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 None
+
+ creds = {}
+
+ login_pw = request._authUserPW()
+ if login_pw is not None:
+ creds[ 'login' ], creds[ 'password' ] = login_pw
+ else:
+ 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:
+ # 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:
+ # 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)
+ if not token:
+ return None
+
+ try:
+ data = jwt.decode(token, self._secret)
+ except (
+ jwt.InvalidIssuedAtError,
+ jwt.ExpiredSignatureError,
+ jwt.InvalidTokenError,
+ jwt.DecodeError,
+ ):
+ self.resetCredentials(request, request.response)
+ return None
+
+ 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 \
+ 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
+
+ ################################
+ # 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 = getUserByLogin(self.getPortalObject(), login)[0]
+
+ # Activate password to have the real tid
+ 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,
+ }
+
+ 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')
+ 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"
+
+ response.setCookie(
+ cookie,
+ jwt.encode(data, self._secret),
+ **cookie_parameters
+ )
+
+ ################################
+ # 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 #
+ ################################
+
+ #'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)
+ )
+
+ 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/ERP5UserManager.py b/product/ERP5Security/ERP5UserManager.py
index 29a45aa55eb0b6c268e87f861d2424514f3fb3a6..04b8c1d126b0cc874a272a4ff42e655998375890 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 6d3516890db8686cc73fc2ab98e5943eae025f76..3321890af743525216e181bc48bfac875dfa437c 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 0000000000000000000000000000000000000000..24d40d77f14fd71dd0e25720eb3d02ad8e9a844b
--- /dev/null
+++ b/product/ERP5Security/tests/testERP5JSONWebTokenPlugin.py
@@ -0,0 +1,544 @@
+# -*- 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
+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
+
+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["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)
+ 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["erp5_users"].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["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")
+ 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"
+ 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")
+ 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
+ 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(
+ "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
+ 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(
+ "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
+ 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(
+ "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
+ 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(
+ "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_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)
+ 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")
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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")
+ 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))
+ return suite
diff --git a/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt b/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt
new file mode 100644
index 0000000000000000000000000000000000000000..fb739c06fd52aef888e4206ab99adb75cde069bd
--- /dev/null
+++ b/product/ERP5Security/www/ERP5Security_addERP5JSONWebTokenPlugin.zpt
@@ -0,0 +1,36 @@
+
PAGE HEADER
+FORM TITLE
+
+Please input the configuration
+
+
+
+PAGE FOOTER
diff --git a/product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt b/product/ERP5Security/www/ERP5Security_setERP5JSONWebTokenPluginExtpirationDelay.zpt
new file mode 100644
index 0000000000000000000000000000000000000000..3b1c8f5b8d1542205ed54a2b1d4a0fdb5c0d17ec
--- /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.
+
+
+
+PAGE FOOTER
diff --git a/product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt b/product/ERP5Security/www/ERP5Security_updateERP5JSONWebTokenPlugin.zpt
new file mode 100644
index 0000000000000000000000000000000000000000..748cf6c9156749b2ad929d29fdf31d7e8a684d79
--- /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