import collections import copy import datetime import hashlib import hmac import json import logging try: import cPickle as pickle except ImportError: import pickle import sys import threading import time from xml.etree import ElementTree from authomatic.exceptions import ( ConfigError, CredentialsError, ImportStringError, RequestElementsError, SessionError, ) from authomatic import six from authomatic.six.moves import urllib_parse as parse # ========================================================================= # Global variables !!! # ========================================================================= _logger = logging.getLogger(__name__) _logger.addHandler(logging.StreamHandler(sys.stdout)) _counter = None def normalize_dict(dict_): """ Replaces all values that are single-item iterables with the value of its index 0. :param dict dict_: Dictionary to normalize. :returns: Normalized dictionary. """ return dict([(k, v[0] if not isinstance(v, str) and len(v) == 1 else v) for k, v in list(dict_.items())]) def items_to_dict(items): """ Converts list of tuples to dictionary with duplicate keys converted to lists. :param list items: List of tuples. :returns: :class:`dict` """ res = collections.defaultdict(list) for k, v in items: res[k].append(v) return normalize_dict(dict(res)) class Counter(object): """ A simple counter to be used in the config to generate unique `id` values. """ def __init__(self, start=0): self._count = start def count(self): self._count += 1 return self._count _counter = Counter() def provider_id(): """ A simple counter to be used in the config to generate unique `IDs`. :returns: :class:`int`. Use it in the :doc:`config` like this: :: import authomatic CONFIG = { 'facebook': { 'class_': authomatic.providers.oauth2.Facebook, 'id': authomatic.provider_id(), # returns 1 'consumer_key': '##########', 'consumer_secret': '##########', 'scope': ['user_about_me', 'email'] }, 'google': { 'class_': 'authomatic.providers.oauth2.Google', 'id': authomatic.provider_id(), # returns 2 'consumer_key': '##########', 'consumer_secret': '##########', 'scope': ['https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email'] }, 'windows_live': { 'class_': 'oauth2.WindowsLive', 'id': authomatic.provider_id(), # returns 3 'consumer_key': '##########', 'consumer_secret': '##########', 'scope': ['wl.basic', 'wl.emails', 'wl.photos'] }, } """ return _counter.count() def escape(s): """ Escape a URL including any /. """ return parse.quote(s.encode('utf-8'), safe='~') def json_qs_parser(body): """ Parses response body from JSON, XML or query string. :param body: string :returns: :class:`dict`, :class:`list` if input is JSON or query string, :class:`xml.etree.ElementTree.Element` if XML. """ try: # Try JSON first. return json.loads(body) except (OverflowError, TypeError, ValueError): pass try: # Then XML. return ElementTree.fromstring(body) except (ElementTree.ParseError, TypeError, ValueError): pass # Finally query string. return dict(parse.parse_qsl(body)) def import_string(import_name, silent=False): """ Imports an object by string in dotted notation. taken `from webapp2.import_string() `_ """ try: if '.' in import_name: module, obj = import_name.rsplit('.', 1) return getattr(__import__(module, None, None, [obj]), obj) else: return __import__(import_name) except (ImportError, AttributeError) as e: if not silent: raise ImportStringError('Import from string failed for path {0}' .format(import_name), str(e)) def resolve_provider_class(class_): """ Returns a provider class. :param class_name: :class:`string` or :class:`authomatic.providers.BaseProvider` subclass. """ if isinstance(class_, str): # prepare path for authomatic.providers package path = '.'.join([__package__, 'providers', class_]) # try to import class by string from providers module or by fully # qualified path return import_string(class_, True) or import_string(path) else: return class_ def id_to_name(config, short_name): """ Returns the provider :doc:`config` key based on it's ``id`` value. :param dict config: :doc:`config`. :param id: Value of the id parameter in the :ref:`config` to search for. """ for k, v in list(config.items()): if v.get('id') == short_name: return k raise Exception( 'No provider with id={0} found in the config!'.format(short_name)) class ReprMixin(object): """ Provides __repr__() method with output *ClassName(arg1=value, arg2=value)*. Ignored are attributes * which values are considered false. * with leading underscore. * listed in _repr_ignore. Values of attributes listed in _repr_sensitive will be replaced by *###*. Values which repr() string is longer than _repr_length_limit will be represented as *ClassName(...)* """ #: Iterable of attributes to be ignored. _repr_ignore = [] #: Iterable of attributes which value should not be visible. _repr_sensitive = [] #: `int` Values longer than this will be truncated to *ClassName(...)*. _repr_length_limit = 20 def __repr__(self): # get class name name = self.__class__.__name__ # construct keyword arguments args = [] for k, v in list(self.__dict__.items()): # ignore attributes with leading underscores and those listed in # _repr_ignore if v and not k.startswith('_') and k not in self._repr_ignore: # replace sensitive values if k in self._repr_sensitive: v = '###' # if repr is too long if len(repr(v)) > self._repr_length_limit: # Truncate to ClassName(...) v = '{0}(...)'.format(v.__class__.__name__) else: v = repr(v) args.append('{0}={1}'.format(k, v)) return '{0}({1})'.format(name, ', '.join(args)) class Future(threading.Thread): """ Represents an activity run in a separate thread. Subclasses the standard library :class:`threading.Thread` and adds :attr:`.get_result` method. .. warning:: |async| """ def __init__(self, func, *args, **kwargs): """ :param callable func: The function to be run in separate thread. Calls :data:`func` in separate thread and returns immediately. Accepts arbitrary positional and keyword arguments which will be passed to :data:`func`. """ super(Future, self).__init__() self._func = func self._args = args self._kwargs = kwargs self._result = None self.start() def run(self): self._result = self._func(*self._args, **self._kwargs) def get_result(self, timeout=None): """ Waits for the wrapped :data:`func` to finish and returns its result. .. note:: This will block the **calling thread** until the :data:`func` returns. :param timeout: :class:`float` or ``None`` A timeout for the :data:`func` to return in seconds. :returns: The result of the wrapped :data:`func`. """ self.join(timeout) return self._result class Session(object): """ A dictionary-like secure cookie session implementation. """ def __init__(self, adapter, secret, name='authomatic', max_age=600, secure=False): """ :param str secret: Session secret used to sign the session cookie. :param str name: Session cookie name. :param int max_age: Maximum allowed age of session cookie nonce in seconds. :param bool secure: If ``True`` the session cookie will be saved with ``Secure`` attribute. """ self.adapter = adapter self.name = name self.secret = secret self.max_age = max_age self.secure = secure self._data = {} def create_cookie(self, delete=None): """ Creates the value for ``Set-Cookie`` HTTP header. :param bool delete: If ``True`` the cookie value will be ``deleted`` and the Expires value will be ``Thu, 01-Jan-1970 00:00:01 GMT``. """ value = 'deleted' if delete else self._serialize(self.data) split_url = parse.urlsplit(self.adapter.url) domain = split_url.netloc.split(':')[0] # Work-around for issue #11, failure of WebKit-based browsers to accept # cookies set as part of a redirect response in some circumstances. if '.' not in domain: template = '{name}={value}; Path={path}; HttpOnly{secure}{expires}' else: template = ('{name}={value}; Domain={domain}; Path={path}; ' 'HttpOnly{secure}{expires}') return template.format( name=self.name, value=value, domain=domain, path=split_url.path, secure='; Secure' if self.secure else '', expires='; Expires=Thu, 01-Jan-1970 00:00:01 GMT' if delete else '' ) def save(self): """ Adds the session cookie to headers. """ if self.data: cookie = self.create_cookie() cookie_len = len(cookie) if cookie_len > 4093: raise SessionError('Cookie too long! The cookie size {0} ' 'is more than 4093 bytes.' .format(cookie_len)) self.adapter.set_header('Set-Cookie', cookie) # Reset data self._data = {} def delete(self): self.adapter.set_header('Set-Cookie', self.create_cookie(delete=True)) def _get_data(self): """ Extracts the session data from cookie. """ cookie = self.adapter.cookies.get(self.name) return self._deserialize(cookie) if cookie else {} @property def data(self): """ Gets session data lazily. """ if not self._data: self._data = self._get_data() # Always return a dict, even if deserialization returned nothing if self._data is None: self._data = {} return self._data def _signature(self, *parts): """ Creates signature for the session. """ signature = hmac.new(six.b(self.secret), digestmod=hashlib.sha1) signature.update(six.b('|'.join(parts))) return signature.hexdigest() def _serialize(self, value): """ Converts the value to a signed string with timestamp. :param value: Object to be serialized. :returns: Serialized value. """ # data = copy.deepcopy(value) data = value # 1. Serialize serialized = pickle.dumps(data).decode('latin-1') # 2. Encode # Percent encoding produces smaller result then urlsafe base64. encoded = parse.quote(serialized, '') # 3. Concatenate timestamp = str(int(time.time())) signature = self._signature(self.name, encoded, timestamp) concatenated = '|'.join([encoded, timestamp, signature]) return concatenated def _deserialize(self, value): """ Deserializes and verifies the value created by :meth:`._serialize`. :param str value: The serialized value. :returns: Deserialized object. """ # 3. Split encoded, timestamp, signature = value.split('|') # Verify signature if not signature == self._signature(self.name, encoded, timestamp): raise SessionError('Invalid signature "{0}"!'.format(signature)) # Verify timestamp if int(timestamp) < int(time.time()) - self.max_age: return None # 2. Decode decoded = parse.unquote(encoded) # 1. Deserialize deserialized = pickle.loads(decoded.encode('latin-1')) return deserialized def __setitem__(self, key, value): self._data[key] = value def __getitem__(self, key): return self.data.__getitem__(key) def __delitem__(self, key): return self._data.__delitem__(key) def get(self, key, default=None): return self.data.get(key, default) class User(ReprMixin): """ Provides unified interface to selected **user** info returned by different **providers**. .. note:: The value format may vary across providers. """ def __init__(self, provider, **kwargs): #: A :doc:`provider ` instance. self.provider = provider #: An :class:`.Credentials` instance. self.credentials = kwargs.get('credentials') #: A :class:`dict` containing all the **user** information returned #: by the **provider**. #: The structure differs across **providers**. self.data = kwargs.get('data') #: The :attr:`.Response.content` of the request made to update #: the user. self.content = kwargs.get('content') #: :class:`str` ID assigned to the **user** by the **provider**. self.id = kwargs.get('id') #: :class:`str` User name e.g. *andrewpipkin*. self.username = kwargs.get('username') #: :class:`str` Name e.g. *Andrew Pipkin*. self.name = kwargs.get('name') #: :class:`str` First name e.g. *Andrew*. self.first_name = kwargs.get('first_name') #: :class:`str` Last name e.g. *Pipkin*. self.last_name = kwargs.get('last_name') #: :class:`str` Nickname e.g. *Andy*. self.nickname = kwargs.get('nickname') #: :class:`str` Link URL. self.link = kwargs.get('link') #: :class:`str` Gender. self.gender = kwargs.get('gender') #: :class:`str` Timezone. self.timezone = kwargs.get('timezone') #: :class:`str` Locale. self.locale = kwargs.get('locale') #: :class:`str` E-mail. self.email = kwargs.get('email') #: :class:`str` phone. self.phone = kwargs.get('phone') #: :class:`str` Picture URL. self.picture = kwargs.get('picture') #: Birth date as :class:`datetime.datetime()` or :class:`str` # if parsing failed or ``None``. self.birth_date = kwargs.get('birth_date') #: :class:`str` Country. self.country = kwargs.get('country') #: :class:`str` City. self.city = kwargs.get('city') #: :class:`str` Geographical location. self.location = kwargs.get('location') #: :class:`str` Postal code. self.postal_code = kwargs.get('postal_code') #: Instance of the Google App Engine Users API #: `User `_ class. #: Only present when using the :class:`authomatic.providers.gaeopenid.GAEOpenID` provider. self.gae_user = kwargs.get('gae_user') def update(self): """ Updates the user info by fetching the **provider's** user info URL. :returns: Updated instance of this class. """ return self.provider.update_user() def async_update(self): """ Same as :meth:`.update` but runs asynchronously in a separate thread. .. warning:: |async| :returns: :class:`.Future` instance representing the separate thread. """ return Future(self.update) def to_dict(self): """ Converts the :class:`.User` instance to a :class:`dict`. :returns: :class:`dict` """ # copy the dictionary d = copy.copy(self.__dict__) # Keep only the provider name to avoid circular reference d['provider'] = self.provider.name d['credentials'] = self.credentials.serialize( ) if self.credentials else None d['birth_date'] = str(d['birth_date']) # Remove content d.pop('content') if isinstance(self.data, ElementTree.Element): d['data'] = None return d SupportedUserAttributesNT = collections.namedtuple( typename='SupportedUserAttributesNT', field_names=['birth_date', 'city', 'country', 'email', 'first_name', 'gender', 'id', 'last_name', 'link', 'locale', 'location', 'name', 'nickname', 'phone', 'picture', 'postal_code', 'timezone', 'username', ] ) class SupportedUserAttributes(SupportedUserAttributesNT): def __new__(cls, **kwargs): defaults = dict((i, False) for i in SupportedUserAttributes._fields) # pylint:disable=no-member defaults.update(**kwargs) return super(SupportedUserAttributes, cls).__new__(cls, **defaults) class Credentials(ReprMixin): """ Contains all necessary information to fetch **user's protected resources**. """ _repr_sensitive = ('token', 'refresh_token', 'token_secret', 'consumer_key', 'consumer_secret') def __init__(self, config, **kwargs): #: :class:`dict` :doc:`config`. self.config = config #: :class:`str` User **access token**. self.token = kwargs.get('token', '') #: :class:`str` Access token type. self.token_type = kwargs.get('token_type', '') #: :class:`str` Refresh token. self.refresh_token = kwargs.get('refresh_token', '') #: :class:`str` Access token secret. self.token_secret = kwargs.get('token_secret', '') #: :class:`int` Expiration date as UNIX timestamp. self.expiration_time = int(kwargs.get('expiration_time', 0)) #: A :doc:`Provider ` instance**. provider = kwargs.get('provider') self.expire_in = int(kwargs.get('expire_in', 0)) if provider: #: :class:`str` Provider name specified in the :doc:`config`. self.provider_name = provider.name #: :class:`str` Provider type e.g. # ``"authomatic.providers.oauth2.OAuth2"``. self.provider_type = provider.get_type() #: :class:`str` Provider type e.g. # ``"authomatic.providers.oauth2.OAuth2"``. self.provider_type_id = provider.type_id #: :class:`str` Provider short name specified in the :doc:`config`. self.provider_id = int(provider.id) if provider.id else None #: :class:`class` Provider class. self.provider_class = provider.__class__ #: :class:`str` Consumer key specified in the :doc:`config`. self.consumer_key = provider.consumer_key #: :class:`str` Consumer secret specified in the :doc:`config`. self.consumer_secret = provider.consumer_secret else: self.provider_name = kwargs.get('provider_name', '') self.provider_type = kwargs.get('provider_type', '') self.provider_type_id = kwargs.get('provider_type_id') self.provider_id = kwargs.get('provider_id') self.provider_class = kwargs.get('provider_class') self.consumer_key = kwargs.get('consumer_key', '') self.consumer_secret = kwargs.get('consumer_secret', '') @property def expire_in(self): """ """ return self._expire_in @expire_in.setter def expire_in(self, value): """ Computes :attr:`.expiration_time` when the value is set. """ # pylint:disable=attribute-defined-outside-init if value: self._expiration_time = int(time.time()) + int(value) self._expire_in = value @property def expiration_time(self): return self._expiration_time @expiration_time.setter def expiration_time(self, value): # pylint:disable=attribute-defined-outside-init self._expiration_time = int(value) self._expire_in = self._expiration_time - int(time.time()) @property def expiration_date(self): """ Expiration date as :class:`datetime.datetime` or ``None`` if credentials never expire. """ if self.expire_in < 0: return None else: return datetime.datetime.fromtimestamp(self.expiration_time) @property def valid(self): """ ``True`` if credentials are valid, ``False`` if expired. """ if self.expiration_time: return self.expiration_time > int(time.time()) else: return True def expire_soon(self, seconds): """ Returns ``True`` if credentials expire sooner than specified. :param int seconds: Number of seconds. :returns: ``True`` if credentials expire sooner than specified, else ``False``. """ if self.expiration_time: return self.expiration_time < int(time.time()) + int(seconds) else: return False def refresh(self, force=False, soon=86400): """ Refreshes the credentials only if the **provider** supports it and if it will expire in less than one day. It does nothing in other cases. .. note:: The credentials will be refreshed only if it gives sense i.e. only |oauth2|_ has the notion of credentials *refreshment/extension*. And there are also differences across providers e.g. Google supports refreshment only if there is a ``refresh_token`` in the credentials and that in turn is present only if the ``access_type`` parameter was set to ``offline`` in the **user authorization request**. :param bool force: If ``True`` the credentials will be refreshed even if they won't expire soon. :param int soon: Number of seconds specifying what means *soon*. """ if hasattr(self.provider_class, 'refresh_credentials'): if force or self.expire_soon(soon): logging.info('PROVIDER NAME: {0}'.format(self.provider_name)) return self.provider_class( self, None, self.provider_name).refresh_credentials(self) def async_refresh(self, *args, **kwargs): """ Same as :meth:`.refresh` but runs asynchronously in a separate thread. .. warning:: |async| :returns: :class:`.Future` instance representing the separate thread. """ return Future(self.refresh, *args, **kwargs) def provider_type_class(self): """ Returns the :doc:`provider ` class specified in the :doc:`config`. :returns: :class:`authomatic.providers.BaseProvider` subclass. """ return resolve_provider_class(self.provider_type) def serialize(self): """ Converts the credentials to a percent encoded string to be stored for later use. :returns: :class:`string` """ if self.provider_id is None: raise ConfigError( 'To serialize credentials you need to specify a ' 'unique integer under the "id" key in the config ' 'for each provider!') # Get the provider type specific items. rest = self.provider_type_class().to_tuple(self) # Provider ID and provider type ID are always the first two items. result = (self.provider_id, self.provider_type_id) + rest # Make sure that all items are strings. stringified = [str(i) for i in result] # Concatenate by newline. concatenated = '\n'.join(stringified) # Percent encode. return parse.quote(concatenated, '') @classmethod def deserialize(cls, config, credentials): """ A *class method* which reconstructs credentials created by :meth:`serialize`. You can also pass it a :class:`.Credentials` instance. :param dict config: The same :doc:`config` used in the :func:`.login` to get the credentials. :param str credentials: :class:`string` The serialized credentials or :class:`.Credentials` instance. :returns: :class:`.Credentials` """ # Accept both serialized and normal. if isinstance(credentials, Credentials): return credentials decoded = parse.unquote(credentials) split = decoded.split('\n') # We need the provider ID to move forward. if split[0] is None: raise CredentialsError( 'To deserialize credentials you need to specify a unique ' 'integer under the "id" key in the config for each provider!') # Get provider config by short name. provider_name = id_to_name(config, int(split[0])) cfg = config.get(provider_name) # Get the provider class. ProviderClass = resolve_provider_class(cfg.get('class_')) deserialized = Credentials(config) deserialized.provider_id = int(split[0]) deserialized.provider_type = ProviderClass.get_type() deserialized.provider_type_id = split[1] deserialized.provider_class = ProviderClass deserialized.provider_name = provider_name deserialized.provider_class = ProviderClass # Add provider type specific properties. return ProviderClass.reconstruct(split[2:], deserialized, cfg) class LoginResult(ReprMixin): """ Result of the :func:`authomatic.login` function. """ def __init__(self, provider): #: A :doc:`provider ` instance. self.provider = provider #: An instance of the :exc:`authomatic.exceptions.BaseError` subclass. self.error = None def popup_js(self, callback_name=None, indent=None, custom=None, stay_open=False): """ Returns JavaScript that: #. Triggers the ``options.onLoginComplete(result, closer)`` handler set with the :ref:`authomatic.setup() ` function of :ref:`javascript.js `. #. Calls the JavasScript callback specified by :data:`callback_name` on the opener of the *login handler popup* and passes it the *login result* JSON object as first argument and the `closer` function which you should call in your callback to close the popup. :param str callback_name: The name of the javascript callback e.g ``foo.bar.loginCallback`` will result in ``window.opener.foo.bar.loginCallback(result);`` in the HTML. :param int indent: The number of spaces to indent the JSON result object. If ``0`` or negative, only newlines are added. If ``None``, no newlines are added. :param custom: Any JSON serializable object that will be passed to the ``result.custom`` attribute. :param str stay_open: If ``True``, the popup will stay open. :returns: :class:`str` with JavaScript. """ custom_callback = """ try {{ window.opener.{cb}(result, closer); }} catch(e) {{}} """.format(cb=callback_name) if callback_name else '' # TODO: Move the window.close() to the opener return """ (function(){{ closer = function(){{ window.close(); }}; var result = {result}; result.custom = {custom}; {custom_callback} try {{ window.opener.authomatic.loginComplete(result, closer); }} catch(e) {{}} }})(); """.format(result=self.to_json(indent), custom=json.dumps(custom), custom_callback=custom_callback, stay_open='// ' if stay_open else '') def popup_html(self, callback_name=None, indent=None, title='Login | {0}', custom=None, stay_open=False): """ Returns a HTML with JavaScript that: #. Triggers the ``options.onLoginComplete(result, closer)`` handler set with the :ref:`authomatic.setup() ` function of :ref:`javascript.js `. #. Calls the JavasScript callback specified by :data:`callback_name` on the opener of the *login handler popup* and passes it the *login result* JSON object as first argument and the `closer` function which you should call in your callback to close the popup. :param str callback_name: The name of the javascript callback e.g ``foo.bar.loginCallback`` will result in ``window.opener.foo.bar.loginCallback(result);`` in the HTML. :param int indent: The number of spaces to indent the JSON result object. If ``0`` or negative, only newlines are added. If ``None``, no newlines are added. :param str title: The text of the HTML title. You can use ``{0}`` tag inside, which will be replaced by the provider name. :param custom: Any JSON serializable object that will be passed to the ``result.custom`` attribute. :param str stay_open: If ``True``, the popup will stay open. :returns: :class:`str` with HTML. """ return """ {title} """.format( title=title.format(self.provider.name if self.provider else ''), js=self.popup_js(callback_name, indent, custom, stay_open) ) @property def user(self): """ A :class:`.User` instance. """ return self.provider.user if self.provider else None def to_dict(self): return dict(provider=self.provider, user=self.user, error=self.error) def to_json(self, indent=4): return json.dumps(self, default=lambda obj: obj.to_dict( ) if hasattr(obj, 'to_dict') else '', indent=indent) class Response(ReprMixin): """ Wraps :class:`httplib.HTTPResponse` and adds. :attr:`.content` and :attr:`.data` attributes. """ def __init__(self, httplib_response, content_parser=None): """ :param httplib_response: The wrapped :class:`httplib.HTTPResponse` instance. :param function content_parser: Callable which accepts :attr:`.content` as argument, parses it and returns the parsed data as :class:`dict`. """ self.httplib_response = httplib_response self.content_parser = content_parser or json_qs_parser self._data = None self._content = None #: Same as :attr:`httplib.HTTPResponse.msg`. self.msg = httplib_response.msg #: Same as :attr:`httplib.HTTPResponse.version`. self.version = httplib_response.version #: Same as :attr:`httplib.HTTPResponse.status`. self.status = httplib_response.status #: Same as :attr:`httplib.HTTPResponse.reason`. self.reason = httplib_response.reason def read(self, amt=None): """ Same as :meth:`httplib.HTTPResponse.read`. :param amt: """ return self.httplib_response.read(amt) def getheader(self, name, default=None): """ Same as :meth:`httplib.HTTPResponse.getheader`. :param name: :param default: """ return self.httplib_response.getheader(name, default) def fileno(self): """ Same as :meth:`httplib.HTTPResponse.fileno`. """ return self.httplib_response.fileno() def getheaders(self): """ Same as :meth:`httplib.HTTPResponse.getheaders`. """ return self.httplib_response.getheaders() @staticmethod def is_binary_string(content): """ Return true if string is binary data. """ textchars = (bytearray([7, 8, 9, 10, 12, 13, 27]) + bytearray(range(0x20, 0x100))) return bool(content.translate(None, textchars)) @property def content(self): """ The whole response content. """ if not self._content: content = self.httplib_response.read() if self.is_binary_string(content): self._content = content else: #self._content = content.decode('utf-8') self._content = content return self._content @property def data(self): """ A :class:`dict` of data parsed from :attr:`.content`. """ if not self._data: self._data = self.content_parser(self.content) return self._data class UserInfoResponse(Response): """ Inherits from :class:`.Response`, adds :attr:`~UserInfoResponse.user` attribute. """ def __init__(self, user, *args, **kwargs): super(UserInfoResponse, self).__init__(*args, **kwargs) #: :class:`.User` instance. self.user = user class RequestElements(tuple): """ A tuple of ``(url, method, params, headers, body)`` request elements. With some additional properties. """ def __new__(cls, url, method, params, headers, body): return tuple.__new__(cls, (url, method, params, headers, body)) @property def url(self): """ Request URL. """ return self[0] @property def method(self): """ HTTP method of the request. """ return self[1] @property def params(self): """ Dictionary of request parameters. """ return self[2] @property def headers(self): """ Dictionary of request headers. """ return self[3] @property def body(self): """ :class:`str` Body of ``POST``, ``PUT`` and ``PATCH`` requests. """ return self[4] @property def query_string(self): """ Query string of the request. """ return parse.urlencode(self.params) @property def full_url(self): """ URL with query string. """ return self.url + '?' + self.query_string def to_json(self): return json.dumps(dict(url=self.url, method=self.method, params=self.params, headers=self.headers, body=self.body)) class Authomatic(object): def __init__( self, config, secret, session_max_age=600, secure_cookie=False, session=None, session_save_method=None, report_errors=True, debug=False, logging_level=logging.INFO, prefix='authomatic', logger=None ): """ Encapsulates all the functionality of this package. :param dict config: :doc:`config` :param str secret: A secret string that will be used as the key for signing :class:`.Session` cookie and as a salt by *CSRF* token generation. :param session_max_age: Maximum allowed age of :class:`.Session` cookie nonce in seconds. :param bool secure_cookie: If ``True`` the :class:`.Session` cookie will be saved wit ``Secure`` attribute. :param session: Custom dictionary-like session implementation. :param callable session_save_method: A method of the supplied session or any mechanism that saves the session data and cookie. :param bool report_errors: If ``True`` exceptions encountered during the **login procedure** will be caught and reported in the :attr:`.LoginResult.error` attribute. Default is ``True``. :param bool debug: If ``True`` traceback of exceptions will be written to response. Default is ``False``. :param int logging_level: The logging level threshold for the default logger as specified in the standard Python `logging library `_. This setting is ignored when :data:`logger` is set. Default is ``logging.INFO``. :param str prefix: Prefix used as the :class:`.Session` cookie name. :param logger: A :class:`logging.logger` instance. """ self.config = config self.secret = secret self.session_max_age = session_max_age self.secure_cookie = secure_cookie self.session = session self.session_save_method = session_save_method self.report_errors = report_errors self.debug = debug self.logging_level = logging_level self.prefix = prefix self._logger = logger or logging.getLogger(str(id(self))) # Set logging level. if logger is None: self._logger.setLevel(logging_level) def login(self, adapter, provider_name, callback=None, session=None, session_saver=None, **kwargs): """ If :data:`provider_name` specified, launches the login procedure for corresponding :doc:`provider ` and returns :class:`.LoginResult`. If :data:`provider_name` is empty, acts like :meth:`.Authomatic.backend`. .. warning:: The method redirects the **user** to the **provider** which in turn redirects **him/her** back to the *request handler* where it has been called. :param str provider_name: Name of the provider as specified in the keys of the :doc:`config`. :param callable callback: If specified the method will call the callback with :class:`.LoginResult` passed as argument and will return nothing. :param bool report_errors: .. note:: Accepts additional keyword arguments that will be passed to :doc:`provider ` constructor. :returns: :class:`.LoginResult` """ if provider_name: # retrieve required settings for current provider and raise # exceptions if missing provider_settings = self.config.get(provider_name) if not provider_settings: raise ConfigError('Provider name "{0}" not specified!' .format(provider_name)) if not (session is None or session_saver is None): session = session session_saver = session_saver else: session = Session(adapter=adapter, secret=self.secret, max_age=self.session_max_age, name=self.prefix, secure=self.secure_cookie) session_saver = session.save # Resolve provider class. class_ = provider_settings.get('class_') if not class_: raise ConfigError( 'The "class_" key not specified in the config' ' for provider {0}!'.format(provider_name)) ProviderClass = resolve_provider_class(class_) # FIXME: Find a nicer solution ProviderClass._logger = self._logger # instantiate provider class provider = ProviderClass(self, adapter=adapter, provider_name=provider_name, callback=callback, session=session, session_saver=session_saver, **kwargs) # return login result return provider.login() else: # Act like backend. self.backend(adapter) def credentials(self, credentials): """ Deserializes credentials. :param credentials: Credentials serialized with :meth:`.Credentials.serialize` or :class:`.Credentials` instance. :returns: :class:`.Credentials` """ return Credentials.deserialize(self.config, credentials) def access(self, credentials, url, params=None, method='GET', headers=None, body='', max_redirects=5, content_parser=None): """ Accesses **protected resource** on behalf of the **user**. :param credentials: The **user's** :class:`.Credentials` (serialized or normal). :param str url: The **protected resource** URL. :param str method: HTTP method of the request. :param dict headers: HTTP headers of the request. :param str body: Body of ``POST``, ``PUT`` and ``PATCH`` requests. :param int max_redirects: Maximum number of HTTP redirects to follow. :param function content_parser: A function to be used to parse the :attr:`.Response.data` from :attr:`.Response.content`. :returns: :class:`.Response` """ # Deserialize credentials. credentials = Credentials.deserialize(self.config, credentials) # Resolve provider class. ProviderClass = credentials.provider_class logging.info('ACCESS HEADERS: {0}'.format(headers)) # Access resource and return response. provider = ProviderClass( self, adapter=None, provider_name=credentials.provider_name) provider.credentials = credentials return provider.access(url=url, params=params, method=method, headers=headers, body=body, max_redirects=max_redirects, content_parser=content_parser) def async_access(self, *args, **kwargs): """ Same as :meth:`.Authomatic.access` but runs asynchronously in a separate thread. .. warning:: |async| :returns: :class:`.Future` instance representing the separate thread. """ return Future(self.access, *args, **kwargs) def request_elements( self, credentials=None, url=None, method='GET', params=None, headers=None, body='', json_input=None, return_json=False ): """ Creates request elements for accessing **protected resource of a user**. Required arguments are :data:`credentials` and :data:`url`. You can pass :data:`credentials`, :data:`url`, :data:`method`, and :data:`params` as a JSON object. :param credentials: The **user's** credentials (can be serialized). :param str url: The url of the protected resource. :param str method: The HTTP method of the request. :param dict params: Dictionary of request parameters. :param dict headers: Dictionary of request headers. :param str body: Body of ``POST``, ``PUT`` and ``PATCH`` requests. :param str json_input: you can pass :data:`credentials`, :data:`url`, :data:`method`, :data:`params` and :data:`headers` in a JSON object. Values from arguments will be used for missing properties. :: { "credentials": "###", "url": "https://example.com/api", "method": "POST", "params": { "foo": "bar" }, "headers": { "baz": "bing", "Authorization": "Bearer ###" }, "body": "Foo bar baz bing." } :param bool return_json: if ``True`` the function returns a json object. :: { "url": "https://example.com/api", "method": "POST", "params": { "access_token": "###", "foo": "bar" }, "headers": { "baz": "bing", "Authorization": "Bearer ###" }, "body": "Foo bar baz bing." } :returns: :class:`.RequestElements` or JSON string. """ # Parse values from JSON if json_input: parsed_input = json.loads(json_input) credentials = parsed_input.get('credentials', credentials) url = parsed_input.get('url', url) method = parsed_input.get('method', method) params = parsed_input.get('params', params) headers = parsed_input.get('headers', headers) body = parsed_input.get('body', body) if not credentials and url: raise RequestElementsError( 'To create request elements, you must provide credentials ' 'and URL either as keyword arguments or in the JSON object!') # Get the provider class credentials = Credentials.deserialize(self.config, credentials) ProviderClass = credentials.provider_class # Create request elements request_elements = ProviderClass.create_request_elements( ProviderClass.PROTECTED_RESOURCE_REQUEST_TYPE, credentials=credentials, url=url, method=method, params=params, headers=headers, body=body) if return_json: return request_elements.to_json() else: return request_elements def backend(self, adapter): """ Converts a *request handler* to a JSON backend which you can use with :ref:`authomatic.js `. Just call it inside a *request handler* like this: :: class JSONHandler(webapp2.RequestHandler): def get(self): authomatic.backend(Webapp2Adapter(self)) :param adapter: The only argument is an :doc:`adapter `. The *request handler* will now accept these request parameters: :param str type: Type of the request. Either ``auto``, ``fetch`` or ``elements``. Default is ``auto``. :param str credentials: Serialized :class:`.Credentials`. :param str url: URL of the **protected resource** request. :param str method: HTTP method of the **protected resource** request. :param str body: HTTP body of the **protected resource** request. :param JSON params: HTTP params of the **protected resource** request as a JSON object. :param JSON headers: HTTP headers of the **protected resource** request as a JSON object. :param JSON json: You can pass all of the aforementioned params except ``type`` in a JSON object. .. code-block:: javascript { "credentials": "######", "url": "https://example.com", "method": "POST", "params": {"foo": "bar"}, "headers": {"baz": "bing"}, "body": "the body of the request" } Depending on the ``type`` param, the handler will either write a JSON object with *request elements* to the response, and add an ``Authomatic-Response-To: elements`` response header, ... .. code-block:: javascript { "url": "https://example.com/api", "method": "POST", "params": { "access_token": "###", "foo": "bar" }, "headers": { "baz": "bing", "Authorization": "Bearer ###" } } ... or make a fetch to the **protected resource** and forward it's response content, status and headers with an additional ``Authomatic-Response-To: fetch`` header to the response. .. warning:: The backend will not work if you write anything to the response in the handler! """ AUTHOMATIC_HEADER = 'Authomatic-Response-To' # Collect request params request_type = adapter.params.get('type', 'auto') json_input = adapter.params.get('json') credentials = adapter.params.get('credentials') url = adapter.params.get('url') method = adapter.params.get('method', 'GET') body = adapter.params.get('body', '') params = adapter.params.get('params') params = json.loads(params) if params else {} headers = adapter.params.get('headers') headers = json.loads(headers) if headers else {} ProviderClass = Credentials.deserialize( self.config, credentials).provider_class if request_type == 'auto': # If there is a "callback" param, it's a JSONP request. jsonp = params.get('callback') # JSONP is possible only with GET method. if ProviderClass.supports_jsonp and method == 'GET': request_type = 'elements' else: # Remove the JSONP callback if jsonp: params.pop('callback') request_type = 'fetch' if request_type == 'fetch': # Access protected resource response = self.access( credentials, url, params, method, headers, body) result = response.content # Forward status adapter.status = str(response.status) + ' ' + str(response.reason) # Forward headers for k, v in response.getheaders(): logging.info(' {0}: {1}'.format(k, v)) adapter.set_header(k, v) elif request_type == 'elements': # Create request elements if json_input: result = self.request_elements( json_input=json_input, return_json=True) else: result = self.request_elements(credentials=credentials, url=url, method=method, params=params, headers=headers, body=body, return_json=True) adapter.set_header('Content-Type', 'application/json') else: result = '{"error": "Bad Request!"}' # Add the authomatic header adapter.set_header(AUTHOMATIC_HEADER, request_type) # Write result to response adapter.write(result)