ERP5KeyAuthPlugin.py 16.1 KB
Newer Older
1
# -*- coding: utf-8 -*-
2 3
##############################################################################
#
4
# Copyright (c) 2011 Nexedi SA and Contributors. All Rights Reserved.
5 6 7
#                    Francois-Xavier Algrain <fxalgrain@tiolive.com>
#
# WARNING: This program as such is intended to be used by professional
8
# programmers who take the whole responsibility of assessing all potential
9 10
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
11
# guarantees and support are strongly adviced to contract a Free Software
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
# 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.
#
##############################################################################
29 30 31 32 33

from base64 import encodestring, decodestring
from urllib import quote, unquote
from DateTime import DateTime
from zLOG import LOG, PROBLEM
34
from Products.ERP5Type.Globals import InitializeClass
35
from zope.interface import Interface
36 37 38 39 40 41 42 43 44 45 46 47

from AccessControl import ClassSecurityInfo

from Products.PageTemplates.PageTemplateFile import PageTemplateFile

from Products.PluggableAuthService.interfaces import plugins
from Products.PluggableAuthService.utils import classImplements
from Products.PluggableAuthService.permissions import ManageUsers
from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin
from Products.PluggableAuthService.plugins.CookieAuthHelper import CookieAuthHelper

from Products.ERP5Type.Cache import CachingMethod
48
from Products.ERP5Type.UnrestrictedMethod import UnrestrictedMethod
49 50
from Products.ERP5Security.ERP5UserManager import ERP5UserManager, \
                                                  SUPER_USER, \
51 52
                                                  _AuthenticationFailure

53
from Crypto.Cipher import AES
54
from Crypto import Random
55
from base64 import urlsafe_b64decode, urlsafe_b64encode
56 57 58 59 60 61 62 63 64

class AESCipher:
  mode = AES.MODE_CFB

  def __init__(self, encryption_key):
    # AES key must be either 16, 24, or 32 bytes long
    self.encryption_key = encryption_key.ljust(32)[:32]

  def encrypt(self, login):
65 66 67
    iv = Random.new().read(AES.block_size)
    encryptor = AES.new(self.encryption_key, self.mode, IV=iv)
    return urlsafe_b64encode(iv + encryptor.encrypt(login.ljust(((len(login)-1)/16+1)*16)))
68 69

  def decrypt(self, crypted_login):
70 71 72 73
    decoded_crypted_login = urlsafe_b64decode(crypted_login)
    iv = decoded_crypted_login[:AES.block_size]
    decryptor = AES.new(self.encryption_key, self.mode, IV=iv)
    return decryptor.decrypt(decoded_crypted_login[AES.block_size:]).rstrip()
74 75 76 77 78 79 80 81 82

# This cipher is weak. Do not use.
class CesarCipher:
  block_length = 3

  def __init__(self, encryption_key):
    self.encryption_key = encryption_key
    self.encrypted_key = self.transformKey(self.encryption_key);

83
  def transformKey(self, key):
84 85 86 87 88 89 90 91 92
    """Transform the key to number for encryption"""
    encrypt_key = []
    for letter in key:
      encrypt_key.append(ord(letter))
    return encrypt_key

  def encrypt(self, login):
    crypted_login = ''
    key_length = len(self.encrypted_key)
93
    for i in range(0, len(login)):
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
      delta = i % key_length
      crypted_letter = str(ord(login[i]) + self.encrypted_key[delta])
      #ord is the inverse of chr() for 8-bit (1111 1111 = 256)
      #so crypted_letter max id 512
      #we ajust lenght to be able to decrypt by block
      crypted_letter = crypted_letter.rjust(self.block_length, '0')
      crypted_login += crypted_letter
    return crypted_login

  def decrypt(self, crypted_login):
    login = ''
    #check lenght of the string
    clogin_length = len(crypted_login)
    if clogin_length % self.block_length != 0:
      raise ValueError, "Lenght is not good"
    #decrypt block per block
    position = 0
    key_length = len(self.encrypted_key)
    for block in range(0, clogin_length, self.block_length):
      delta = position % key_length
      crypted_letter = crypted_login[block:block + self.block_length]
      crypted_letter = int(crypted_letter) - self.encrypted_key[delta]
      letter = chr(crypted_letter)
      login += letter
      position += 1
    return login

