ERP5ExternalOauth2ExtractionPlugin.py 10.8 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
# -*- 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 Products.ERP5Type.Globals import InitializeClass
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.plugins.BasePlugin import BasePlugin
36
from Products import ERP5Security
37 38
from AccessControl.SecurityManagement import getSecurityManager, \
  setSecurityManager, newSecurityManager
39
from Products.ERP5Type.Cache import DEFAULT_CACHE_SCOPE
40
import time
41
import socket
42 43 44
import httplib
import urllib
import json
45 46 47 48 49 50 51
from zLOG import LOG, ERROR, INFO

try:
  import facebook
except ImportError:
  facebook = None

52 53 54 55 56 57 58
try:
  import apiclient.discovery
  import httplib2
  import oauth2client.client
except ImportError:
  httplib2 = None

59 60 61 62 63
#Form for new plugin in ZMI
manage_addERP5FacebookExtractionPluginForm = PageTemplateFile(
  'www/ERP5Security_addERP5FacebookExtractionPlugin', globals(),
  __name__='manage_addERP5FacebookExtractionPluginForm')

64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
def getGoogleUserEntry(token):
  if httplib2 is None:
    LOG('ERP5GoogleExtractionPlugin', INFO,
        'No Google modules available, please install google-api-python-client '
        'package. Authentication disabled..')
    return None

  http = oauth2client.client.AccessTokenCredentials(token,
                                                    'ERP5 Client'
    ).authorize(httplib2.Http(timeout=5))
  service = apiclient.discovery.build("oauth2", "v1", http=http)
  google_entry = service.userinfo().get().execute()

  user_entry = {}
  if google_entry is not None:
    # sanitise value
    for k in (('first_name', 'given_name'),
        ('last_name', 'family_name'),
        ('email', 'email'),
        ('reference', 'email'),):
      value = google_entry[k[1]].encode('utf-8')
      user_entry[k[0]] = value
  return user_entry

88 89 90 91 92 93 94
def addERP5FacebookExtractionPlugin(dispatcher, id, title=None, REQUEST=None):
  """ Add a ERP5FacebookExtractionPlugin to a Pluggable Auth Service. """

  plugin = ERP5FacebookExtractionPlugin(id, title)
  dispatcher._setObject(plugin.getId(), plugin)

  if REQUEST is not None:
95 96 97 98 99
    REQUEST['RESPONSE'].redirect(
      '%s/manage_workspace'
      '?manage_tabs_message='
      'ERP5FacebookExtractionPlugin+added.'
      % dispatcher.absolute_url())
100

101 102 103 104 105 106 107 108 109 110 111 112
#Form for new plugin in ZMI
manage_addERP5GoogleExtractionPluginForm = PageTemplateFile(
  'www/ERP5Security_addERP5GoogleExtractionPlugin', globals(),
  __name__='manage_addERP5GoogleExtractionPluginForm')

def addERP5GoogleExtractionPlugin(dispatcher, id, title=None, REQUEST=None):
  """ Add a ERP5GoogleExtractionPlugin to a Pluggable Auth Service. """

  plugin = ERP5GoogleExtractionPlugin(id, title)
  dispatcher._setObject(plugin.getId(), plugin)

  if REQUEST is not None:
113 114 115 116 117
    REQUEST['RESPONSE'].redirect(
      '%s/manage_workspace'
      '?manage_tabs_message='
      'ERP5GoogleExtractionPlugin+added.'
      % dispatcher.absolute_url())
118

119
class ERP5ExternalOauth2ExtractionPlugin:
120

121
  cache_factory_name = 'external_oauth2_token_cache_factory'
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
  security = ClassSecurityInfo()

  def __init__(self, id, title=None):
    #Register value
    self._setId(id)
    self.title = title

  #####################
  # memcached helpers #
  #####################
  def _getCacheFactory(self):
    portal = self.getPortalObject()
    cache_tool = portal.portal_caches
    cache_factory = cache_tool.getRamCacheRoot().get(self.cache_factory_name)
    #XXX This conditional statement should be remove as soon as
    #Broadcasting will be enable among all zeo clients.
    #Interaction which update portal_caches should interact with all nodes.
    if cache_factory is None \
        and getattr(cache_tool, self.cache_factory_name, None) is not None:
      #ram_cache_root is not up to date for current node
      cache_tool.updateCache()
    cache_factory = cache_tool.getRamCacheRoot().get(self.cache_factory_name)
    if cache_factory is None:
145
      raise KeyError("Cache factory %s not found" % self.cache_factory_name)
146 147
    return cache_factory

148
  def setToken(self, key, body):
149 150 151 152 153 154
    cache_factory = self._getCacheFactory()
    cache_duration = cache_factory.cache_duration
    for cache_plugin in cache_factory.getCachePluginList():
      cache_plugin.set(key, DEFAULT_CACHE_SCOPE,
                       body, cache_duration=cache_duration)

155
  def getToken(self, key):
156 157 158 159
    cache_factory = self._getCacheFactory()
    for cache_plugin in cache_factory.getCachePluginList():
      cache_entry = cache_plugin.get(key, DEFAULT_CACHE_SCOPE)
      if cache_entry is not None:
160 161 162 163 164 165 166
        # Avoid errors if the plugin don't have the funcionality of refresh token
        refreshTokenIfExpired = getattr(self, "refreshTokenIfExpired", None)
        cache_value = cache_entry.getValue()
        if refreshTokenIfExpired is not None:
          return refreshTokenIfExpired(key, cache_value)
        else:
          return cache_value
