Commit 8e92345f authored by Cédric Le Ninivin's avatar Cédric Le Ninivin

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
parent 544c68e0
# -*- 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)
......@@ -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
......
......@@ -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=(
......
This diff is collapsed.
<h1 tal:replace="structure context/manage_page_header">PAGE HEADER</h1>
<h2 tal:define="form_title string:Add ERP5 JSON Web Token Plugin"
tal:replace="structure context/manage_form_title">FORM TITLE</h2>
<p class="form-help">Please input the configuration</p>
<form action="addERP5JSONWebTokenPlugin" method="POST">
<table cellspacing="0" cellpadding="2" border="0">
<tr>
<td align="left" valign="top">
<div class="form-label">
Id
</div>
</td>
<td align="left" valign="top">
<input type="text" name="id" size="40" />
</td>
</tr>
<tr>
<td align="left" valign="top">
<div class="form-label">
Title
</div>
</td>
<td align="left" valign="top">
<input type="text" name="title" size="40" />
</td>
</tr>
<tr>
<td colspan="2"> <input type="submit" value="add plugin"/>
</td>
</tr>
</table>
</form>
<h1 tal:replace="structure context/manage_page_footer">PAGE FOOTER</h1>
<h1 tal:replace="structure context/manage_page_header">PAGE HEADER</h1>
<h2 tal:replace="structure here/manage_tabs"> TABS </h2>
<h2 tal:define="form_title string:Update ERP5 JSON Web Token Plugin"
tal:replace="structure context/manage_form_title">FORM TITLE</h2>
<p class="form-help">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.</p>
<form action="manage_updateERP5JSONWebTokenPlugin" method="POST">
<table>
<tr>
<td colspan="2">
<input type="submit" value="Update"/>
</td>
</tr>
</table>
</form>
<h1 tal:replace="structure context/manage_page_footer">PAGE FOOTER</h1>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment