# -*- coding: utf-8 -*-
import collections
import copy
import datetime
from . import exceptions
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:
pass
try:
# Then XML.
return ElementTree.fromstring(body)
except:
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
break
else:
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 not k 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))
args = ', '.join(args)
return '{0}({1})'.format(name, args)
[docs]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)
[docs] 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)
[docs]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` 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')
[docs] 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()
[docs] 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)
[docs] 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
class SupportedUserAttributes(collections.namedtuple('SupportedUserAttributes',
[
'id',
'username',
'name',
'first_name',
'last_name',
'nickname',
'link',
'gender',
'timezone',
'locale',
'email',
'phone',
'picture',
'birth_date',
'country',
'city',
'postal_code',
])):
def __new__(cls, **kwargs):
defaults = dict((i, False) for i in SupportedUserAttributes._fields)
defaults.update(**kwargs)
return super(SupportedUserAttributes, cls).__new__(cls, **defaults)
[docs]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
[docs] def expire_in(self, value):
"""
Computes :attr:`.expiration_time` when the value is set.
"""
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
[docs] def expiration_time(self, value):
self._expiration_time = int(value)
self._expire_in = self._expiration_time - int(time.time())
@property
[docs] 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
[docs] 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
[docs] 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
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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
[docs] 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!')
provider_id = int(split[0])
# Get provider config by short name.
provider_name = id_to_name(config, provider_id)
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)
[docs]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
@property
[docs] 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)
[docs]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
[docs] def read(self, amt=None):
"""
Same as :meth:`httplib.HTTPResponse.read`.
:param amt:
"""
return self.httplib_response.read(amt)
[docs] def fileno(self):
"""
Same as :meth:`httplib.HTTPResponse.fileno`.
"""
return self.httplib_response.fileno()
@property
[docs] def content(self):
"""
The whole response content.
"""
if not self._content:
self._content = self.httplib_response.read().decode('utf-8')
return self._content
@property
[docs] 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
[docs]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 params.get('callback'):
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)
#===============================================================================
# Deprecated
#===============================================================================
def setup(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use instance of :class:`.Authomatic` class instead.
"""
logging.warn('The authomatic.setup function is deprecated and will be removed in version 0.1.0! ' + \
'Use an instance of the Authomatic class instead.')
global global_authomatic_instance
global_authomatic_instance = Authomatic(*args, **kwargs)
def login(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.login` method instead.
"""
logging.warn('The authomatic.login function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "login" method of the "Authomatic" class instead.')
return global_authomatic_instance.login(*args, **kwargs)
def credentials(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.credentials` method instead.
"""
logging.warn('The authomatic.credentials function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "credentials" method of the "Authomatic" class instead.')
return global_authomatic_instance.credentials(*args, **kwargs)
def access(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.access` method instead.
"""
logging.warn('The authomatic.access function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "access" method of the "Authomatic" class instead.')
return global_authomatic_instance.access(*args, **kwargs)
def async_access(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.async_access` method instead.
"""
logging.warn('The authomatic.async_access function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "async_access" method of the "Authomatic" class instead.')
return global_authomatic_instance.async_access(*args, **kwargs)
def request_elements(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.request_elements` method instead.
"""
logging.warn('The authomatic.request_elements function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "request_elements" method of the "Authomatic" class instead.')
return global_authomatic_instance.request_elements(*args, **kwargs)
def backend(*args, **kwargs):
"""
.. warning::
This function is **deprecated** and will be removed in version 0.1.0!
Use the :meth:`.Authomatic.backend` method instead.
"""
logging.warn('The authomatic.backend function is deprecated and will be removed in version 0.1.0! ' + \
'Use the "backend" method of the "Authomatic" class instead.')
return global_authomatic_instance.backend(*args, **kwargs)