167 168 169 170 171 172 173
    raise KeyError('Key %r not found' % key)

  ####################################
  #ILoginPasswordHostExtractionPlugin#
  ####################################
  security.declarePrivate('extractCredentials')
  def extractCredentials(self, request):
174
    """ Extract Oauth2 credentials from the request header. """
175 176 177 178 179 180 181 182 183
    user_dict = {}
    cookie_hash = request.get(self.cookie_name)
    if cookie_hash is not None:
      try:
        user_dict = self.getToken(cookie_hash)
      except KeyError:
        LOG(self.getId(), INFO, 'Hash %s not found' % cookie_hash)
        return {}

184
    token = None
185 186
    if "access_token" in user_dict:
      token = user_dict["access_token"]
187 188

    if token is None:
189 190
      # no token, then no credentials
      return {}
191

192
    user_entry = None
193
    try:
194
      user_entry = self.getToken(token)
195
    except KeyError:
196 197
      user_entry = self.getUserEntry(token)
      if user_entry is not None:
198 199 200 201 202 203 204
        # Reduce data size because, we don't need more than reference
        user_entry = {"reference": user_entry["reference"]}

    if user_entry is None:
      # no user, then no credentials
      return {}

205
    try:
206 207 208 209
      # Every request will update cache to postpone the cache expiration
      # to keep the user logged in
      self.setToken(token, user_entry)
    except KeyError as error:
210
      # allow to work w/o cache
211
      LOG(self.getId(), INFO, error)
212
      pass
213 214 215 216 217 218 219 220 221

    # Credentials returned here will be used by ERP5LoginUserManager to find the login document
    # having reference `user`.
    creds = {
      "login_portal_type": self.login_portal_type,
      "external_login": user_entry["reference"]
    }

    # PAS wants remote_host / remote_address
222 223 224 225 226 227 228
    creds['remote_host'] = request.get('REMOTE_HOST', '')
    try:
      creds['remote_address'] = request.getClientAddr()
    except AttributeError:
      creds['remote_address'] = request.get('REMOTE_ADDR', '')
    return creds

229 230 231 232 233 234
class ERP5FacebookExtractionPlugin(ERP5ExternalOauth2ExtractionPlugin, BasePlugin):
  """
  Plugin to authenicate as machines.
  """

  meta_type = "ERP5 Facebook Extraction Plugin"
235 236 237 238 239
  cookie_name = "__ac_facebook_hash"
  cache_factory_name = "facebook_server_auth_token_cache_factory"

  def refreshTokenIfExpired(self, key, cache_value):
    return cache_value
240 241

  def getUserEntry(self, token):
242 243 244 245 246
    if facebook is None:
      LOG('ERP5FacebookExtractionPlugin', INFO,
          'No facebook module, install facebook-sdk package. '
            'Authentication disabled.')
      return None
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
    timeout = socket.getdefaulttimeout()
    try:
      # require really fast interaction
      socket.setdefaulttimeout(5)
      facebook_entry = facebook.GraphAPI(token).get_object("me")
    except Exception:
      facebook_entry = None
    finally:
      socket.setdefaulttimeout(timeout)

    user_entry = {}
    if facebook_entry is not None:
      # sanitise value
      try:
        for k in ('first_name', 'last_name', 'id', 'email'):
          if k == 'id':
263
            user_entry['reference'] = facebook_entry[k].encode('utf-8')
264 265 266 267 268 269
          else:
            user_entry[k] = facebook_entry[k].encode('utf-8')
      except KeyError:
        user_entry = None
    return user_entry

270 271 272 273 274 275
class ERP5GoogleExtractionPlugin(ERP5ExternalOauth2ExtractionPlugin, BasePlugin):
  """
  Plugin to authenicate as machines.
  """

  meta_type = "ERP5 Google Extraction Plugin"
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294
  login_portal_type = "Google Login"
  cookie_name = "__ac_google_hash"
  cache_factory_name = "google_server_auth_token_cache_factory"

  def refreshTokenIfExpired(self, key, cache_value):
    expires_in = cache_value.get("token_response", {}).get("expires_in")
    refresh_token = cache_value.get("refresh_token")
    if expires_in and refresh_token:
     if (time.time() - cache_value["response_timestamp"]) >= float(expires_in):
       credential = oauth2client.client.OAuth2Credentials(
         cache_value["access_token"], cache_value["client_id"],
         cache_value["client_secret"], refresh_token,
         cache_value["token_expiry"], cache_value["token_uri"],
         cache_value["user_agent"])
       credential.refresh(httplib2.Http())
       cache_value = json.loads(credential.to_json())
       cache_value["response_timestamp"] = time.time()
       self.setToken(key, cache_value)
    return cache_value
295 296

  def getUserEntry(self, token):
297
    return getGoogleUserEntry(token)
298 299 300 301 302 303 304

#List implementation of class
classImplements( ERP5FacebookExtractionPlugin,
                plugins.ILoginPasswordHostExtractionPlugin
               )
InitializeClass(ERP5FacebookExtractionPlugin)

305 306 307 308 309
classImplements( ERP5GoogleExtractionPlugin,
                plugins.ILoginPasswordHostExtractionPlugin
               )
InitializeClass(ERP5GoogleExtractionPlugin)