121 122 123
class ILoginEncryptionPlugin(Interface):
  """Contract for possible ERP5 Key Auth Plugin"""

124
  def encrypt(self, login):
125 126
    """Encrypt the login"""

127
  def decrypt(self, crypted_login):
128 129 130 131
    """Decrypt string and return the login"""


#Form for new plugin in ZMI
132
manage_addERP5KeyAuthPluginForm = PageTemplateFile(
133 134 135
    'www/ERP5Security_addERP5KeyAuthPlugin', globals(),
    __name__='manage_addERP5KeyAuthPluginForm')

136 137
def addERP5KeyAuthPlugin(dispatcher, id, title=None,
                         encryption_key='', cipher='AES', cookie_name='',
138
                         default_cookie_name='',REQUEST=None):
139
  """ Add a ERP5KeyAuthPlugin to a Pluggable Auth Service. """
140

141 142 143 144
  plugin = ERP5KeyAuthPlugin(id=id, title=title, encryption_key=encryption_key,
                             cipher=cipher, cookie_name=cookie_name,
                             default_cookie_name=default_cookie_name)
  dispatcher._setObject(plugin.getId(), plugin)
145

146 147 148 149 150 151
  if REQUEST is not None:
    REQUEST['RESPONSE'].redirect(
      '%s/manage_workspace'
      '?manage_tabs_message='
      'ERP5KeyAuthPlugin+added.'
      % dispatcher.absolute_url())
152 153 154 155 156

class ERP5KeyAuthPlugin(ERP5UserManager, CookieAuthHelper):
  """
    Key authentification PAS plugin which support key authentication in URL.

157
    <ERP5_Root>/web_page_module/1?__ac_key=207221200213146153166
158

159
    where value of __ac_key contains an encrypted reference of a user
160

161 162 163
  TODO: We should use a real PKI (Public Key Infrastructure) so that we
  can revoke a part of already provided keys without changing the
  encryption key or a user's reference.
164 165 166 167
  """

  meta_type = "ERP5 Key Authentication"
  login_path = 'login_form'
168
  security = ClassSecurityInfo()
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
  cookie_name = "__ac_key"
  default_cookie_name = "__ac"
  encryption_key = ''

  manage_options = ( ( { 'label': 'Edit',
                          'action': 'manage_editERP5KeyAuthPluginForm', }
                        ,
                      )
                      + BasePlugin.manage_options[:]
                      #+ CookieAuthHelper.manage_options[:] //don't need folder option today
                    )

  _properties = ( ( { 'id':'default_cookie_name',
                      'type':'string',
                      'mode':'w',
                      'label':'Default Cookie Name'
                    },
                    )
                    + BasePlugin._properties[:]
                    + CookieAuthHelper._properties[:]
                  )

191 192
  def __init__(self, id, title=None, encryption_key='', cipher='AES',
               cookie_name='', default_cookie_name=''):
193 194
    #Check parameters
    if cookie_name is None or cookie_name == '':
195
      cookie_name = id
196 197 198 199 200 201 202 203 204 205 206
    if encryption_key is None or encryption_key == '':
      encryption_key = id
    if "__ac_key" in [cookie_name, default_cookie_name]:
      raise ValueError, "Cookie name must be different of __ac_key"

    #Register value
    self._setId(id)
    self.title = title
    self.cookie_name = cookie_name
    self.default_cookie_name = default_cookie_name
    self.encryption_key = encryption_key
207
    self.cipher = cipher
208

209 210 211 212
  def _getCipher(self):
    # If self.cipher does not exist, we use CesarCipher only for
    # backward compatibility.
    return getattr(self, 'cipher', 'Cesar')
213 214 215 216 217

  ################################
  #    ILoginEncryptionPlugin    #
  ################################
  security.declarePublic('encrypt')
218
  def encrypt(self, login):
