##// END OF EJS Templates
typing: added typing module for annotations
typing: added typing module for annotations

File last commit:

r4952:9860e3ac default
r5034:06d9c955 default
Show More
core.py
1761 lines | 51.9 KiB | text/x-python | PythonLexer
# -*- coding: utf-8 -*-
import collections
import copy
import datetime
import hashlib
import hmac
import json
import logging
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() <http://webapp-
improved.appspot.com/api/webapp2.html#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 <providers>` 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 <https://developers.google.com/appengine/docs/python/users/userclass>`_ 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 <providers>` 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 <providers>` 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 = provider_id
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 <providers>` 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() <js_setup>`
function of :ref:`javascript.js <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() <js_setup>` function of
:ref:`javascript.js <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 """
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
<script type="text/javascript">
{js}
</script>
</body>
</html>
""".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
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 <http://docs.python.org/2/library/logging.html>`_.
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 </reference/providers>` 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 <providers>` 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 <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 <adapters>`.
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 is '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)