##// END OF EJS Templates
pass-change: don't trigger check on ops ping
pass-change: don't trigger check on ops ping

File last commit:

r3912:9bf26830 default
r4941:77ff22a4 default
Show More
__init__.py
1012 lines | 29.5 KiB | text/x-python | PythonLexer
# -*- coding: utf-8 -*-
"""
Abstract Classes for Providers
------------------------------
Abstract base classes for implementation of protocol specific providers.
.. note::
Attributes prefixed with ``_x_`` serve the purpose of unification
of differences across providers.
.. autosummary::
login_decorator
BaseProvider
AuthorizationProvider
AuthenticationProvider
"""
import abc
import base64
import hashlib
import logging
import random
import sys
import traceback
import uuid
import authomatic.core
from authomatic.exceptions import (
ConfigError,
FetchError,
CredentialsError,
)
from authomatic import six
from authomatic.six.moves import urllib_parse as parse
from authomatic.six.moves import http_client
from authomatic.exceptions import CancellationError
__all__ = [
'BaseProvider',
'AuthorizationProvider',
'AuthenticationProvider',
'login_decorator']
def _error_traceback_html(exc_info, traceback_):
"""
Generates error traceback HTML.
:param tuple exc_info:
Output of :func:`sys.exc_info` function.
:param traceback:
Output of :func:`traceback.format_exc` function.
"""
html = """
<html>
<head>
<title>ERROR: {error}</title>
</head>
<body style="font-family: sans-serif">
<h4>The Authomatic library encountered an error!</h4>
<h1>{error}</h1>
<pre>{traceback}</pre>
</body>
</html>
"""
return html.format(error=exc_info[1], traceback=traceback_)
def login_decorator(func):
"""
Decorate the :meth:`.BaseProvider.login` implementations with this
decorator.
Provides mechanism for error reporting and returning result which
makes the :meth:`.BaseProvider.login` implementation cleaner.
"""
def wrap(provider, *args, **kwargs):
error = None
result = authomatic.core.LoginResult(provider)
try:
func(provider, *args, **kwargs)
except Exception as e: # pylint:disable=broad-except
if provider.settings.report_errors:
error = e
if not isinstance(error, CancellationError):
provider._log(
logging.ERROR,
u'Reported suppressed exception: {0}!'.format(
repr(error)),
exc_info=1)
else:
if provider.settings.debug:
# TODO: Check whether it actually works without middleware
provider.write(
_error_traceback_html(
sys.exc_info(),
traceback.format_exc()))
raise
# If there is user or error the login procedure has finished
if provider.user or error:
result = authomatic.core.LoginResult(provider)
# Add error to result
result.error = error
# delete session cookie
if isinstance(provider.session, authomatic.core.Session):
provider.session.delete()
provider._log(logging.INFO, u'Procedure finished.')
if provider.callback:
provider.callback(result)
return result
else:
# Save session
provider.save_session()
return wrap
class BaseProvider(object):
"""
Abstract base class for all providers.
"""
PROVIDER_TYPE_ID = 0
_repr_ignore = ('user',)
__metaclass__ = abc.ABCMeta
supported_user_attributes = authomatic.core.SupportedUserAttributes()
def __init__(self, settings, adapter, provider_name, session=None,
session_saver=None, callback=None, js_callback=None,
prefix='authomatic', **kwargs):
self.settings = settings
self.adapter = adapter
self.session = session
self.save_session = session_saver
#: :class:`str` The provider name as specified in the :doc:`config`.
self.name = provider_name
#: :class:`callable` An optional callback called when the login
#: procedure is finished with :class:`.core.LoginResult` passed as
#: argument.
self.callback = callback
#: :class:`str` Name of an optional javascript callback.
self.js_callback = js_callback
#: :class:`.core.User`.
self.user = None
#: :class:`bool` If ``True``, the
#: :attr:`.BaseProvider.user_authorization_url` will be displayed
#: in a *popup mode*, if the **provider** supports it.
self.popup = self._kwarg(kwargs, 'popup')
@property
def url(self):
return self.adapter.url
@property
def params(self):
return self.adapter.params
def write(self, value):
self.adapter.write(value)
def set_header(self, key, value):
self.adapter.set_header(key, value)
def set_status(self, status):
self.adapter.set_status(status)
def redirect(self, url):
self.set_status('302 Found')
self.set_header('Location', url)
# ========================================================================
# Abstract methods
# ========================================================================
@abc.abstractmethod
def login(self):
"""
Launches the *login procedure* to get **user's credentials** from
**provider**.
Should be decorated with :func:`.login_decorator`. The *login
procedure* is considered finished when the :attr:`.user`
attribute is not empty when the method runs out of it's flow or
when there are errors.
"""
# ========================================================================
# Exposed methods
# ========================================================================
def to_dict(self):
"""
Converts the provider instance to a :class:`dict`.
:returns:
:class:`dict`
"""
return dict(name=self.name,
id=getattr(self, 'id', None),
type_id=self.type_id,
type=self.get_type(),
scope=getattr(self, 'scope', None),
user=self.user.id if self.user else None)
@classmethod
def get_type(cls):
"""
Returns the provider type.
:returns:
:class:`str` The full dotted path to base class e.g.
:literal:`"authomatic.providers.oauth2.OAuth2"`.
"""
return cls.__module__ + '.' + cls.__bases__[0].__name__
def update_user(self):
"""
Updates and returns :attr:`.user`.
:returns:
:class:`.User`
"""
# ========================================================================
# Internal methods
# ========================================================================
@property
def type_id(self):
pass
def _kwarg(self, kwargs, kwname, default=None):
"""
Resolves keyword arguments from constructor or :doc:`config`.
.. note::
The keyword arguments take this order of precedence:
1. Arguments passed to constructor through the
:func:`authomatic.login`.
2. Provider specific arguments from :doc:`config`.
3. Arguments from :doc:`config` set in the ``__defaults__`` key.
2. The value from :data:`default` argument.
:param dict kwargs:
Keyword arguments dictionary.
:param str kwname:
Name of the desired keyword argument.
"""
return kwargs.get(kwname) or \
self.settings.config.get(self.name, {}).get(kwname) or \
self.settings.config.get('__defaults__', {}).get(kwname) or \
default
def _session_key(self, key):
"""
Generates session key string.
:param str key:
e.g. ``"authomatic:facebook:key"``
"""
return '{0}:{1}:{2}'.format(self.settings.prefix, self.name, key)
def _session_set(self, key, value):
"""
Saves a value to session.
"""
self.session[self._session_key(key)] = value
def _session_get(self, key):
"""
Retrieves a value from session.
"""
return self.session.get(self._session_key(key))
@staticmethod
def csrf_generator(secret):
"""
Generates CSRF token.
Inspired by this article:
http://blog.ptsecurity.com/2012/10/random-number-security-in-python.html
:returns:
:class:`str` Random unguessable string.
"""
# Create hash from random string plus salt.
hashed = hashlib.md5(uuid.uuid4().bytes + six.b(secret)).hexdigest()
# Each time return random portion of the hash.
span = 5
shift = random.randint(0, span)
return hashed[shift:shift - span - 1]
@classmethod
def _log(cls, level, msg, **kwargs):
"""
Logs a message with pre-formatted prefix.
:param int level:
Logging level as specified in the
`login module <http://docs.python.org/2/library/logging.html>`_ of
Python standard library.
:param str msg:
The actual message.
"""
logger = getattr(cls, '_logger', None) or authomatic.core._logger
logger.log(
level, ': '.join(
('authomatic', cls.__name__, msg)), **kwargs)
def _fetch(self, url, method='GET', params=None, headers=None,
body='', max_redirects=5, content_parser=None):
"""
Fetches a URL.
:param str url:
The URL to fetch.
:param str method:
HTTP method of the request.
:param dict params:
Dictionary of request parameters.
:param dict headers:
HTTP headers of the request.
:param str body:
Body of ``POST``, ``PUT`` and ``PATCH`` requests.
:param int max_redirects:
Number of maximum HTTP redirects to follow.
:param function content_parser:
A callable to be used to parse the :attr:`.Response.data`
from :attr:`.Response.content`.
"""
# 'magic' using _kwarg method
# pylint:disable=no-member
params = params or {}
params.update(self.access_params)
headers = headers or {}
headers.update(self.access_headers)
scheme, host, path, query, fragment = parse.urlsplit(url)
query = parse.urlencode(params)
if method in ('POST', 'PUT', 'PATCH'):
if not body:
# Put querystring to body
body = query
query = ''
headers.update(
{'Content-Type': 'application/x-www-form-urlencoded'})
request_path = parse.urlunsplit(('', '', path or '', query or '', ''))
self._log(logging.DEBUG, u' \u251C\u2500 host: {0}'.format(host))
self._log(
logging.DEBUG,
u' \u251C\u2500 path: {0}'.format(request_path))
self._log(logging.DEBUG, u' \u251C\u2500 method: {0}'.format(method))
self._log(logging.DEBUG, u' \u251C\u2500 body: {0}'.format(body))
self._log(logging.DEBUG, u' \u251C\u2500 params: {0}'.format(params))
self._log(logging.DEBUG, u' \u2514\u2500 headers: {0}'.format(headers))
# Connect
if scheme.lower() == 'https':
connection = http_client.HTTPSConnection(host)
else:
connection = http_client.HTTPConnection(host)
try:
connection.request(method, request_path, body, headers)
except Exception as e:
raise FetchError('Fetching URL failed',
original_message=str(e),
url=request_path)
response = connection.getresponse()
location = response.getheader('Location')
if response.status in (300, 301, 302, 303, 307) and location:
if location == url:
raise FetchError('Url redirects to itself!',
url=location,
status=response.status)
elif max_redirects > 0:
remaining_redirects = max_redirects - 1
self._log(logging.DEBUG, u'Redirecting to {0}'.format(url))
self._log(logging.DEBUG, u'Remaining redirects: {0}'
.format(remaining_redirects))
# Call this method again.
response = self._fetch(url=location,
params=params,
method=method,
headers=headers,
max_redirects=remaining_redirects)
else:
raise FetchError('Max redirects reached!',
url=location,
status=response.status)
else:
self._log(logging.DEBUG, u'Got response:')
self._log(logging.DEBUG, u' \u251C\u2500 url: {0}'.format(url))
self._log(
logging.DEBUG,
u' \u251C\u2500 status: {0}'.format(
response.status))
self._log(
logging.DEBUG,
u' \u2514\u2500 headers: {0}'.format(
response.getheaders()))
return authomatic.core.Response(response, content_parser)
def _update_or_create_user(self, data, credentials=None, content=None):
"""
Updates or creates :attr:`.user`.
:returns:
:class:`.User`
"""
if not self.user:
self.user = authomatic.core.User(self, credentials=credentials)
self.user.content = content
self.user.data = data
# Update.
for key in self.user.__dict__:
# Exclude data.
if key not in ('data', 'content'):
# Extract every data item whose key matches the user
# property name, but only if it has a value.
value = data.get(key)
if value:
setattr(self.user, key, value)
# Handle different structure of data by different providers.
self.user = self._x_user_parser(self.user, data)
if self.user.id:
self.user.id = str(self.user.id)
# TODO: Move to User
# If there is no user.name,
if not self.user.name:
if self.user.first_name and self.user.last_name:
# Create it from first name and last name if available.
self.user.name = ' '.join((self.user.first_name,
self.user.last_name))
else:
# Or use one of these.
self.user.name = (self.user.username or self.user.nickname or
self.user.first_name or self.user.last_name)
if not self.user.location:
if self.user.city and self.user.country:
self.user.location = '{0}, {1}'.format(self.user.city,
self.user.country)
else:
self.user.location = self.user.city or self.user.country
return self.user
@staticmethod
def _x_user_parser(user, data):
"""
Handles different structure of user info data by different providers.
:param user:
:class:`.User`
:param dict data:
User info data returned by provider.
"""
return user
@staticmethod
def _http_status_in_category(status, category):
"""
Checks whether a HTTP status code is in the category denoted by the
hundreds digit.
"""
assert category < 10, 'HTTP status category must be a one-digit int!'
cat = category * 100
return status >= cat and status < cat + 100
class AuthorizationProvider(BaseProvider):
"""
Base provider for *authorization protocols* i.e. protocols which allow a
**provider** to authorize a **consumer** to access **protected resources**
of a **user**.
e.g. `OAuth 2.0 <http://oauth.net/2/>`_ or `OAuth 1.0a
<http://oauth.net/core/1.0a/>`_.
"""
USER_AUTHORIZATION_REQUEST_TYPE = 2
ACCESS_TOKEN_REQUEST_TYPE = 3
PROTECTED_RESOURCE_REQUEST_TYPE = 4
REFRESH_TOKEN_REQUEST_TYPE = 5
BEARER = 'Bearer'
_x_term_dict = {}
#: If ``True`` the provider doesn't support Cross-site HTTP requests.
same_origin = True
#: :class:`bool` Whether the provider supports JSONP requests.
supports_jsonp = False
# Whether to use the HTTP Authorization header.
_x_use_authorization_header = True
def __init__(self, *args, **kwargs):
"""
Accepts additional keyword arguments:
:arg str consumer_key:
The *key* assigned to our application (**consumer**) by the
**provider**.
:arg str consumer_secret:
The *secret* assigned to our application (**consumer**) by the
**provider**.
:arg int id:
A unique numeric ID used to serialize :class:`.Credentials`.
:arg dict user_authorization_params:
A dictionary of additional request parameters for
**user authorization request**.
:arg dict access_token_params:
A dictionary of additional request parameters for
**access_with_credentials token request**.
:arg dict access_headers:
A dictionary of default HTTP headers that will be used when
accessing **user's** protected resources.
Applied by :meth:`.access()`, :meth:`.update_user()` and
:meth:`.User.update()`
:arg dict access_params:
A dictionary of default query string parameters that will be used
when accessing **user's** protected resources.
Applied by :meth:`.access()`, :meth:`.update_user()` and
:meth:`.User.update()`
"""
super(AuthorizationProvider, self).__init__(*args, **kwargs)
self.consumer_key = self._kwarg(kwargs, 'consumer_key')
self.consumer_secret = self._kwarg(kwargs, 'consumer_secret')
self.user_authorization_params = self._kwarg(
kwargs, 'user_authorization_params', {})
self.access_token_headers = self._kwarg(
kwargs, 'user_authorization_headers', {})
self.access_token_params = self._kwarg(
kwargs, 'access_token_params', {})
self.id = self._kwarg(kwargs, 'id')
self.access_headers = self._kwarg(kwargs, 'access_headers', {})
self.access_params = self._kwarg(kwargs, 'access_params', {})
#: :class:`.Credentials` to access **user's protected resources**.
self.credentials = authomatic.core.Credentials(
self.settings.config, provider=self)
#: Response of the *access token request*.
self.access_token_response = None
# ========================================================================
# Abstract properties
# ========================================================================
@abc.abstractproperty
def user_authorization_url(self):
"""
:class:`str` URL to which we redirect the **user** to grant our app
i.e. the **consumer** an **authorization** to access his
**protected resources**. See
http://tools.ietf.org/html/rfc6749#section-4.1.1 and
http://oauth.net/core/1.0a/#auth_step2.
"""
@abc.abstractproperty
def access_token_url(self):
"""
:class:`str` URL where we can get the *access token* to access
**protected resources** of a **user**. See
http://tools.ietf.org/html/rfc6749#section-4.1.3 and
http://oauth.net/core/1.0a/#auth_step3.
"""
@abc.abstractproperty
def user_info_url(self):
"""
:class:`str` URL where we can get the **user** info.
see http://tools.ietf.org/html/rfc6749#section-7 and
http://oauth.net/core/1.0a/#anchor12.
"""
# ========================================================================
# Abstract methods
# ========================================================================
@abc.abstractmethod
def to_tuple(self, credentials):
"""
Must convert :data:`credentials` to a :class:`tuple` to be used by
:meth:`.Credentials.serialize`.
.. warning::
|classmethod|
:param credentials:
:class:`.Credentials`
:returns:
:class:`tuple`
"""
@abc.abstractmethod
def reconstruct(self, deserialized_tuple, credentials, cfg):
"""
Must convert the :data:`deserialized_tuple` back to
:class:`.Credentials`.
.. warning::
|classmethod|
:param tuple deserialized_tuple:
A tuple whose first index is the :attr:`.id` and the rest
are all the items of the :class:`tuple` created by
:meth:`.to_tuple`.
:param credentials:
A :class:`.Credentials` instance.
:param dict cfg:
Provider configuration from :doc:`config`.
"""
@abc.abstractmethod
def create_request_elements(self, request_type, credentials,
url, method='GET', params=None, headers=None,
body=''):
"""
Must return :class:`.RequestElements`.
.. warning::
|classmethod|
:param int request_type:
Type of the request specified by one of the class's constants.
:param credentials:
:class:`.Credentials` of the **user** whose
**protected resource** we want to access.
:param str url:
URL of the request.
:param str method:
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.
:returns:
:class:`.RequestElements`
"""
# ========================================================================
# Exposed methods
# ========================================================================
@property
def type_id(self):
"""
A short string representing the provider implementation id used for
serialization of :class:`.Credentials` and to identify the type of
provider in JavaScript.
The part before hyphen denotes the type of the provider, the part
after hyphen denotes the class id e.g.
``oauth2.Facebook.type_id = '2-5'``,
``oauth1.Twitter.type_id = '1-5'``.
"""
cls = self.__class__
mod = sys.modules.get(cls.__module__)
return str(self.PROVIDER_TYPE_ID) + '-' + \
str(mod.PROVIDER_ID_MAP.index(cls))
def access(self, url, params=None, method='GET', headers=None,
body='', max_redirects=5, content_parser=None):
"""
Fetches the **protected resource** of an authenticated **user**.
:param credentials:
The **user's** :class:`.Credentials` (serialized or normal).
:param str url:
The URL of the **protected resource**.
: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`
"""
if not self.user and not self.credentials:
raise CredentialsError(u'There is no authenticated user!')
headers = headers or {}
self._log(
logging.INFO,
u'Accessing protected resource {0}.'.format(url))
request_elements = self.create_request_elements(
request_type=self.PROTECTED_RESOURCE_REQUEST_TYPE,
credentials=self.credentials,
url=url,
body=body,
params=params,
headers=headers,
method=method
)
response = self._fetch(*request_elements,
max_redirects=max_redirects,
content_parser=content_parser)
self._log(
logging.INFO,
u'Got response. HTTP status = {0}.'.format(
response.status))
return response
def async_access(self, *args, **kwargs):
"""
Same as :meth:`.access` but runs asynchronously in a separate thread.
.. warning::
|async|
:returns:
:class:`.Future` instance representing the separate thread.
"""
return authomatic.core.Future(self.access, *args, **kwargs)
def update_user(self):
"""
Updates the :attr:`.BaseProvider.user`.
.. warning::
Fetches the :attr:`.user_info_url`!
:returns:
:class:`.UserInfoResponse`
"""
if self.user_info_url:
response = self._access_user_info()
self.user = self._update_or_create_user(response.data,
content=response.content)
return authomatic.core.UserInfoResponse(self.user,
response.httplib_response)
# ========================================================================
# Internal methods
# ========================================================================
@classmethod
def _authorization_header(cls, credentials):
"""
Creates authorization headers if the provider supports it. See:
http://en.wikipedia.org/wiki/Basic_access_authentication.
:param credentials:
:class:`.Credentials`
:returns:
Headers as :class:`dict`.
"""
if cls._x_use_authorization_header:
res = ':'.join(
(credentials.consumer_key,
credentials.consumer_secret))
res = base64.b64encode(six.b(res)).decode()
return {'Authorization': 'Basic {0}'.format(res)}
else:
return {}
def _check_consumer(self):
"""
Validates the :attr:`.consumer`.
"""
# 'magic' using _kwarg method
# pylint:disable=no-member
if not self.consumer.key:
raise ConfigError(
'Consumer key not specified for provider {0}!'.format(
self.name))
if not self.consumer.secret:
raise ConfigError(
'Consumer secret not specified for provider {0}!'.format(
self.name))
@staticmethod
def _split_url(url):
"""
Splits given url to url base and params converted to list of tuples.
"""
split = parse.urlsplit(url)
base = parse.urlunsplit((split.scheme, split.netloc, split.path, 0, 0))
params = parse.parse_qsl(split.query, True)
return base, params
@classmethod
def _x_request_elements_filter(
cls, request_type, request_elements, credentials):
"""
Override this to handle special request requirements of zealous
providers.
.. warning::
|classmethod|
:param int request_type:
Type of request.
:param request_elements:
:class:`.RequestElements`
:param credentials:
:class:`.Credentials`
:returns:
:class:`.RequestElements`
"""
return request_elements
@staticmethod
def _x_credentials_parser(credentials, data):
"""
Override this to handle differences in naming conventions across
providers.
:param credentials:
:class:`.Credentials`
:param dict data:
Response data dictionary.
:returns:
:class:`.Credentials`
"""
return credentials
def _access_user_info(self):
"""
Accesses the :attr:`.user_info_url`.
:returns:
:class:`.UserInfoResponse`
"""
url = self.user_info_url.format(**self.user.__dict__)
return self.access(url)
class AuthenticationProvider(BaseProvider):
"""
Base provider for *authentication protocols* i.e. protocols which allow a
**provider** to authenticate a *claimed identity* of a **user**.
e.g. `OpenID <http://openid.net/>`_.
"""
#: Indicates whether the **provider** supports access_with_credentials to
#: **user's** protected resources.
# TODO: Useless
has_protected_resources = False
def __init__(self, *args, **kwargs):
super(AuthenticationProvider, self).__init__(*args, **kwargs)
# Lookup default identifier, if available in provider
default_identifier = getattr(self, 'identifier', None)
# Allow for custom name for the "id" querystring parameter.
self.identifier_param = kwargs.get('identifier_param', 'id')
# Get the identifier from request params, or use default as fallback.
self.identifier = self.params.get(
self.identifier_param, default_identifier)
PROVIDER_ID_MAP = [
AuthenticationProvider,
AuthorizationProvider,
BaseProvider,
]