219
    """Encrypt the login"""
220 221
    cipher = globals()['%sCipher' % self._getCipher()](self.encryption_key)
    return cipher.encrypt(login)
222

223 224 225
  security.declarePrivate('decrypt')
  def decrypt(self, crypted_login):
    """Decrypt string and return the login"""
226 227
    cipher = globals()['%sCipher' % self._getCipher()](self.encryption_key)
    return cipher.decrypt(crypted_login)
228 229 230 231 232 233 234 235 236 237 238

  ####################################
  #ILoginPasswordHostExtractionPlugin#
  ####################################
  security.declarePrivate('extractCredentials')
  def extractCredentials(self, request):
    """ Extract credentials from cookie or 'request'. """
    try:
      creds = {}
      #Search __ac_key
      key = request.get('__ac_key', None)
239
      if key is not None:
240 241
        creds['key'] = key
        #Save this in cookie
242
        self.updateCredentials(request, request["RESPONSE"], None, None)
243
      else:
244
        # Look in the request for the names coming from the login form
245 246 247 248 249 250 251 252
        #It's default method
        login_pw = request._authUserPW()

        if login_pw is not None:
          name, password = login_pw
          creds[ 'login' ] = name
          creds[ 'password' ] = password
          #Save this in cookie
253
          self.updateCredentials(request, request["RESPONSE"], name, password)
254 255

        else:
256
          #search in cookies
257 258 259 260 261
          cookie = request.get(self.cookie_name, None)
          if cookie is not None:
            #Cookie is found
            cookie_val = unquote(cookie)
            creds['key'] = cookie_val
262
          else:
263 264 265 266 267 268 269 270 271 272
            #Default cookie if needed
            default_cookie = request.get(self.default_cookie_name, None)
            if default_cookie is not None:
              #Cookie is found
              cookie_val = decodestring(unquote(default_cookie))
              if cookie_val is not None:
                login, password = cookie_val.split(':')
                creds['login'] = login
                creds['password'] = password

273
      #Complete credential with some information
274 275 276
      if creds:
        creds['remote_host'] = request.get('REMOTE_HOST', '')
        try:
277
          creds['remote_address'] = request.getClientAddr()
278
        except AttributeError:
279
          creds['remote_address'] = request.get('REMOTE_ADDR', '')
280
    except StandardError, e:
281 282 283 284
      #Log standard error to check error
      LOG('ERP5KeyAuthPlugin.extractCredentials', PROBLEM, str(e))

    return creds
285

286 287 288 289 290 291 292 293 294
  ################################
  #   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
295
    cookie_val = request.get("__ac_key", None)
296 297 298
    if cookie_val is not None:
      #expires = (DateTime() + 365).toZone('GMT').rfc822()
      cookie_val = cookie_val.rstrip()
299
      response.setCookie(self.cookie_name, quote(cookie_val), path='/')#, expires=expires)
300 301 302 303 304 305 306 307 308 309 310 311 312 313
      response.expireCookie(self.default_cookie_name, path='/')
    elif login is not None and new_password is not None:
      cookie_val = encodestring('%s:%s' % (login, new_password))
      cookie_val = cookie_val.rstrip()
      response.setCookie(self.default_cookie_name, quote(cookie_val), path='/')
      response.expireCookie(self.cookie_name, path='/')


  ################################
  #    ICredentialsResetPlugin   #
  ################################
  security.declarePrivate('resetCredentials')
  def resetCredentials(self, request, response):
    """Expire cookies of authentification """
314
    response.expireCookie(self.cookie_name, path='/')
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
    response.expireCookie(self.default_cookie_name, path='/')


  ################################
  #     IAuthenticationPlugin    #
  ################################
  security.declarePrivate('authenticateCredentials')
  def authenticateCredentials( self, credentials ):
    """Authentificate with credentials"""
    key = credentials.get('key', None)
    if key != None:
      login = self.decrypt(key)
      # Forbidden the usage of the super user.
      if login == SUPER_USER:
        return None
330

331
      #Function to allow cache
332
      @UnrestrictedMethod
333 334 335 336 337 338 339 340 341 342
      def _authenticateCredentials(login):
        if not login:
          return None

        #Search the user by his login
        user_list = self.getUserByLogin(login)
        if len(user_list) != 1:
          raise _AuthenticationFailure()
        user = user_list[0]

343
        if True:
344 345 346 347 348 349 350 351 352 353 354
          try:
            # get assignment list
            assignment_list = [x for x in user.contentValues(portal_type="Assignment") \
                                 if x.getValidationState() == "open"]
            valid_assignment_list = []
            # check dates if exist
            login_date = DateTime()
            for assignment in assignment_list:
              if assignment.getStartDate() is not None and \
                        assignment.getStartDate() > login_date:
                continue
355
              if assignment.hasStopDate() and \
356 357 358 359
                  assignment.getStopDate() < login_date:
                continue
              valid_assignment_list.append(assignment)

360
            # validate
361
            if len(valid_assignment_list) > 0:
362
              return (login, login)
363
          finally:
364
            pass
365

366 367 368 369 370 371 372
          raise _AuthenticationFailure()

      #Cache Method for best performance
      _authenticateCredentials = CachingMethod(_authenticateCredentials,
                                                id='ERP5KeyAuthPlugin_authenticateCredentials',
                                                cache_factory='erp5_content_short')
      try:
373
        return _authenticateCredentials(login=login)
374
      except _AuthenticationFailure:
375
        return None
376
      except StandardError, e:
377 378 379
        #Log standard error
        LOG('ERP5KeyAuthPlugin.authenticateCredentials', PROBLEM, str(e))
        return None
380 381 382 383 384 385

  ################################
  # Properties for ZMI managment #
  ################################

  #'Edit' option form
386 387
  manage_editERP5KeyAuthPluginForm = PageTemplateFile(
      'www/ERP5Security_editERP5KeyAuthPlugin',
388 389
      globals(),
      __name__='manage_editERP5KeyAuthPluginForm' )
390

391
  security.declareProtected( ManageUsers, 'manage_editKeyAuthPlugin' )
392 393
  def manage_editKeyAuthPlugin(self, encryption_key, cipher, cookie_name,
                               default_cookie_name, RESPONSE=None):
394 395
    """Edit the object"""
    error_message = ''
396

397 398 399 400 401 402 403 404 405
    #Test paramaeters
    if "__ac_key" in [cookie_name, default_cookie_name]:
      raise ValueError, "Cookie name must be different of __ac_key"

    #Save key
    if encryption_key == '' or encryption_key is None:
      error_message += 'Invalid key value '
    else:
      self.encryption_key = encryption_key
406 407 408 409 410 411

    #Save cipher
    if cipher == '' or cipher is None:
      error_message += 'Invalid cipher value '
    else:
      self.cipher = cipher
412

413 414 415 416 417 418 419 420 421 422 423 424 425 426 427
    #Save cookie name
    if cookie_name == '' or cookie_name is None:
      error_message += 'Invalid cookie name '
    else:
      self.cookie_name = cookie_name

    #Save default_cookie_name
    if default_cookie_name == '' or default_cookie_name is None:
      error_message += 'Invalid default cookie name '
    else:
      self.default_cookie_name = default_cookie_name

    #Redirect
    if RESPONSE is not None:
      if error_message != '':
428 429
        self.REQUEST.form['manage_tabs_message'] = error_message
        return self.manage_editERP5KeyAuthPluginForm(RESPONSE)
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444
      else:
        message = "Updated"
        RESPONSE.redirect( '%s/manage_editERP5KeyAuthPluginForm'
                            '?manage_tabs_message=%s'
                            % ( self.absolute_url(), message )
                          )

#List implementation of class
classImplements(ERP5KeyAuthPlugin,
                ILoginEncryptionPlugin,
                plugins.IAuthenticationPlugin,
                plugins.ILoginPasswordHostExtractionPlugin,
                plugins.ICredentialsResetPlugin,
                plugins.ICredentialsUpdatePlugin)

445
InitializeClass(ERP5KeyAuthPlugin)