├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── Procfile ├── README.rst ├── allaccess ├── __init__.py ├── admin.py ├── apps.py ├── backends.py ├── clients.py ├── compat.py ├── context_processors.py ├── fields.py ├── fixtures │ └── common_providers.json ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150511_1853.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ ├── base.py │ ├── custom │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── test_backends.py │ ├── test_clients.py │ ├── test_context_processors.py │ ├── test_models.py │ ├── test_views.py │ └── urls.py ├── urls.py └── views.py ├── docs ├── Makefile ├── api-access.rst ├── conf.py ├── contributing.rst ├── customize-views.rst ├── index.rst ├── make.bat ├── providers.rst ├── quick-start.rst └── releases.rst ├── example ├── db.sqlite3 ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── manage.py ├── static │ ├── css │ │ └── site.css │ └── font │ │ ├── zocial-regular-webfont.eot │ │ ├── zocial-regular-webfont.svg │ │ ├── zocial-regular-webfont.ttf │ │ └── zocial-regular-webfont.woff └── templates │ └── home.html ├── requirements.txt ├── runtests.py ├── runtime.txt ├── setup.cfg ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .project 3 | .pydevproject 4 | *~ 5 | *.db 6 | *.orig 7 | *.DS_Store 8 | .coverage 9 | .tox 10 | *.egg-info/* 11 | docs/_build/* 12 | dist/* 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - "3.5" 7 | 8 | env: 9 | - TOXENV=py27-django18-normal,py33-django18-normal 10 | - TOXENV=py27-django18-custom,py33-django18-custom 11 | - TOXENV=py27-django19-normal,py34-django19-normal 12 | - TOXENV=py27-django19-custom,py34-django19-custom 13 | - TOXENV=py27-django110-normal,py34-django110-normal,py35-django110-normal 14 | - TOXENV=py27-django110-custom,py34-django110-custom,py35-django110-custom 15 | 16 | cache: 17 | directories: 18 | - $HOME/.cache/pip 19 | 20 | install: 21 | - pip install tox pip wheel codecov -U 22 | 23 | script: 24 | - tox 25 | 26 | after_success: 27 | - codecov -e TOX_ENV 28 | 29 | branches: 30 | only: 31 | - master 32 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Primary author: 2 | 3 | Mark Lavin 4 | 5 | Additional contributors: 6 | 7 | Jharrod LaFon 8 | Marco Seguri 9 | Dan Poirier 10 | Florian Demmer 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2016, Mark Lavin 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.rst 4 | include allaccess/fixtures/common_providers.json 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn example.wsgi --log-file=- 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-all-access 2 | =================== 3 | 4 | django-all-access is a reusable application for user registration and authentication 5 | from OAuth 1.0 and OAuth 2.0 providers such as Twitter and Facebook. 6 | 7 | The goal of this project is to make it easy to create your own workflows for 8 | authenticating with these remote APIs. django-all-access will provide the simple 9 | views with sane defaults along with hooks to override the default behavior. 10 | 11 | .. image:: https://travis-ci.org/mlavin/django-all-access.svg?branch=master 12 | :target: https://travis-ci.org/mlavin/django-all-access 13 | 14 | .. image:: https://codecov.io/github/mlavin/django-all-access/coverage.svg?branch=master 15 | :target: https://codecov.io/github/mlavin/django-all-access?branch=master 16 | 17 | You can find a basic demo application running at http://django-all-access.mlavin.org/ 18 | 19 | Features 20 | ------------------------------------ 21 | 22 | - Sane and secure defaults for OAuth authentication 23 | - Easy customization through class-based views 24 | - Built on the amazing `requests `_ library 25 | 26 | 27 | Installation 28 | ------------------------------------ 29 | 30 | It is easiest to install django-all-access from PyPi using pip:: 31 | 32 | pip install django-all-access 33 | 34 | django-all-access requires Python 2.7 or 3.3+ along with the following Python 35 | packages:: 36 | 37 | django>=1.8 38 | pycrypto>=2.4 39 | requests>=2.0 40 | requests_oauthlib>=0.4.2 41 | oauthlib>=0.6.2 42 | 43 | 44 | Documentation 45 | -------------------------------------- 46 | 47 | Additional documentation on using django-all-access is available on 48 | `Read The Docs `_. 49 | 50 | 51 | License 52 | -------------------------------------- 53 | 54 | django-all-access is released under the BSD License. See the 55 | `LICENSE `_ file for more details. 56 | 57 | 58 | Contributing 59 | -------------------------------------- 60 | 61 | If you have questions about using django-all-access or want to follow updates about 62 | the project you can join the `mailing list `_ 63 | through Google Groups. 64 | 65 | If you think you've found a bug or are interested in contributing to this project 66 | check out `django-all-access on Github `_. 67 | 68 | -------------------------------------------------------------------------------- /allaccess/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-all-access is a reusable application for user registration and authentication 3 | from OAuth 1.0 and OAuth 2.0 providers such as Twitter and Facebook. 4 | """ 5 | 6 | 7 | __version__ = '0.10.0.dev.0' 8 | 9 | 10 | default_app_config = 'allaccess.apps.AllAccessConfig' 11 | 12 | 13 | import logging 14 | 15 | 16 | class NullHandler(logging.Handler): 17 | "No-op logging handler." 18 | 19 | def emit(self, record): 20 | pass 21 | 22 | # Configure null handler to prevent "No handlers could be found..." errors 23 | logging.getLogger('allaccess').addHandler(NullHandler()) 24 | -------------------------------------------------------------------------------- /allaccess/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Provider, AccountAccess 4 | 5 | 6 | class ProviderAdmin(admin.ModelAdmin): 7 | "Admin customization for OAuth providers." 8 | 9 | list_display = ('name', 'enabled', ) 10 | 11 | 12 | class AccountAccessAdmin(admin.ModelAdmin): 13 | "Admin customization for accounts." 14 | 15 | list_display = ('__str__', 'provider', 'user', 'created', 'modified', ) 16 | list_filter = ('provider', 'created', 'modified', ) 17 | 18 | 19 | admin.site.register(Provider, ProviderAdmin) 20 | admin.site.register(AccountAccess, AccountAccessAdmin) 21 | -------------------------------------------------------------------------------- /allaccess/apps.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.apps import AppConfig 3 | except ImportError: 4 | AppConfig = object 5 | 6 | 7 | class AllAccessConfig(AppConfig): 8 | name = 'allaccess' 9 | -------------------------------------------------------------------------------- /allaccess/backends.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.auth.backends import ModelBackend 4 | from django.db.models import Q 5 | 6 | from .models import Provider, AccountAccess 7 | 8 | 9 | class AuthorizedServiceBackend(ModelBackend): 10 | "Authentication backend for users registered with remote OAuth provider." 11 | 12 | def authenticate(self, provider=None, identifier=None): 13 | "Fetch user for a given provider by id." 14 | provider_q = Q(provider__name=provider) 15 | if isinstance(provider, Provider): 16 | provider_q = Q(provider=provider) 17 | try: 18 | access = AccountAccess.objects.filter( 19 | provider_q, identifier=identifier 20 | ).select_related('user')[0] 21 | except IndexError: 22 | return None 23 | else: 24 | return access.user 25 | -------------------------------------------------------------------------------- /allaccess/clients.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | import logging 5 | 6 | from django.utils.crypto import constant_time_compare, get_random_string 7 | from django.utils.encoding import force_text 8 | 9 | from requests.api import request 10 | from requests_oauthlib import OAuth1 11 | from requests.exceptions import RequestException 12 | 13 | from .compat import urlencode, parse_qs 14 | 15 | 16 | logger = logging.getLogger('allaccess.clients') 17 | 18 | 19 | class BaseOAuthClient(object): 20 | 21 | def __init__(self, provider, token=''): 22 | self.provider = provider 23 | self.token = token 24 | 25 | def get_access_token(self, request, callback=None): 26 | "Fetch access token from callback request." 27 | raise NotImplementedError('Defined in a sub-class') # pragma: no cover 28 | 29 | def get_profile_info(self, raw_token, profile_info_params={}): 30 | "Fetch user profile information." 31 | try: 32 | response = self.request('get', self.provider.profile_url, token=raw_token, params=profile_info_params) 33 | response.raise_for_status() 34 | except RequestException as e: 35 | logger.error('Unable to fetch user profile: {0}'.format(e)) 36 | return None 37 | else: 38 | return response.json() or response.text 39 | 40 | def get_redirect_args(self, request, callback): 41 | "Get request parameters for redirect url." 42 | raise NotImplementedError('Defined in a sub-class') # pragma: no cover 43 | 44 | def get_redirect_url(self, request, callback, parameters=None): 45 | "Build authentication redirect url." 46 | args = self.get_redirect_args(request, callback=callback) 47 | additional = parameters or {} 48 | args.update(additional) 49 | params = urlencode(args) 50 | return '{0}?{1}'.format(self.provider.authorization_url, params) 51 | 52 | def parse_raw_token(self, raw_token): 53 | "Parse token and secret from raw token response." 54 | raise NotImplementedError('Defined in a sub-class') # pragma: no cover 55 | 56 | def request(self, method, url, **kwargs): 57 | "Build remote url request." 58 | return request(method, url, **kwargs) 59 | 60 | @property 61 | def session_key(self): 62 | raise NotImplementedError('Defined in a sub-class') # pragma: no cover 63 | 64 | 65 | class OAuthClient(BaseOAuthClient): 66 | 67 | def get_access_token(self, request, callback=None): 68 | "Fetch access token from callback request." 69 | raw_token = request.session.get(self.session_key, None) 70 | verifier = request.GET.get('oauth_verifier', None) 71 | if raw_token is not None and verifier is not None: 72 | data = {'oauth_verifier': verifier} 73 | callback = request.build_absolute_uri(callback or request.path) 74 | callback = force_text(callback) 75 | try: 76 | response = self.request('post', self.provider.access_token_url, 77 | token=raw_token, data=data, oauth_callback=callback) 78 | response.raise_for_status() 79 | except RequestException as e: 80 | logger.error('Unable to fetch access token: {0}'.format(e)) 81 | return None 82 | else: 83 | return response.text 84 | return None 85 | 86 | def get_request_token(self, request, callback): 87 | "Fetch the OAuth request token. Only required for OAuth 1.0." 88 | callback = force_text(request.build_absolute_uri(callback)) 89 | try: 90 | response = self.request( 91 | 'post', self.provider.request_token_url, oauth_callback=callback) 92 | response.raise_for_status() 93 | except RequestException as e: 94 | logger.error('Unable to fetch request token: {0}'.format(e)) 95 | return None 96 | else: 97 | return response.text 98 | 99 | def get_redirect_args(self, request, callback): 100 | "Get request parameters for redirect url." 101 | callback = force_text(request.build_absolute_uri(callback)) 102 | raw_token = self.get_request_token(request, callback) 103 | token, secret = self.parse_raw_token(raw_token) 104 | if token is not None and secret is not None: 105 | request.session[self.session_key] = raw_token 106 | return { 107 | 'oauth_token': token, 108 | 'oauth_callback': callback, 109 | } 110 | 111 | def parse_raw_token(self, raw_token): 112 | "Parse token and secret from raw token response." 113 | if raw_token is None: 114 | return (None, None) 115 | qs = parse_qs(raw_token) 116 | token = qs.get('oauth_token', [None])[0] 117 | secret = qs.get('oauth_token_secret', [None])[0] 118 | return (token, secret) 119 | 120 | def request(self, method, url, **kwargs): 121 | "Build remote url request. Constructs necessary auth." 122 | user_token = kwargs.pop('token', self.token) 123 | token, secret = self.parse_raw_token(user_token) 124 | callback = kwargs.pop('oauth_callback', None) 125 | verifier = kwargs.get('data', {}).pop('oauth_verifier', None) 126 | oauth = OAuth1( 127 | resource_owner_key=token, 128 | resource_owner_secret=secret, 129 | client_key=self.provider.consumer_key, 130 | client_secret=self.provider.consumer_secret, 131 | verifier=verifier, 132 | callback_uri=callback, 133 | ) 134 | kwargs['auth'] = oauth 135 | return super(OAuthClient, self).request(method, url, **kwargs) 136 | 137 | @property 138 | def session_key(self): 139 | return 'allaccess-{0}-request-token'.format(self.provider.name) 140 | 141 | 142 | class OAuth2Client(BaseOAuthClient): 143 | 144 | def check_application_state(self, request, callback): 145 | "Check optional state parameter." 146 | stored = request.session.get(self.session_key, None) 147 | returned = request.GET.get('state', None) 148 | check = False 149 | if stored is not None: 150 | if returned is not None: 151 | check = constant_time_compare(stored, returned) 152 | else: 153 | logger.error('No state parameter returned by the provider.') 154 | else: 155 | logger.error('No state stored in the sesssion.') 156 | return check 157 | 158 | def get_access_token(self, request, callback=None): 159 | "Fetch access token from callback request." 160 | callback = request.build_absolute_uri(callback or request.path) 161 | if not self.check_application_state(request, callback): 162 | logger.error('Application state check failed.') 163 | return None 164 | if 'code' in request.GET: 165 | args = { 166 | 'client_id': self.provider.consumer_key, 167 | 'redirect_uri': callback, 168 | 'client_secret': self.provider.consumer_secret, 169 | 'code': request.GET['code'], 170 | 'grant_type': 'authorization_code', 171 | } 172 | else: 173 | logger.error('No code returned by the provider') 174 | return None 175 | try: 176 | response = self.request('post', self.provider.access_token_url, data=args) 177 | response.raise_for_status() 178 | except RequestException as e: 179 | logger.error('Unable to fetch access token: {0}'.format(e)) 180 | return None 181 | else: 182 | return response.text 183 | 184 | def get_application_state(self, request, callback): 185 | "Generate state optional parameter." 186 | return get_random_string(32) 187 | 188 | def get_redirect_args(self, request, callback): 189 | "Get request parameters for redirect url." 190 | callback = request.build_absolute_uri(callback) 191 | args = { 192 | 'client_id': self.provider.consumer_key, 193 | 'redirect_uri': callback, 194 | 'response_type': 'code', 195 | } 196 | state = self.get_application_state(request, callback) 197 | if state is not None: 198 | args['state'] = state 199 | request.session[self.session_key] = state 200 | return args 201 | 202 | def parse_raw_token(self, raw_token): 203 | "Parse token and secret from raw token response." 204 | if raw_token is None: 205 | return (None, None) 206 | # Load as json first then parse as query string 207 | try: 208 | token_data = json.loads(raw_token) 209 | except ValueError: 210 | qs = parse_qs(raw_token) 211 | token = qs.get('access_token', [None])[0] 212 | else: 213 | token = token_data.get('access_token', None) 214 | return (token, None) 215 | 216 | def request(self, method, url, **kwargs): 217 | "Build remote url request. Constructs necessary auth." 218 | user_token = kwargs.pop('token', self.token) 219 | token, _ = self.parse_raw_token(user_token) 220 | if token is not None: 221 | params = kwargs.get('params', {}) 222 | params['access_token'] = token 223 | kwargs['params'] = params 224 | return super(OAuth2Client, self).request(method, url, **kwargs) 225 | 226 | @property 227 | def session_key(self): 228 | return 'allaccess-{0}-request-state'.format(self.provider.name) 229 | 230 | 231 | def get_client(provider, token=''): 232 | "Return the API client for the given provider." 233 | cls = OAuth2Client 234 | if provider.request_token_url: 235 | cls = OAuthClient 236 | return cls(provider, token) 237 | -------------------------------------------------------------------------------- /allaccess/compat.py: -------------------------------------------------------------------------------- 1 | "Python and Django compatibility functions." 2 | from __future__ import unicode_literals 3 | 4 | # urllib 5 | try: 6 | from urllib.parse import urlencode, parse_qs, urlparse 7 | except ImportError: # pragma: no cover 8 | # Python 2.X 9 | from urllib import urlencode 10 | from urlparse import parse_qs, urlparse 11 | 12 | try: # pragma: no cover 13 | from google.appengine.ext import db 14 | APPENGINE = True 15 | except ImportError: 16 | APPENGINE = False 17 | 18 | 19 | try: # pragma: no cover 20 | from unittest.mock import patch, Mock 21 | except ImportError: 22 | from mock import patch, Mock 23 | -------------------------------------------------------------------------------- /allaccess/context_processors.py: -------------------------------------------------------------------------------- 1 | "Helpers to add provider and account access information to the template context." 2 | from __future__ import unicode_literals 3 | 4 | from django.utils.functional import SimpleLazyObject 5 | 6 | from .compat import APPENGINE 7 | from .models import Provider 8 | 9 | 10 | def _get_enabled(): 11 | """Wrapped function for filtering enabled providers.""" 12 | providers = Provider.objects.all() 13 | return [p for p in providers if p.enabled()] 14 | 15 | 16 | def available_providers(request): 17 | "Adds the list of enabled providers to the context." 18 | if APPENGINE: 19 | # Note: AppEngine inequality queries are limited to one property. 20 | # See https://developers.google.com/appengine/docs/python/datastore/queries#Python_Restrictions_on_queries 21 | # Users have also noted that the exclusion queries don't work 22 | # See https://github.com/mlavin/django-all-access/pull/46 23 | # So this is lazily-filtered in Python 24 | qs = SimpleLazyObject(lambda: _get_enabled()) 25 | else: 26 | qs = Provider.objects.filter(consumer_secret__isnull=False, consumer_key__isnull=False) 27 | return {'allaccess_providers': qs} 28 | -------------------------------------------------------------------------------- /allaccess/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import binascii 5 | import hmac 6 | 7 | from django.conf import settings 8 | from django.db import models 9 | from django.utils.crypto import constant_time_compare 10 | from django.utils.encoding import force_bytes, force_text 11 | 12 | try: 13 | import Crypto.Cipher.AES 14 | except ImportError: # pragma: no cover 15 | raise ImportError('PyCrypto is required to use django-all-access.') 16 | 17 | 18 | class SignatureException(Exception): 19 | pass 20 | 21 | 22 | class SignedAESEncryption(object): 23 | cipher_class = Crypto.Cipher.AES 24 | prefix = b'$AES' 25 | #: enable hmac signature of cypher text with the same key (default: True) 26 | sign = True 27 | 28 | def __init__(self, *args, **kwargs): 29 | self.cipher = self.cipher_class.new(self.get_key()) 30 | 31 | def get_key(self): 32 | return force_bytes(settings.SECRET_KEY.zfill(32))[:32] 33 | 34 | def get_signature(self, value): 35 | return force_bytes(hmac.new(self.get_key(), value).hexdigest()) 36 | 37 | def get_padding(self, value): 38 | # We always want at least 2 chars of padding (including zero byte), 39 | # so we could have up to block_size + 1 chars. 40 | mod = (len(value) + 2) % self.cipher.block_size 41 | return self.cipher.block_size - mod + 2 42 | 43 | def add_padding(self, clear_text): 44 | padding = self.get_padding(clear_text) 45 | if padding > 0: 46 | return clear_text + b'\x00' + b'*' * (padding - 1) 47 | return clear_text 48 | 49 | def split_value(self, value): 50 | #: split value from database into _, prefix, mac, cypher_text 51 | parts = value.split(b'$') 52 | if len(parts) == 3: 53 | parts.insert(2, None) 54 | return parts 55 | 56 | def is_encrypted(self, value): 57 | return value.startswith(self.prefix) 58 | 59 | def is_signed(self, value): 60 | #: value consists of 3 or 4 $ separated parts, check for mac in 2nd 61 | _, prefix, mac, cypher_text = self.split_value(value) 62 | return mac is not None 63 | 64 | def decrypt(self, cypher_text): 65 | _, prefix, mac, cypher_text = self.split_value(cypher_text) 66 | if self.sign and mac and \ 67 | not constant_time_compare(self.get_signature(cypher_text), mac): 68 | raise SignatureException( 69 | 'EncryptedField cannot be decrypted. ' 70 | 'Did settings.SECRET_KEY change?' 71 | ) 72 | cypher_text = binascii.a2b_hex(cypher_text) 73 | return self.cipher.decrypt(cypher_text).split(b'\x00')[0] 74 | 75 | def encrypt(self, clear_text): 76 | clear_text = self.add_padding(clear_text) 77 | cypher_text = binascii.b2a_hex(self.cipher.encrypt(clear_text)) 78 | parts = [self.prefix] 79 | if self.sign: 80 | parts.append(self.get_signature(cypher_text)) 81 | parts.append(cypher_text) 82 | return b'$'.join(parts) 83 | 84 | 85 | class EncryptedField(models.TextField): 86 | """ 87 | This code is based on http://www.djangosnippets.org/snippets/1095/ 88 | and django-fields https://github.com/svetlyak40wt/django-fields 89 | """ 90 | encryption_class = SignedAESEncryption 91 | 92 | def __init__(self, *args, **kwargs): 93 | self.cipher = self.encryption_class() 94 | super(EncryptedField, self).__init__(*args, **kwargs) 95 | 96 | def from_db_value(self, value, expression, connection, context): 97 | if value is None: 98 | return value 99 | value = force_bytes(value) 100 | if self.cipher.is_encrypted(value): 101 | return force_text(self.cipher.decrypt(value)) 102 | return force_text(value) 103 | 104 | def get_db_prep_value(self, value, connection=None, prepared=False): 105 | if self.null: 106 | # Normalize empty values to None 107 | value = value or None 108 | if value is None: 109 | return None 110 | value = force_bytes(value) 111 | if not self.cipher.is_encrypted(value): 112 | value = self.cipher.encrypt(value) 113 | return force_text(value) 114 | -------------------------------------------------------------------------------- /allaccess/fixtures/common_providers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": null, 4 | "model": "allaccess.provider", 5 | "fields": { 6 | "name": "facebook", 7 | "authorization_url": "https://www.facebook.com/v2.8/dialog/oauth", 8 | "access_token_url": "https://graph.facebook.com/v2.8/oauth/access_token", 9 | "request_token_url": "", 10 | "profile_url": "https://graph.facebook.com/v2.8/me" 11 | } 12 | }, 13 | { 14 | "pk": null, 15 | "model": "allaccess.provider", 16 | "fields": { 17 | "name": "twitter", 18 | "authorization_url": "https://api.twitter.com/oauth/authenticate", 19 | "access_token_url": "https://api.twitter.com/oauth/access_token", 20 | "request_token_url": "https://api.twitter.com/oauth/request_token", 21 | "profile_url": "https://api.twitter.com/1.1/account/verify_credentials.json" 22 | } 23 | }, 24 | { 25 | "pk": null, 26 | "model": "allaccess.provider", 27 | "fields": { 28 | "name": "google", 29 | "authorization_url": "https://accounts.google.com/o/oauth2/auth", 30 | "access_token_url": "https://accounts.google.com/o/oauth2/token", 31 | "request_token_url": "", 32 | "profile_url": "https://www.googleapis.com/oauth2/v1/userinfo" 33 | } 34 | }, 35 | { 36 | "pk": null, 37 | "model": "allaccess.provider", 38 | "fields": { 39 | "name": "microsoft", 40 | "authorization_url": "https://login.live.com/oauth20_authorize.srf", 41 | "access_token_url": "https://login.live.com/oauth20_token.srf", 42 | "request_token_url": "", 43 | "profile_url": "https://apis.live.net/v5.0/me" 44 | } 45 | }, 46 | { 47 | "pk": null, 48 | "model": "allaccess.provider", 49 | "fields": { 50 | "name": "github", 51 | "authorization_url": "https://github.com/login/oauth/authorize", 52 | "access_token_url": "https://github.com/login/oauth/access_token", 53 | "request_token_url": "", 54 | "profile_url": "https://api.github.com/user" 55 | } 56 | }, 57 | { 58 | "pk": null, 59 | "model": "allaccess.provider", 60 | "fields": { 61 | "name": "bitbucket", 62 | "authorization_url": "https://bitbucket.org/api/1.0/oauth/authenticate/", 63 | "access_token_url": "https://bitbucket.org/api/1.0/oauth/access_token/", 64 | "request_token_url": "https://bitbucket.org/api/1.0/oauth/request_token/", 65 | "profile_url": "https://api.bitbucket.org/1.0/user/" 66 | } 67 | } 68 | ] 69 | -------------------------------------------------------------------------------- /allaccess/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.utils.timezone 6 | from django.conf import settings 7 | import allaccess.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='AccountAccess', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('identifier', models.CharField(max_length=255)), 22 | ('created', models.DateTimeField(default=django.utils.timezone.now, auto_now_add=True)), 23 | ('modified', models.DateTimeField(default=django.utils.timezone.now, auto_now=True)), 24 | ('access_token', allaccess.fields.EncryptedField(default=None, null=True, blank=True)), 25 | ], 26 | options={ 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='Provider', 32 | fields=[ 33 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 34 | ('name', models.CharField(unique=True, max_length=50)), 35 | ('request_token_url', models.CharField(max_length=255, blank=True)), 36 | ('authorization_url', models.CharField(max_length=255)), 37 | ('access_token_url', models.CharField(max_length=255)), 38 | ('profile_url', models.CharField(max_length=255)), 39 | ('consumer_key', allaccess.fields.EncryptedField(default=None, null=True, blank=True)), 40 | ('consumer_secret', allaccess.fields.EncryptedField(default=None, null=True, blank=True)), 41 | ], 42 | options={ 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | migrations.AddField( 47 | model_name='accountaccess', 48 | name='provider', 49 | field=models.ForeignKey(to='allaccess.Provider', on_delete=models.CASCADE), 50 | preserve_default=True, 51 | ), 52 | migrations.AddField( 53 | model_name='accountaccess', 54 | name='user', 55 | field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), 56 | preserve_default=True, 57 | ), 58 | migrations.AlterUniqueTogether( 59 | name='accountaccess', 60 | unique_together=set([('identifier', 'provider')]), 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /allaccess/migrations/0002_auto_20150511_1853.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('allaccess', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='accountaccess', 16 | name='created', 17 | field=models.DateTimeField(auto_now_add=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='accountaccess', 21 | name='modified', 22 | field=models.DateTimeField(auto_now=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /allaccess/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/allaccess/migrations/__init__.py -------------------------------------------------------------------------------- /allaccess/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.encoding import python_2_unicode_compatible 6 | 7 | from .clients import get_client 8 | from .fields import EncryptedField 9 | 10 | 11 | class ProviderManager(models.Manager): 12 | "Additional manager methods for Providers." 13 | 14 | def get_by_natural_key(self, name): 15 | return self.get(name=name) 16 | 17 | 18 | @python_2_unicode_compatible 19 | class Provider(models.Model): 20 | "Configuration for OAuth provider." 21 | 22 | name = models.CharField(max_length=50, unique=True) 23 | request_token_url = models.CharField(blank=True, max_length=255) 24 | authorization_url = models.CharField(max_length=255) 25 | access_token_url = models.CharField(max_length=255) 26 | profile_url = models.CharField(max_length=255) 27 | consumer_key = EncryptedField(blank=True, null=True, default=None) 28 | consumer_secret = EncryptedField(blank=True, null=True, default=None) 29 | 30 | objects = ProviderManager() 31 | 32 | def __str__(self): 33 | return self.name 34 | 35 | def save(self, *args, **kwargs): 36 | self.consumer_key = self.consumer_key or None 37 | self.consumer_secret = self.consumer_secret or None 38 | super(Provider, self).save(*args, **kwargs) 39 | 40 | def natural_key(self): 41 | return (self.name, ) 42 | 43 | def enabled(self): 44 | return self.consumer_key is not None and self.consumer_secret is not None 45 | enabled.boolean = True 46 | 47 | 48 | class AccountAccessManager(models.Manager): 49 | "Additional manager for AccountAccess models." 50 | 51 | def get_by_natural_key(self, identifier, provider): 52 | provider = Provider.objects.get_by_natural_key(provider) 53 | return self.get(identifier=identifier, provider=provider) 54 | 55 | 56 | @python_2_unicode_compatible 57 | class AccountAccess(models.Model): 58 | "Authorized remote OAuth provider." 59 | 60 | identifier = models.CharField(max_length=255) 61 | provider = models.ForeignKey(Provider, on_delete=models.CASCADE) 62 | user = models.ForeignKey( 63 | settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) 64 | created = models.DateTimeField(auto_now_add=True) 65 | modified = models.DateTimeField(auto_now=True) 66 | access_token = EncryptedField(blank=True, null=True, default=None) 67 | 68 | objects = AccountAccessManager() 69 | 70 | class Meta(object): 71 | unique_together = ('identifier', 'provider') 72 | 73 | def __str__(self): 74 | return '{0} {1}'.format(self.provider, self.identifier) 75 | 76 | def save(self, *args, **kwargs): 77 | self.access_token = self.access_token or None 78 | super(AccountAccess, self).save(*args, **kwargs) 79 | 80 | def natural_key(self): 81 | return (self.identifier, ) + self.provider.natural_key() 82 | natural_key.dependencies = ['allaccess.provider'] 83 | 84 | @property 85 | def api_client(self): 86 | return get_client(self.provider, self.access_token or '') 87 | -------------------------------------------------------------------------------- /allaccess/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/allaccess/tests/__init__.py -------------------------------------------------------------------------------- /allaccess/tests/base.py: -------------------------------------------------------------------------------- 1 | "Base test class and helper methods for writing tests." 2 | from __future__ import unicode_literals 3 | 4 | import random 5 | import string 6 | import unittest 7 | 8 | from django.conf import settings 9 | from django.contrib.auth import get_user_model 10 | from django.test import TestCase 11 | 12 | from allaccess.models import Provider, AccountAccess 13 | 14 | 15 | def skipIfCustomUser(test_func): 16 | "Tweaked version of check for replaced auth.User" 17 | return unittest.skipIf( 18 | settings.AUTH_USER_MODEL != 'auth.User', 'Custom user model in use')(test_func) 19 | 20 | 21 | class AllAccessTestCase(TestCase): 22 | "Common base test class." 23 | 24 | def get_random_string(self, length=10): 25 | "Create a random string for generating test data." 26 | return ''.join(random.choice(string.ascii_letters) for x in range(length)) 27 | 28 | def get_random_email(self, domain='example.com'): 29 | "Create a random email for generating test data." 30 | local = self.get_random_string() 31 | return '{0}@{1}'.format(local, domain) 32 | 33 | def get_random_url(self, domain='example.com'): 34 | "Create a random url for generating test data." 35 | path = self.get_random_string() 36 | return 'http://{0}/{1}'.format(domain, path) 37 | 38 | def create_user(self, **kwargs): 39 | "Create a test User" 40 | User = get_user_model() 41 | defaults = { 42 | User.USERNAME_FIELD: self.get_random_string(), 43 | 'password': self.get_random_string(), 44 | 'email': self.get_random_email() 45 | } 46 | defaults.update(kwargs) 47 | return User.objects.create_user(**defaults) 48 | 49 | def create_provider(self, **kwargs): 50 | "Create OAuth provider." 51 | defaults = { 52 | 'name': self.get_random_string(), 53 | 'authorization_url': self.get_random_url(), 54 | 'access_token_url': self.get_random_url(), 55 | 'profile_url': self.get_random_url(), 56 | } 57 | defaults.update(kwargs) 58 | return Provider.objects.create(**defaults) 59 | 60 | def create_access(self, **kwargs): 61 | "Create a test remote AccountAccess" 62 | defaults = { 63 | 'identifier': self.get_random_string(), 64 | } 65 | defaults.update(kwargs) 66 | if 'provider' not in defaults: 67 | defaults['provider'] = self.create_provider() 68 | return AccountAccess.objects.create(**defaults) 69 | -------------------------------------------------------------------------------- /allaccess/tests/custom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/allaccess/tests/custom/__init__.py -------------------------------------------------------------------------------- /allaccess/tests/custom/models.py: -------------------------------------------------------------------------------- 1 | "Custom User Model based on the docs example." 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import BaseUserManager, AbstractBaseUser 5 | 6 | 7 | class MyUserManager(BaseUserManager): 8 | 9 | def create_user(self, email, password=None): 10 | """ 11 | Creates and saves a User with the given email, date of 12 | birth and password. 13 | """ 14 | if not email: 15 | raise ValueError('Users must have an email address') 16 | 17 | user = self.model(email=self.normalize_email(email)) 18 | user.set_password(password) 19 | user.save(using=self._db) 20 | return user 21 | 22 | 23 | class MyUser(AbstractBaseUser): 24 | email = models.EmailField( 25 | verbose_name='email address', 26 | max_length=255, 27 | unique=True, 28 | db_index=True, 29 | ) 30 | is_active = models.BooleanField(default=True) 31 | is_admin = models.BooleanField(default=False) 32 | 33 | objects = MyUserManager() 34 | 35 | USERNAME_FIELD = 'email' 36 | 37 | def get_full_name(self): 38 | # The user is identified by their email address 39 | return self.email 40 | 41 | def get_short_name(self): 42 | # The user is identified by their email address 43 | return self.email 44 | 45 | def __unicode__(self): 46 | return self.email 47 | -------------------------------------------------------------------------------- /allaccess/tests/custom/tests.py: -------------------------------------------------------------------------------- 1 | "Functional tests for using a swapped user model." 2 | 3 | import unittest 4 | 5 | from django.conf import settings 6 | 7 | from .. import test_views 8 | 9 | 10 | @unittest.skipUnless( 11 | 'allaccess.tests.custom' in settings.INSTALLED_APPS, 12 | 'custom user is not installed for testing') 13 | class CustomizedCallbackTestCase(test_views.OAuthCallbackTestCase): 14 | "OAuth callback customized for swapped user." 15 | 16 | url_name = 'custom-callback' 17 | 18 | def test_create_new_user(self): 19 | "Create a new user and associate them with the provider." 20 | self._test_create_new_user() 21 | 22 | def test_existing_user(self): 23 | "Authenticate existing user and update their access token." 24 | self._test_existing_user() 25 | 26 | def test_authentication_redirect(self): 27 | "Post-authentication redirect to LOGIN_REDIRECT_URL." 28 | self._test_authentication_redirect() 29 | -------------------------------------------------------------------------------- /allaccess/tests/custom/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import CustomRedirect, CustomCallback 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^custom-login/(?P(\w|-)+)/$', 8 | CustomRedirect.as_view(), name='custom-login'), 9 | url(r'^custom-callback/(?P(\w|-)+)/$', 10 | CustomCallback.as_view(), name='custom-callback'), 11 | ] 12 | -------------------------------------------------------------------------------- /allaccess/tests/custom/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import base64 4 | import hashlib 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.core.urlresolvers import reverse 8 | from django.utils.encoding import smart_bytes, force_text 9 | 10 | from allaccess.views import OAuthRedirect, OAuthCallback 11 | 12 | 13 | class CustomRedirect(OAuthRedirect): 14 | "Redirect to custom callback." 15 | 16 | def get_callback_url(self, provider): 17 | "Return the callback url for this provider." 18 | return reverse('custom-callback', kwargs={'provider': provider.name}) 19 | 20 | 21 | class CustomCallback(OAuthCallback): 22 | "Create custom user on callback." 23 | 24 | def get_or_create_user(self, provider, access, info): 25 | "Create a shell custom.MyUser." 26 | email = info.get('email', None) 27 | if email is None: 28 | # No email was given by the provider so create a fake one 29 | digest = hashlib.sha1(smart_bytes(access)).digest() 30 | # Base 64 encode to get below 30 characters 31 | # Removed padding characters 32 | email = '%s@example.com' % force_text(base64.urlsafe_b64encode(digest)).replace('=', '') 33 | User = get_user_model() 34 | kwargs = { 35 | 'email': email, 36 | 'password': None 37 | } 38 | return User.objects.create_user(**kwargs) 39 | -------------------------------------------------------------------------------- /allaccess/tests/test_backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate 2 | 3 | from .base import AllAccessTestCase 4 | 5 | 6 | class AuthBackendTestCase(AllAccessTestCase): 7 | "Custom contrib.auth backend tests." 8 | 9 | def setUp(self): 10 | self.user = self.create_user() 11 | self.access = self.create_access(user=self.user) 12 | 13 | def test_successful_authenticate(self): 14 | "User successfully authenticated." 15 | provider = self.access.provider 16 | identifier = self.access.identifier 17 | user = authenticate(provider=provider, identifier=identifier) 18 | self.assertEqual(user, self.user, "Correct user was not returned.") 19 | 20 | def test_provider_name(self): 21 | "Match on provider name as a string." 22 | provider = self.access.provider.name 23 | identifier = self.access.identifier 24 | user = authenticate(provider=provider, identifier=identifier) 25 | self.assertEqual(user, self.user, "Correct user was not returned.") 26 | 27 | def test_failed_authentication(self): 28 | "No matches found for the provider/id pair." 29 | provider = self.access.provider 30 | identifier = self.access.identifier 31 | self.access.delete() 32 | user = authenticate(provider=provider, identifier=identifier) 33 | self.assertEqual(user, None, "No user should be returned.") 34 | 35 | def test_match_no_user(self): 36 | "Matched access is not associated with a user." 37 | self.access.user = None 38 | self.access.save() 39 | user = authenticate(provider=self.access.provider, identifier=self.access.identifier) 40 | self.assertEqual(user, None, "No user should be returned.") 41 | 42 | def test_performance(self): 43 | "Only one query should be required to get the user." 44 | with self.assertNumQueries(1): 45 | authenticate(provider=self.access.provider, identifier=self.access.identifier) 46 | -------------------------------------------------------------------------------- /allaccess/tests/test_clients.py: -------------------------------------------------------------------------------- 1 | "OAuth 1.0 and 2.0 client tests." 2 | from __future__ import unicode_literals 3 | 4 | from django.test.client import RequestFactory 5 | 6 | from requests.exceptions import RequestException 7 | 8 | from .base import AllAccessTestCase 9 | from ..clients import OAuthClient, OAuth2Client 10 | from ..compat import urlparse, parse_qs, patch, Mock 11 | 12 | 13 | class BaseClientTestCase(object): 14 | "Common client test functionality." 15 | 16 | oauth_client = None 17 | 18 | def setUp(self): 19 | super(BaseClientTestCase, self).setUp() 20 | self.consumer_key = self.get_random_string() 21 | self.consumer_secret = self.get_random_string() 22 | self.provider = self.create_provider( 23 | consumer_key=self.consumer_key, consumer_secret=self.consumer_secret) 24 | self.oauth = self.oauth_client(self.provider) 25 | self.factory = RequestFactory() 26 | 27 | def test_redirect_url(self, *args, **kwargs): 28 | "Redirect url is build from provider authorization_url." 29 | with patch.object(self.oauth, 'get_redirect_args') as args: 30 | args.return_value = {'foo': 'bar'} 31 | request = self.factory.get('/login/') 32 | url = self.oauth.get_redirect_url(request, callback='/callback/') 33 | scheme, netloc, path, params, query, fragment = urlparse(url) 34 | query = parse_qs(query) 35 | self.assertEqual('%s://%s%s' % (scheme, netloc, path), self.provider.authorization_url) 36 | self.assertEqual(query, {'foo': ['bar']}) 37 | 38 | def test_additional_redirect_args(self, *args, **kwargs): 39 | "Additional redirect arguments." 40 | with patch.object(self.oauth, 'get_redirect_args') as args: 41 | args.return_value = {'foo': 'bar'} 42 | request = self.factory.get('/login/') 43 | additional = {'scope': 'email'} 44 | url = self.oauth.get_redirect_url(request, callback='/callback/', parameters=additional) 45 | scheme, netloc, path, params, query, fragment = urlparse(url) 46 | query = parse_qs(query) 47 | self.assertEqual(query, {'foo': ['bar'], 'scope': ['email']}) 48 | 49 | 50 | @patch('allaccess.clients.OAuth1') 51 | @patch('allaccess.clients.request') 52 | class OAuthClientTestCase(BaseClientTestCase, AllAccessTestCase): 53 | "OAuth 1.0 client handling to match http://oauth.net/core/1.0/" 54 | 55 | oauth_client = OAuthClient 56 | 57 | def setUp(self): 58 | super(OAuthClientTestCase, self).setUp() 59 | self.provider.request_token_url = self.get_random_url() 60 | self.provider.save() 61 | 62 | def test_request_token_auth(self, requests, auth): 63 | "Construct post auth with provider key and secret." 64 | request = self.factory.get('/login/') 65 | self.oauth.get_request_token(request, callback='/callback/') 66 | self.assertTrue(auth.called) 67 | args, kwargs = auth.call_args 68 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 69 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 70 | self.assertEqual(kwargs['resource_owner_key'], None) 71 | self.assertEqual(kwargs['resource_owner_secret'], None) 72 | self.assertEqual(kwargs['verifier'], None) 73 | self.assertEqual(kwargs['callback_uri'], 'http://testserver/callback/') 74 | 75 | def test_request_token_url(self, requests, auth): 76 | "Post should be sent to provider's request_token_url." 77 | request = self.factory.get('/login/') 78 | self.oauth.get_request_token(request, callback='/callback/') 79 | self.assertTrue(requests.called) 80 | args, kwargs = requests.call_args 81 | method, url = args 82 | self.assertEqual(method, 'post') 83 | self.assertEqual(url, self.provider.request_token_url) 84 | 85 | def test_request_token_response(self, requests, auth): 86 | "Return full response text without parsing key/secret." 87 | response = Mock() 88 | response.text = 'oauth_token=token&oauth_token_secret=secret' 89 | requests.return_value = response 90 | request = self.factory.get('/login/') 91 | token = self.oauth.get_request_token(request, callback='/callback/') 92 | self.assertEqual(token, 'oauth_token=token&oauth_token_secret=secret') 93 | 94 | def test_request_token_failure(self, requests, auth): 95 | "Handle upstream server errors when fetching request token." 96 | requests.side_effect = RequestException('Server Down') 97 | request = self.factory.get('/login/') 98 | token = self.oauth.get_request_token(request, callback='/callback/') 99 | self.assertEqual(token, None) 100 | 101 | def test_access_token_auth(self, requests, auth): 102 | "Construct auth from provider key and secret and request token." 103 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 104 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 105 | self.oauth.get_access_token(request) 106 | self.assertTrue(auth.called) 107 | args, kwargs = auth.call_args 108 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 109 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 110 | self.assertEqual(kwargs['resource_owner_key'], 'token') 111 | self.assertEqual(kwargs['resource_owner_secret'], 'secret') 112 | self.assertEqual(kwargs['verifier'], 'verifier') 113 | self.assertEqual(kwargs['callback_uri'], 'http://testserver/callback/') 114 | 115 | def test_access_token_auth_custom_callback(self, requests, auth): 116 | "Construct auth when a callback is given." 117 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 118 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 119 | self.oauth.get_access_token(request, callback='/other/') 120 | self.assertTrue(auth.called) 121 | args, kwargs = auth.call_args 122 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 123 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 124 | self.assertEqual(kwargs['resource_owner_key'], 'token') 125 | self.assertEqual(kwargs['resource_owner_secret'], 'secret') 126 | self.assertEqual(kwargs['verifier'], 'verifier') 127 | self.assertEqual(kwargs['callback_uri'], 'http://testserver/other/') 128 | 129 | def test_access_token_no_request_token(self, requests, auth): 130 | "Handle no request token found in the session." 131 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 132 | request.session = {} 133 | response = self.oauth.get_access_token(request) 134 | self.assertEqual(response, None) 135 | self.assertFalse(requests.called) 136 | self.assertFalse(auth.called) 137 | 138 | def test_access_token_no_verifier(self, requests, auth): 139 | "Don't request access token if no verifier was given." 140 | request = self.factory.get('/callback/') 141 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 142 | response = self.oauth.get_access_token(request) 143 | self.assertEqual(response, None) 144 | self.assertFalse(requests.called) 145 | self.assertFalse(auth.called) 146 | 147 | def test_access_token_bad_request_token(self, requests, auth): 148 | "Handle bad request token found in the session." 149 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 150 | request.session = {self.oauth.session_key: 'XXXXX'} 151 | self.oauth.get_access_token(request) 152 | self.assertTrue(auth.called) 153 | args, kwargs = auth.call_args 154 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 155 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 156 | self.assertEqual(kwargs['resource_owner_key'], None) 157 | self.assertEqual(kwargs['resource_owner_secret'], None) 158 | self.assertEqual(kwargs['verifier'], 'verifier') 159 | 160 | def test_access_token_url(self, requests, auth): 161 | "Post should be sent to provider's access_token_url." 162 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 163 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 164 | self.oauth.get_access_token(request) 165 | self.assertTrue(requests.called) 166 | args, kwargs = requests.call_args 167 | method, url = args 168 | self.assertEqual(method, 'post') 169 | self.assertEqual(url, self.provider.access_token_url) 170 | 171 | def test_access_token_response(self, requests, auth): 172 | "Return full response text without parsing key/secret." 173 | response = Mock() 174 | response.text = 'oauth_token=token&oauth_token_secret=secret' 175 | requests.return_value = response 176 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 177 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 178 | token = self.oauth.get_access_token(request) 179 | self.assertEqual(token, 'oauth_token=token&oauth_token_secret=secret') 180 | 181 | def test_access_token_failure(self, requests, auth): 182 | "Handle upstream server errors when fetching access token." 183 | requests.side_effect = RequestException('Server Down') 184 | request = self.factory.get('/callback/', {'oauth_verifier': 'verifier'}) 185 | request.session = {self.oauth.session_key: 'oauth_token=token&oauth_token_secret=secret'} 186 | token = self.oauth.get_access_token(request) 187 | self.assertEqual(token, None) 188 | 189 | def test_profile_info_auth(self, requests, auth): 190 | "Construct auth from provider key and secret and user token." 191 | raw_token = 'oauth_token=token&oauth_token_secret=secret' 192 | self.oauth.get_profile_info(raw_token) 193 | self.assertTrue(auth.called) 194 | args, kwargs = auth.call_args 195 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 196 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 197 | self.assertEqual(kwargs['resource_owner_key'], 'token') 198 | self.assertEqual(kwargs['resource_owner_secret'], 'secret') 199 | 200 | def test_profile_info_url(self, requests, auth): 201 | "Make get request for profile url." 202 | raw_token = 'oauth_token=token&oauth_token_secret=secret' 203 | self.oauth.get_profile_info(raw_token) 204 | self.assertTrue(requests.called) 205 | args, kwargs = requests.call_args 206 | method, url = args 207 | self.assertEqual(method, 'get') 208 | self.assertEqual(url, self.provider.profile_url) 209 | 210 | def test_profile_info_failure(self, requests, auth): 211 | "Handle upstream server errors when fetching profile info." 212 | requests.side_effect = RequestException('Server Down') 213 | raw_token = 'oauth_token=token&oauth_token_secret=secret' 214 | response = self.oauth.get_profile_info(raw_token) 215 | self.assertEqual(response, None) 216 | 217 | def test_request_with_user_token(self, requests, auth): 218 | "Use token for request auth." 219 | token = 'oauth_token=token&oauth_token_secret=secret' 220 | self.oauth = self.oauth_client(self.provider, token=token) 221 | self.oauth.request('get', 'http://example.com/') 222 | self.assertTrue(auth.called) 223 | args, kwargs = auth.call_args 224 | self.assertEqual(kwargs['client_key'], self.provider.consumer_key) 225 | self.assertEqual(kwargs['client_secret'], self.provider.consumer_secret) 226 | self.assertEqual(kwargs['resource_owner_key'], 'token') 227 | self.assertEqual(kwargs['resource_owner_secret'], 'secret') 228 | 229 | 230 | @patch('allaccess.clients.request') 231 | class OAuth2ClientTestCase(BaseClientTestCase, AllAccessTestCase): 232 | "OAuth 2.0 client handling." 233 | 234 | oauth_client = OAuth2Client 235 | 236 | def test_access_token_url(self, requests): 237 | "Get should be sent to provider's access_token_url." 238 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 239 | request.session = {self.oauth.session_key: 'foo'} 240 | self.oauth.get_access_token(request) 241 | self.assertTrue(requests.called) 242 | args, kwargs = requests.call_args 243 | method, url = args 244 | self.assertEqual(method, 'post') 245 | self.assertEqual(url, self.provider.access_token_url) 246 | 247 | def test_access_token_parameters(self, requests): 248 | "Check parameters used when fetching access token." 249 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 250 | request.session = {self.oauth.session_key: 'foo'} 251 | self.oauth.get_access_token(request) 252 | self.assertTrue(requests.called) 253 | args, kwargs = requests.call_args 254 | params = kwargs['data'] 255 | self.assertEqual(params['redirect_uri'], 'http://testserver/callback/') 256 | self.assertEqual(params['code'], 'code') 257 | self.assertEqual(params['grant_type'], 'authorization_code') 258 | self.assertEqual(params['client_id'], self.provider.consumer_key) 259 | self.assertEqual(params['client_secret'], self.provider.consumer_secret) 260 | 261 | def test_access_token_custom_callback(self, requests): 262 | "Check parameters used with custom callback." 263 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 264 | request.session = {self.oauth.session_key: 'foo'} 265 | self.oauth.get_access_token(request, callback='/other/') 266 | self.assertTrue(requests.called) 267 | args, kwargs = requests.call_args 268 | params = kwargs['data'] 269 | self.assertEqual(params['redirect_uri'], 'http://testserver/other/') 270 | self.assertEqual(params['code'], 'code') 271 | self.assertEqual(params['grant_type'], 'authorization_code') 272 | self.assertEqual(params['client_id'], self.provider.consumer_key) 273 | self.assertEqual(params['client_secret'], self.provider.consumer_secret) 274 | 275 | def test_access_token_no_code(self, requests): 276 | "Don't request token if no code was given to the callback." 277 | request = self.factory.get('/callback/', {'state': 'foo'}) 278 | request.session = {self.oauth.session_key: 'foo'} 279 | token = self.oauth.get_access_token(request) 280 | self.assertEqual(token, None) 281 | self.assertFalse(requests.called) 282 | 283 | def test_access_token_response(self, requests): 284 | "Return full response text without parsing key/secret." 285 | response = Mock() 286 | response.text = 'access_token=USER_ACCESS_TOKEN' 287 | requests.return_value = response 288 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 289 | request.session = {self.oauth.session_key: 'foo'} 290 | token = self.oauth.get_access_token(request) 291 | self.assertEqual(token, 'access_token=USER_ACCESS_TOKEN') 292 | 293 | def test_access_token_failure(self, requests): 294 | "Handle upstream server errors when fetching access token." 295 | requests.side_effect = RequestException('Server Down') 296 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 297 | request.session = {self.oauth.session_key: 'foo'} 298 | token = self.oauth.get_access_token(request) 299 | self.assertEqual(token, None) 300 | 301 | def test_profile_info_auth(self, requests): 302 | "Pass access token when requesting profile info." 303 | raw_token = 'access_token=USER_ACCESS_TOKEN' 304 | self.oauth.get_profile_info(raw_token) 305 | self.assertTrue(requests.called) 306 | args, kwargs = requests.call_args 307 | self.assertEqual(kwargs['params']['access_token'], 'USER_ACCESS_TOKEN') 308 | 309 | def test_profile_info_url(self, requests): 310 | "Make get request for profile url." 311 | raw_token = 'access_token=USER_ACCESS_TOKEN' 312 | self.oauth.get_profile_info(raw_token) 313 | self.assertTrue(requests.called) 314 | args, kwargs = requests.call_args 315 | method, url = args 316 | self.assertEqual(method, 'get') 317 | self.assertEqual(url, self.provider.profile_url) 318 | 319 | def test_profile_info_failure(self, requests): 320 | "Handle upstream server errors when fetching profile info." 321 | requests.side_effect = RequestException('Server Down') 322 | raw_token = 'access_token=USER_ACCESS_TOKEN' 323 | response = self.oauth.get_profile_info(raw_token) 324 | self.assertEqual(response, None) 325 | 326 | def test_parse_token_response_json(self, requests): 327 | "Parse token response which is JSON encoded per spec." 328 | raw_token = '{"access_token": "USER_ACCESS_TOKEN"}' 329 | token, secret = self.oauth.parse_raw_token(raw_token) 330 | self.assertEqual(token, 'USER_ACCESS_TOKEN') 331 | self.assertEqual(secret, None) 332 | 333 | def test_parse_error_response_json(self, requests): 334 | "Parse token error response which is JSON encoded per spec." 335 | raw_token = '{"error": "invalid_request"}' 336 | token, secret = self.oauth.parse_raw_token(raw_token) 337 | self.assertEqual(token, None) 338 | self.assertEqual(secret, None) 339 | 340 | def test_parse_token_response_query(self, requests): 341 | "Parse token response which is url encoded (FB)." 342 | raw_token = 'access_token=USER_ACCESS_TOKEN' 343 | token, secret = self.oauth.parse_raw_token(raw_token) 344 | self.assertEqual(token, 'USER_ACCESS_TOKEN') 345 | self.assertEqual(secret, None) 346 | 347 | def test_parse_invalid_token_response(self, requests): 348 | "Parse garbage token response." 349 | raw_token = 'XXXXX' 350 | token, secret = self.oauth.parse_raw_token(raw_token) 351 | self.assertEqual(token, None) 352 | self.assertEqual(secret, None) 353 | 354 | def test_access_token_no_state_session(self, requests): 355 | "Handle no state found in the session." 356 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'foo'}) 357 | request.session = {} 358 | response = self.oauth.get_access_token(request) 359 | self.assertEqual(response, None) 360 | self.assertFalse(requests.called) 361 | 362 | def test_access_token_no_state_provider(self, requests): 363 | "Handle no state returned by the provider." 364 | request = self.factory.get('/callback/', {'code': 'code'}) 365 | request.session = {self.oauth.session_key: 'foo'} 366 | response = self.oauth.get_access_token(request) 367 | self.assertEqual(response, None) 368 | self.assertFalse(requests.called) 369 | 370 | def test_access_token_state_incorrect(self, requests): 371 | "Handle invalid state returned by the provider" 372 | request = self.factory.get('/callback/', {'code': 'code', 'state': 'bar'}) 373 | request.session = {self.oauth.session_key: 'foo'} 374 | response = self.oauth.get_access_token(request) 375 | self.assertEqual(response, None) 376 | self.assertFalse(requests.called) 377 | 378 | def test_request_with_user_token(self, requests): 379 | "Use token for request auth." 380 | token = '{"access_token": "USER_ACCESS_TOKEN"}' 381 | self.oauth = self.oauth_client(self.provider, token=token) 382 | self.oauth.request('get', 'http://example.com/') 383 | self.assertTrue(requests.called) 384 | args, kwargs = requests.call_args 385 | self.assertEqual(kwargs['params']['access_token'], 'USER_ACCESS_TOKEN') 386 | -------------------------------------------------------------------------------- /allaccess/tests/test_context_processors.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.test.client import RequestFactory 4 | 5 | from .base import AllAccessTestCase 6 | from ..context_processors import available_providers 7 | 8 | 9 | class AvailableProvidersTestCase(AllAccessTestCase): 10 | "Processor to add available Providers to the context." 11 | 12 | def setUp(self): 13 | self.enabled_provider = self.create_provider( 14 | consumer_key=self.get_random_string(), consumer_secret=self.get_random_string() 15 | ) 16 | self.disabled_provider = self.create_provider(consumer_key=None, consumer_secret=None) 17 | self.factory = RequestFactory() 18 | 19 | def test_enabled_filter(self): 20 | "Return only providers with key/secret pairs." 21 | request = self.factory.get("/") 22 | context = available_providers(request) 23 | self.assertTrue('allaccess_providers' in context) 24 | providers = context['allaccess_providers'] 25 | self.assertTrue(self.enabled_provider in providers) 26 | self.assertFalse(self.disabled_provider in providers) 27 | 28 | def test_no_queries(self): 29 | "Context processor should not execute any queries (only lazy queryset)." 30 | request = self.factory.get("/") 31 | with self.assertNumQueries(0): 32 | available_providers(request) 33 | -------------------------------------------------------------------------------- /allaccess/tests/test_models.py: -------------------------------------------------------------------------------- 1 | "Models and field encryption tests." 2 | from __future__ import unicode_literals 3 | 4 | from .base import AllAccessTestCase, Provider, AccountAccess 5 | 6 | 7 | class ProviderTestCase(AllAccessTestCase): 8 | "Custom provider methods and key/secret encryption." 9 | 10 | def setUp(self): 11 | self.provider = self.create_provider() 12 | 13 | def test_save_empty_key(self): 14 | "None/blank key should normalize to None which is not encrypted." 15 | self.provider.consumer_key = '' 16 | self.provider.save() 17 | self.assertEqual(self.provider.consumer_key, None) 18 | 19 | self.provider.consumer_key = None 20 | self.provider.save() 21 | self.assertEqual(self.provider.consumer_key, None) 22 | 23 | def test_save_empty_secret(self): 24 | "None/blank secret should normalize to None which is not encrypted." 25 | self.provider.consumer_secret = '' 26 | self.provider.save() 27 | self.assertEqual(self.provider.consumer_secret, None) 28 | 29 | self.provider.consumer_secret = None 30 | self.provider.save() 31 | self.assertEqual(self.provider.consumer_secret, None) 32 | 33 | def test_encrypted_save(self): 34 | "Encrypt key/secret on save." 35 | key = self.get_random_string() 36 | secret = self.get_random_string() 37 | self.provider.consumer_key = key 38 | self.provider.consumer_secret = secret 39 | self.provider.save() 40 | provider = Provider.objects.extra( 41 | select={'raw_key': 'consumer_key', 'raw_secret': 'consumer_secret'} 42 | ).get(pk=self.provider.pk) 43 | self.assertNotEqual(provider.raw_key, key) 44 | self.assertTrue(provider.raw_key.startswith('$AES$')) 45 | self.assertNotEqual(provider.raw_secret, secret) 46 | self.assertTrue(provider.raw_secret.startswith('$AES$')) 47 | 48 | def test_encrypted_fetch(self): 49 | "Decrypt key/secret on save." 50 | key = self.get_random_string() 51 | secret = self.get_random_string() 52 | self.provider.consumer_key = key 53 | self.provider.consumer_secret = secret 54 | self.provider.save() 55 | provider = Provider.objects.get(pk=self.provider.pk) 56 | self.assertEqual(provider.consumer_key, key, "Could not decrypt key.") 57 | self.assertEqual(provider.consumer_secret, secret, "Could not decrypt secret.") 58 | 59 | 60 | class AccountAccessTestCase(AllAccessTestCase): 61 | "Custom AccountAccess methods and access token encryption." 62 | 63 | def setUp(self): 64 | self.access = self.create_access() 65 | 66 | def test_save_empty_token(self): 67 | "None/blank access token should normalize to None which is not encrypted." 68 | self.access.access_token = '' 69 | self.access.save() 70 | self.assertEqual(self.access.access_token, None) 71 | 72 | self.access.access_token = None 73 | self.access.save() 74 | self.assertEqual(self.access.access_token, None) 75 | 76 | def test_encrypted_save(self): 77 | "Encrypt access token on save." 78 | access_token = self.get_random_string() 79 | self.access.access_token = access_token 80 | self.access.save() 81 | access = AccountAccess.objects.extra( 82 | select={'raw_token': 'access_token'} 83 | ).get(pk=self.access.pk) 84 | self.assertNotEqual(access.raw_token, access_token) 85 | self.assertTrue(access.raw_token.startswith('$AES$')) 86 | self.assertEqual(access.access_token, access_token, "Token should be unencrypted on fetch.") 87 | 88 | def test_encrypted_update(self): 89 | "Access token should be encrypted on update." 90 | access_token = self.get_random_string() 91 | AccountAccess.objects.filter(pk=self.access.pk).update(access_token=access_token) 92 | access = AccountAccess.objects.extra( 93 | select={'raw_token': 'access_token'} 94 | ).get(pk=self.access.pk) 95 | self.assertNotEqual(access.raw_token, access_token) 96 | self.assertTrue(access.raw_token.startswith('$AES$')) 97 | self.assertEqual(access.access_token, access_token, "Token should be unencrypted on fetch.") 98 | 99 | def test_fetch_api_client(self): 100 | "Get API client with the provider and user token set." 101 | access_token = self.get_random_string() 102 | self.access.access_token = access_token 103 | self.access.save() 104 | api = self.access.api_client 105 | self.assertEqual(api.provider, self.access.provider) 106 | self.assertEqual(api.token, self.access.access_token) 107 | -------------------------------------------------------------------------------- /allaccess/tests/test_views.py: -------------------------------------------------------------------------------- 1 | "Redirect and callback view tests." 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.core.urlresolvers import reverse 6 | from django.test import override_settings, RequestFactory 7 | 8 | from .base import AllAccessTestCase, AccountAccess, get_user_model, skipIfCustomUser 9 | from ..compat import urlparse, parse_qs, patch, Mock 10 | from ..views import OAuthRedirect, OAuthCallback 11 | 12 | 13 | @override_settings(ROOT_URLCONF='allaccess.tests.urls', LOGIN_URL='/login/', LOGIN_REDIRECT_URL='/') 14 | class BaseViewTestCase(AllAccessTestCase): 15 | "Common view test functionality." 16 | 17 | url_name = None 18 | 19 | def setUp(self): 20 | self.consumer_key = self.get_random_string() 21 | self.consumer_secret = self.get_random_string() 22 | self.provider = self.create_provider( 23 | consumer_key=self.consumer_key, consumer_secret=self.consumer_secret) 24 | self.url = reverse(self.url_name, kwargs={'provider': self.provider.name}) 25 | 26 | 27 | class OAuthRedirectTestCase(BaseViewTestCase): 28 | "Initial redirect for user to sign log in with OAuth 1.0 provider." 29 | 30 | url_name = 'allaccess-login' 31 | 32 | def test_oauth_1_redirect_url(self): 33 | "Redirect url for OAuth 1.0 provider." 34 | self.provider.request_token_url = self.get_random_url() 35 | self.provider.save() 36 | with patch('allaccess.clients.OAuthClient.get_request_token') as request_token: 37 | request_token.return_value = 'oauth_token=token&oauth_token_secret=secret' 38 | response = self.client.get(self.url) 39 | url = response['Location'] 40 | scheme, netloc, path, params, query, fragment = urlparse(url) 41 | self.assertEqual('%s://%s%s' % (scheme, netloc, path), self.provider.authorization_url) 42 | 43 | def test_oauth_1_redirect_parameters(self): 44 | "Redirect parameters for OAuth 1.0 provider." 45 | self.provider.request_token_url = self.get_random_url() 46 | self.provider.save() 47 | with patch('allaccess.clients.OAuthClient.get_request_token') as request_token: 48 | request_token.return_value = 'oauth_token=token&oauth_token_secret=secret' 49 | response = self.client.get(self.url) 50 | url = response['Location'] 51 | scheme, netloc, path, params, query, fragment = urlparse(url) 52 | query = parse_qs(query) 53 | self.assertEqual(query['oauth_token'][0], 'token') 54 | callback = reverse('allaccess-callback', kwargs={'provider': self.provider.name}) 55 | self.assertEqual(query['oauth_callback'][0], 'http://testserver' + callback) 56 | 57 | def test_oauth_2_redirect_url(self): 58 | "Redirect url for OAuth 2.0 provider." 59 | self.provider.request_token_url = '' 60 | self.provider.save() 61 | response = self.client.get(self.url) 62 | url = response['Location'] 63 | scheme, netloc, path, params, query, fragment = urlparse(url) 64 | self.assertEqual('%s://%s%s' % (scheme, netloc, path), self.provider.authorization_url) 65 | 66 | def test_oauth_2_redirect_parameters(self): 67 | "Redirect parameters for OAuth 2.0 provider." 68 | self.provider.request_token_url = '' 69 | self.provider.save() 70 | response = self.client.get(self.url) 71 | url = response['Location'] 72 | scheme, netloc, path, params, query, fragment = urlparse(url) 73 | query = parse_qs(query) 74 | callback = reverse('allaccess-callback', kwargs={'provider': self.provider.name}) 75 | self.assertEqual(query['redirect_uri'][0], 'http://testserver' + callback) 76 | self.assertEqual(query['response_type'][0], 'code') 77 | self.assertEqual(query['client_id'][0], self.provider.consumer_key) 78 | # State should be stored in the session and passed to the provider 79 | key = 'allaccess-{0}-request-state'.format(self.provider.name) 80 | state = self.client.session[key] 81 | self.assertEqual(query['state'][0], state) 82 | 83 | def test_unknown_provider(self): 84 | "Return a 404 if unknown provider name is given." 85 | self.provider.delete() 86 | response = self.client.get(self.url) 87 | self.assertEqual(response.status_code, 404) 88 | 89 | def test_disabled_provider(self): 90 | "Return a 404 if provider does not have key/secret set." 91 | self.provider.consumer_key = None 92 | self.provider.consumer_secret = None 93 | self.provider.save() 94 | response = self.client.get(self.url) 95 | self.assertEqual(response.status_code, 404) 96 | 97 | def test_redirect_params(self): 98 | "Set additional redirect parameters in as_view." 99 | view = OAuthRedirect.as_view(params={'scope': 'email'}) 100 | self.provider.request_token_url = '' 101 | self.provider.save() 102 | request = RequestFactory().get(self.url) 103 | request.session = {} 104 | response = view(request, provider=self.provider.name) 105 | url = response['Location'] 106 | scheme, netloc, path, params, query, fragment = urlparse(url) 107 | self.assertEqual('%s://%s%s' % (scheme, netloc, path), self.provider.authorization_url) 108 | query = parse_qs(query) 109 | self.assertEqual(query['scope'][0], 'email') 110 | 111 | 112 | class OAuthCallbackTestCase(BaseViewTestCase): 113 | "Callback after user has authenticated with OAuth provider." 114 | 115 | url_name = 'allaccess-callback' 116 | 117 | def setUp(self): 118 | super(OAuthCallbackTestCase, self).setUp() 119 | # Patch OAuth client 120 | self.patched_get_client = patch('allaccess.views.get_client') 121 | self.get_client = self.patched_get_client.start() 122 | self.mock_client = Mock() 123 | self.get_client.return_value = self.mock_client 124 | 125 | def tearDown(self): 126 | super(OAuthCallbackTestCase, self).tearDown() 127 | self.patched_get_client.stop() 128 | 129 | def test_unknown_provider(self): 130 | "Return a 404 if unknown provider name is given." 131 | self.provider.delete() 132 | response = self.client.get(self.url) 133 | self.assertEqual(response.status_code, 404) 134 | 135 | def test_disabled_provider(self): 136 | "Return a 404 if provider does not have key/secret set." 137 | self.provider.consumer_key = None 138 | self.provider.consumer_secret = None 139 | self.provider.save() 140 | response = self.client.get(self.url) 141 | self.assertEqual(response.status_code, 404) 142 | 143 | def test_failed_access_token(self): 144 | "Handle bad response when fetching access token." 145 | self.mock_client.get_access_token.return_value = None 146 | response = self.client.get(self.url) 147 | # Errors redirect to LOGIN_URL by default 148 | self.assertRedirects(response, settings.LOGIN_URL) 149 | 150 | def test_failed_user_profile(self): 151 | "Handle bad response when fetching user info." 152 | self.mock_client.get_access_token.return_value = 'token' 153 | self.mock_client.get_profile_info.return_value = None 154 | response = self.client.get(self.url) 155 | # Errors redirect to LOGIN_URL by default 156 | self.assertRedirects(response, settings.LOGIN_URL) 157 | 158 | def test_failed_user_id(self): 159 | "Handle bad response when parsing user id from info." 160 | self.mock_client.get_access_token.return_value = 'token' 161 | self.mock_client.get_profile_info.return_value = {} 162 | response = self.client.get(self.url) 163 | # Errors redirect to LOGIN_URL by default 164 | self.assertRedirects(response, settings.LOGIN_URL) 165 | 166 | def _test_create_new_user(self): 167 | "Base test case for both swapped and non-swapped user." 168 | User = get_user_model() 169 | User.objects.all().delete() 170 | self.mock_client.get_access_token.return_value = 'token' 171 | self.mock_client.get_profile_info.return_value = {'id': 100} 172 | self.client.get(self.url) 173 | access = AccountAccess.objects.get( 174 | provider=self.provider, identifier=100 175 | ) 176 | self.assertEqual(access.access_token, 'token') 177 | self.assertTrue(access.user, "User should be created.") 178 | self.assertFalse(access.user.has_usable_password(), "User created without password.") 179 | 180 | @skipIfCustomUser 181 | def test_create_new_user(self): 182 | "Create a new user and associate them with the provider." 183 | self._test_create_new_user() 184 | 185 | def _test_existing_user(self): 186 | "Base test case for both swapped and non-swapped user." 187 | User = get_user_model() 188 | user = self.create_user() 189 | access = self.create_access(user=user, provider=self.provider) 190 | user_count = User.objects.all().count() 191 | access_count = AccountAccess.objects.all().count() 192 | self.mock_client.get_access_token.return_value = 'token' 193 | self.mock_client.get_profile_info.return_value = {'id': access.identifier} 194 | self.client.get(self.url) 195 | self.assertEqual(User.objects.all().count(), user_count, "No users created.") 196 | self.assertEqual(AccountAccess.objects.all().count(), access_count, "No access records created.") 197 | # Refresh from DB 198 | access = AccountAccess.objects.get(pk=access.pk) 199 | self.assertEqual(access.access_token, 'token') 200 | 201 | @skipIfCustomUser 202 | def test_existing_user(self): 203 | "Authenticate existing user and update their access token." 204 | self._test_existing_user() 205 | 206 | def _test_authentication_redirect(self): 207 | "Base test case for both swapped and non-swapped user." 208 | self.mock_client.get_access_token.return_value = 'token' 209 | self.mock_client.get_profile_info.return_value = {'id': 100} 210 | response = self.client.get(self.url) 211 | self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) 212 | 213 | @skipIfCustomUser 214 | def test_authentication_redirect(self): 215 | "Post-authentication redirect to LOGIN_REDIRECT_URL." 216 | self._test_authentication_redirect() 217 | 218 | def test_customized_provider_id(self): 219 | "Change how to find the provider id in as_view." 220 | view = OAuthCallback(provider_id='account_id') 221 | result = view.get_user_id(self.provider, {'account_id': '123'}) 222 | self.assertEqual(result, '123') 223 | result = view.get_user_id(self.provider, {'id': '123'}) 224 | self.assertIsNone(result) 225 | 226 | def test_nested_provider_id(self): 227 | "Allow easy access to nested provider ids." 228 | view = OAuthCallback(provider_id='user.account_id') 229 | result = view.get_user_id(self.provider, {'user': {'account_id': '123'}}) 230 | self.assertEqual(result, '123') 231 | result = view.get_user_id(self.provider, {'id': '123'}) 232 | self.assertIsNone(result) 233 | -------------------------------------------------------------------------------- /allaccess/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include, handler404, handler500 2 | from django.contrib import admin 3 | from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError 4 | 5 | 6 | admin.autodiscover() 7 | 8 | handler404 = 'allaccess.tests.urls.test_404' 9 | handler500 = 'allaccess.tests.urls.test_500' 10 | 11 | 12 | def error(request): 13 | return HttpResponse('Error') 14 | 15 | 16 | def home(request): 17 | return HttpResponse('Home') 18 | 19 | 20 | def login(request): 21 | return HttpResponse('Login') 22 | 23 | 24 | def test_404(request, exception=None): 25 | return HttpResponseNotFound() 26 | 27 | 28 | def test_500(request): 29 | return HttpResponseServerError() 30 | 31 | 32 | urlpatterns = [ 33 | url(r'^allaccess/', include('allaccess.urls')), 34 | url(r'^allaccess/', include('allaccess.tests.custom.urls')), 35 | url(r'^error/$', error, name='test-error'), 36 | url(r'^login/$', login, name='test-login'), 37 | url(r'^$', home, name='test-home'), 38 | ] 39 | -------------------------------------------------------------------------------- /allaccess/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import OAuthRedirect, OAuthCallback 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^login/(?P(\w|-)+)/$', OAuthRedirect.as_view(), name='allaccess-login'), 8 | url(r'^callback/(?P(\w|-)+)/$', OAuthCallback.as_view(), name='allaccess-callback'), 9 | ] 10 | -------------------------------------------------------------------------------- /allaccess/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import base64 4 | import hashlib 5 | import logging 6 | 7 | from django.conf import settings 8 | from django.contrib import messages 9 | from django.contrib.auth import authenticate, login, get_user_model 10 | from django.core.urlresolvers import reverse 11 | from django.http import Http404 12 | from django.shortcuts import redirect 13 | from django.utils.encoding import smart_bytes, force_text 14 | from django.views.generic import RedirectView, View 15 | 16 | from .clients import get_client 17 | from .models import Provider, AccountAccess 18 | 19 | 20 | logger = logging.getLogger('allaccess.views') 21 | 22 | 23 | class OAuthClientMixin(object): 24 | "Mixin for getting OAuth client for a provider." 25 | 26 | client_class = None 27 | 28 | def get_client(self, provider): 29 | "Get instance of the OAuth client for this provider." 30 | if self.client_class is not None: 31 | return self.client_class(provider) 32 | return get_client(provider) 33 | 34 | 35 | class OAuthRedirect(OAuthClientMixin, RedirectView): 36 | "Redirect user to OAuth provider to enable access." 37 | 38 | permanent = False 39 | params = None 40 | 41 | def get_additional_parameters(self, provider): 42 | "Return additional redirect parameters for this provider." 43 | return self.params or {} 44 | 45 | def get_callback_url(self, provider): 46 | "Return the callback url for this provider." 47 | return reverse('allaccess-callback', kwargs={'provider': provider.name}) 48 | 49 | def get_redirect_url(self, **kwargs): 50 | "Build redirect url for a given provider." 51 | name = kwargs.get('provider', '') 52 | try: 53 | provider = Provider.objects.get(name=name) 54 | except Provider.DoesNotExist: 55 | raise Http404('Unknown OAuth provider.') 56 | else: 57 | if not provider.enabled(): 58 | raise Http404('Provider %s is not enabled.' % name) 59 | client = self.get_client(provider) 60 | callback = self.get_callback_url(provider) 61 | params = self.get_additional_parameters(provider) 62 | return client.get_redirect_url(self.request, callback=callback, parameters=params) 63 | 64 | 65 | class OAuthCallback(OAuthClientMixin, View): 66 | "Base OAuth callback view." 67 | 68 | provider_id = None 69 | profile_info_params = None 70 | 71 | def get(self, request, *args, **kwargs): 72 | name = kwargs.get('provider', '') 73 | try: 74 | provider = Provider.objects.get(name=name) 75 | except Provider.DoesNotExist: 76 | raise Http404('Unknown OAuth provider.') 77 | else: 78 | if not provider.enabled(): 79 | raise Http404('Provider %s is not enabled.' % name) 80 | client = self.get_client(provider) 81 | callback = self.get_callback_url(provider) 82 | # Fetch access token 83 | raw_token = client.get_access_token(self.request, callback=callback) 84 | if raw_token is None: 85 | return self.handle_login_failure(provider, "Could not retrieve token.") 86 | # Fetch profile info params 87 | profile_info_params = self.get_profile_info_params() 88 | # Fetch profile info 89 | info = client.get_profile_info(raw_token, profile_info_params) 90 | if info is None: 91 | return self.handle_login_failure(provider, "Could not retrieve profile.") 92 | identifier = self.get_user_id(provider, info) 93 | if identifier is None: 94 | return self.handle_login_failure(provider, "Could not determine id.") 95 | # Get or create access record 96 | defaults = { 97 | 'access_token': raw_token, 98 | } 99 | access, created = AccountAccess.objects.get_or_create( 100 | provider=provider, identifier=identifier, defaults=defaults 101 | ) 102 | if not created: 103 | access.access_token = raw_token 104 | AccountAccess.objects.filter(pk=access.pk).update(**defaults) 105 | user = authenticate(provider=provider, identifier=identifier) 106 | if user is None: 107 | return self.handle_new_user(provider, access, info) 108 | else: 109 | return self.handle_existing_user(provider, user, access, info) 110 | 111 | def get_callback_url(self, provider): 112 | "Return callback url if different than the current url." 113 | return None 114 | 115 | def get_error_redirect(self, provider, reason): 116 | "Return url to redirect on login failure." 117 | return settings.LOGIN_URL 118 | 119 | def get_login_redirect(self, provider, user, access, new=False): 120 | "Return url to redirect authenticated users." 121 | return settings.LOGIN_REDIRECT_URL 122 | 123 | def get_or_create_user(self, provider, access, info): 124 | "Create a shell auth.User." 125 | digest = hashlib.sha1(smart_bytes(access)).digest() 126 | # Base 64 encode to get below 30 characters 127 | # Removed padding characters 128 | username = force_text(base64.urlsafe_b64encode(digest)).replace('=', '') 129 | User = get_user_model() 130 | kwargs = { 131 | User.USERNAME_FIELD: username, 132 | 'email': '', 133 | 'password': None 134 | } 135 | return User.objects.create_user(**kwargs) 136 | 137 | def get_profile_info_params(self): 138 | "Return params that are going to be sent when getting user profile info" 139 | return self.profile_info_params or {} 140 | 141 | def get_user_id(self, provider, info): 142 | "Return unique identifier from the profile info." 143 | id_key = self.provider_id or 'id' 144 | result = info 145 | try: 146 | for key in id_key.split('.'): 147 | result = result[key] 148 | return result 149 | except KeyError: 150 | return None 151 | 152 | def handle_existing_user(self, provider, user, access, info): 153 | "Login user and redirect." 154 | login(self.request, user) 155 | return redirect(self.get_login_redirect(provider, user, access)) 156 | 157 | def handle_login_failure(self, provider, reason): 158 | "Message user and redirect on error." 159 | logger.error('Authenication Failure: {0}'.format(reason)) 160 | messages.error(self.request, 'Authenication Failed.') 161 | return redirect(self.get_error_redirect(provider, reason)) 162 | 163 | def handle_new_user(self, provider, access, info): 164 | "Create a shell auth.User and redirect." 165 | user = self.get_or_create_user(provider, access, info) 166 | access.user = user 167 | AccountAccess.objects.filter(pk=access.pk).update(user=user) 168 | user = authenticate(provider=access.provider, identifier=access.identifier) 169 | login(self.request, user) 170 | return redirect(self.get_login_redirect(provider, user, access, True)) 171 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-all-access.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-all-access.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-all-access" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-all-access" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/api-access.rst: -------------------------------------------------------------------------------- 1 | Additional API Calls 2 | ==================================== 3 | 4 | django-all-access requests the user's access token and fetches their profile information 5 | during the authentication process. If you want to make additional API calls on behalf 6 | of the user, it is easy to do and you have the full power of the 7 | `python-requests `_ library. 8 | 9 | 10 | Getting the API 11 | ---------------------- 12 | 13 | You can access the API client through the ``AccountAccess.api_client`` property. 14 | This will return either a :py:class:`OAuthClient` or :py:class:`OAuth2Client` based on the 15 | provider. API requests can be made using either the :py:meth:`BaseOAuthClient.request` method. This takes 16 | the HTTP method as the first parameter and the URL as the second. An example for the 17 | Twitter API is given below: 18 | 19 | .. code-block:: python 20 | 21 | from allaccess.views import OAuthCallback 22 | 23 | class NewTweetCallback(OAuthCallback): 24 | 25 | def get_login_redirect(self, provider, user, access, new=False): 26 | "Send a tweet for new Twitter users." 27 | if new and provider.name == 'twitter': 28 | api = access.api_client 29 | url = 'https://api.twitter.com/1/statuses/update.json' 30 | data = {'status': 'I just joined an awesome new site!'} 31 | response = api.request('post', url, data=data) 32 | # Check for errors in the response? 33 | return super(NewTweetCallback, self).get_login_redirect(provider, user, access, new) 34 | 35 | This assumes that you have requested sufficient permissions to tweet on behalf of the 36 | user. While this example is done in the callback, you can access the API client at 37 | any time by querying the ``AccountAccess`` table. There is a catch in that the 38 | access token from the provider might have been revoked by the user or expired. 39 | You should refer to the provider's API documentation for information regarding 40 | available endpoints and the access token expiration. 41 | 42 | The :py:meth:`BaseOAuthClient.request` method is a thin wrapper around the underlying 43 | ``python-requests`` library which sets up the appropriate authenication for OAuth 1.0 or OAuth 2.0. For 44 | more information on additional hooks available, you should refer to the `python-requests 45 | documentation `_. 46 | 47 | 48 | API Client 49 | ---------------------- 50 | 51 | The :py:class:`OAuthClient` or :py:class:`OAuth2Client` classes define methods centered around OAuth 52 | specifications and the authentication and registration workflow. The common methods 53 | are defined in a :py:class:`BaseOAuthClient`. If you are going to extend the client for 54 | a particular provider, it is recommended that you extend the appropriate OAuth 1.0 or 55 | 2.0 client rather than the :py:class:`BaseOAuthClient`. 56 | 57 | .. class:: BaseOAuthClient() 58 | 59 | .. method:: __init__(provider, token='') 60 | 61 | The client classes are created with an associated provider model record. 62 | The provider is used to provide the necessary URL (request token, access 63 | token, profile URL) information to the client. 64 | 65 | .. method:: get_access_token(request, callback=None) 66 | 67 | Used to fetch the access token from the callback URL. Unless you are 68 | familiar with the OAuth specifications, it is not recommended that you 69 | override this method. 70 | 71 | .. method:: get_profile_info(raw_token) 72 | 73 | Fetches and parses the profile information from the provider's profile 74 | URL. This assumes that the response is JSON. If not, you may need to 75 | override this method. 76 | 77 | .. method:: get_redirect_args(request, callback) 78 | 79 | Builds the necessary query string parameters for the initial redirect 80 | based on the OAuth specification. Additional parameters are better added 81 | using :py:meth:`OAuthRedirect.get_additional_parameters`. Unless you are 82 | familiar with the OAuth specifications, it is not recommended that you 83 | override this method. 84 | 85 | .. method:: get_redirect_url(request, callback) 86 | 87 | Builds the appropriate OAuth callback URL based on the provider information 88 | and the result of :py:meth:`BaseOAuthClient.get_redirect_args`. Unless you are familiar with the 89 | OAuth specifications, it is not recommended that you override this method. 90 | 91 | .. method:: parse_raw_token(raw_token) 92 | 93 | Parses the token (key, secret) information from the raw token response. 94 | 95 | .. method:: request(method, url, **kwargs) 96 | 97 | A thin wrapper around ``python-requests``, this also sets up the appropriate 98 | authentication headers/parameters. 99 | 100 | .. attribute:: session_key 101 | 102 | Returns a key for storing information in the user's session. For OAuth 1.0 103 | this would be used to store the request token information. For OAuth 2.0 104 | this is used for enforcing the ``state`` parameter. 105 | 106 | Beyond the methods above, the :py:class:`OAuthClient` also defines the below methods. 107 | 108 | .. class:: OAuthClient() 109 | 110 | .. method:: get_request_token(request, callback) 111 | 112 | Retrieves the request token prior to the initial redirect to the provider. This 113 | is stored in the session using the :py:attr:`BaseOAuthClient.session_key` which is unique per provider. 114 | Unless you are familiar with the OAuth 1.0 specification, it is not recommended that you 115 | override this method. 116 | 117 | 118 | :py:class:`OAuth2Client` extends :py:class:`BaseOAuthClient` to include these additional methods. 119 | 120 | .. class:: OAuth2Client() 121 | 122 | .. method:: check_application_state(request, callback) 123 | 124 | On the callback this method is called to enforce the use of the ``state`` parameter. 125 | The use of ``state`` is optional in the OAuth 2.0 spec but it is recommended 126 | and enforced by default by django-all-access. If you do not want to enforce 127 | the use of ``state``, you should override :py:meth:`OAuth2Client.get_application_state` and 128 | leave this method alone. 129 | 130 | .. method:: get_application_state(request, callback) 131 | 132 | Prior to the redirect, this method is used to generate a random ``state`` parameter 133 | which is stored in the session based on the :py:attr:`BaseOAuthClient.session_key`. By default it 134 | generates a secure random 32 character string. If you wish to make it longer 135 | you can override this method. If you do not want to enforce the ``state`` 136 | parameter or the provider you are using does not allow it, you can override 137 | this to return ``None``. 138 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-all-access documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jun 19 22:50:45 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | import datetime 14 | import os 15 | import sys 16 | import allaccess 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = [] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = 'django-all-access' 46 | copyright = '2012-%s, Mark Lavin' % datetime.date.today().year 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = '.'.join(allaccess.__version__.split('.')[0:2]) 54 | # The full version, including alpha/beta/rc tags. 55 | release = allaccess.__version__ 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = 'default' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_domain_indices = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 155 | #html_show_sphinx = True 156 | 157 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 158 | #html_show_copyright = True 159 | 160 | # If true, an OpenSearch description file will be output, and all pages will 161 | # contain a tag referring to it. The value of this option must be the 162 | # base URL from which the finished HTML is served. 163 | #html_use_opensearch = '' 164 | 165 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 166 | #html_file_suffix = None 167 | 168 | # Output file base name for HTML help builder. 169 | htmlhelp_basename = 'django-all-accessdoc' 170 | 171 | 172 | # -- Options for LaTeX output -------------------------------------------------- 173 | 174 | latex_elements = { 175 | # The paper size ('letterpaper' or 'a4paper'). 176 | #'papersize': 'letterpaper', 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | #'pointsize': '10pt', 180 | 181 | # Additional stuff for the LaTeX preamble. 182 | #'preamble': '', 183 | } 184 | 185 | # Grouping the document tree into LaTeX files. List of tuples 186 | # (source start file, target name, title, author, documentclass [howto/manual]). 187 | latex_documents = [ 188 | ('index', 'django-all-access.tex', u'django-all-access Documentation', 189 | u'Mark Lavin', 'manual'), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | #latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | #latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | #latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | #latex_show_urls = False 205 | 206 | # Documents to append as an appendix to all manuals. 207 | #latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | #latex_domain_indices = True 211 | 212 | 213 | # -- Options for manual page output -------------------------------------------- 214 | 215 | # One entry per manual page. List of tuples 216 | # (source start file, name, description, authors, manual section). 217 | man_pages = [ 218 | ('index', 'django-all-access', u'django-all-access Documentation', 219 | [u'Mark Lavin'], 1) 220 | ] 221 | 222 | # If true, show URL addresses after external links. 223 | #man_show_urls = False 224 | 225 | 226 | # -- Options for Texinfo output ------------------------------------------------ 227 | 228 | # Grouping the document tree into Texinfo files. List of tuples 229 | # (source start file, target name, title, author, 230 | # dir menu entry, description, category) 231 | texinfo_documents = [ 232 | ('index', 'django-all-access', u'django-all-access Documentation', 233 | u'Mark Lavin', 'django-all-access', 'One line description of project.', 234 | 'Miscellaneous'), 235 | ] 236 | 237 | # Documents to append as an appendix to all manuals. 238 | #texinfo_appendices = [] 239 | 240 | # If false, no module index is generated. 241 | #texinfo_domain_indices = True 242 | 243 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 244 | #texinfo_show_urls = 'footnote' 245 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing Guide 2 | ==================================== 3 | 4 | There are a number of ways to contribute to django-all-access. If you are interested 5 | in making django-all-access better then this guide will help you find a way to contribute. 6 | 7 | 8 | Ways to Contribute 9 | ------------------------------------ 10 | 11 | Not all contributions are source code related. You can contribute to the project by 12 | writing a blog post on using django-all-access and sharing with the 13 | `mailing list `_. You can also 14 | submit bug reports, feature requests or documentation updates through the Github 15 | `issues `_. 16 | 17 | 18 | Getting the Source 19 | ------------------------------------ 20 | 21 | You can clone the repository from Github:: 22 | 23 | git clone git://github.com/mlavin/django-all-access.git 24 | 25 | However this checkout will be read only. If you want to contribute code you should 26 | create a fork and clone your fork. You can then add the main repository as a remote:: 27 | 28 | git clone git@github.com:/django-all-access.git 29 | git remote add upstream git://github.com/mlavin/django-all-access.git 30 | git fetch upstream 31 | 32 | 33 | Running the Tests 34 | ------------------------------------ 35 | 36 | When making changes to the code, either fixing bugs or adding features, you'll want to 37 | run the tests to ensure that you have not broken any of the existing functionality. 38 | With the code checked out and Django installed you can run the tests via:: 39 | 40 | python setup.py test 41 | 42 | or:: 43 | 44 | python runtests.py 45 | 46 | Note that the tests require the `mock `_ library. 47 | To test against multiple versions of Django you can use install and use ``tox>=1.4``. The 48 | ``tox`` command will run the tests against the currently supported Python and Django versions. 49 | 50 | # Build all environments 51 | tox 52 | # Build a single environment 53 | tox -e py27-django18-normal 54 | 55 | Building all environments will also build the documentation. More on that in the next 56 | section. 57 | 58 | 59 | Building the Documentation 60 | ------------------------------------ 61 | 62 | This project aims to have a minimal core with hooks for customization. That makes documentation 63 | an important part of the project. Useful examples and notes on common use cases are a great 64 | way to contribute and improve the documentation. 65 | 66 | The docs are written in `ReST `_ 67 | and built using `Sphinx `_. As noted above, you can use 68 | tox to build the documentation or you can build them on their own via:: 69 | 70 | tox -e docs 71 | 72 | or:: 73 | 74 | make html 75 | 76 | from inside the ``docs/`` directory. 77 | 78 | 79 | Coding Standards 80 | ------------------------------------ 81 | 82 | Code contributions should follow the `PEP8 `_ 83 | and `Django contributing style `_ 84 | standards. Please note that these are only guidelines. Overall code consistency 85 | and readability are more important than strict adherence to these guides. 86 | 87 | 88 | Submitting a Pull Request 89 | ------------------------------------ 90 | 91 | The easiest way to contribute code or documentation changes is through a pull request. 92 | For information on submitting a pull request you can read the Github help page 93 | https://help.github.com/articles/using-pull-requests. 94 | 95 | Pull requests are a place for the code to be reviewed before it is merged. This review 96 | will go over the coding style as well as if it solves the problem intended and fits 97 | in the scope of the project. It may be a long discussion or it might just be a simple 98 | thank you. 99 | 100 | Not necessarily every request will be merged but you should not take it personally 101 | if your change is not accepted. If you want to increase the chances of your change 102 | being incorporated, here are some tips. 103 | 104 | - Address a known issue. Preference is given to a request that fixes a currently open issue. 105 | - Include documentation and tests when appropriate. New features should be tested and documented. Bugfixes should include tests which demonstrate the problem. 106 | - Keep it simple. It's difficult to review a large block of code, so try to keep the scope of the change small. 107 | 108 | If you aren't sure if a particular change is a good idea, or if it would be helpful to 109 | other users, `just ask `_. You should 110 | also feel free to ask for help writing tests or writing documentation if you aren't sure 111 | how to go about it. 112 | -------------------------------------------------------------------------------- /docs/customize-views.rst: -------------------------------------------------------------------------------- 1 | Customizing Redirects and Callbacks 2 | ==================================== 3 | 4 | django-all-access provides default views/urls for authentication. These are built 5 | from Django's `class based views `_ 6 | making them easy to extend or override the default behavior in your project. 7 | 8 | 9 | OAuthRedirect View 10 | ---------------------- 11 | 12 | The initial step for authenticating with any OAuth provider is redirecting the 13 | user to the provider's website. The :py:class:`OAuthRedirect` view extends from the 14 | `RedirectView `_ 15 | By default it is mapped to the ``allaccess-login`` URL name. This view takes one 16 | keyword argument from the URL pattern ``provider`` which corresponds to the ``Provider.name`` 17 | for an enabled provider. If no enabled provider is found for the name, this view 18 | will return a 404. 19 | 20 | .. class:: OAuthRedirect() 21 | 22 | .. attribute:: client_class 23 | 24 | Used to change the :py:class:`BaseOAuthClient` used by the view. See 25 | :py:meth:`OAuthRedirect.get_client` for more details. 26 | 27 | .. versionadded:: 0.8 28 | .. attribute:: params 29 | 30 | Used to pass additional parameters to the authorization redirect (i.e. ``scope`` requests). 31 | See :py:meth:`OAuthRedirect.get_additional_parameters` for more details. 32 | 33 | .. method:: get_client(provider) 34 | 35 | Here you can override the OAuth client class which is used to generate the 36 | redirect URL. Another use case is to disable the enforcement of the OAuth 2.0 37 | ``state`` parameter for providers which don't support it. If you are using 38 | the view for a single provider, it would be easiest to set the 39 | :py:attr:`OAuthRedirect.client_class` attribute on the class instead. 40 | 41 | You should be sure to use the same client class for the callback view as well. 42 | 43 | .. method:: get_redirect_url(**kwargs) 44 | 45 | This method is originally defined by the RedirectView. The redirect URL is 46 | constructed from the ``Provider.authorization_url`` along with the necessary 47 | parameters to match the OAuth specifications. You should not need to override 48 | this method in your application. 49 | 50 | .. method:: get_additional_parameters(provider) 51 | 52 | Here you can return additional parameters for the authorization request. By 53 | default this returns ``{}``. A common usage for overriding this method is 54 | to request additional permissions for the authorization. There is no 55 | standard for additional permissions in the OAuth 1.0 specification. For 56 | an OAuth 2.0 provider this is done with the ``scope`` parameter. 57 | 58 | .. method:: get_callback_url(provider) 59 | 60 | This returns the URL which the remote provider should return the user after 61 | authentication. It is called by :py:meth:`OAuthRedirect.get_redirect_url` to construct 62 | the appropriate redirect URL. By default the reverses the ``allaccess-callback`` 63 | URL name with the passed provider name. 64 | 65 | You may want to override this method in your application if you wish to have 66 | a custom callback for a given provider, a different callback for login vs 67 | registration, or a different callback for an authenticated user associating a 68 | new provider with their account. 69 | 70 | 71 | OAuthCallback View 72 | ---------------------- 73 | 74 | After the user has authenticated with the remote provider or denied access to your application 75 | request, they are returned to the callback specifed in the initial redirect. :py:class:`OAuthCallback` 76 | defines the default behaviour on this callback. This view extends from the base 77 | `View `_ class. 78 | By default it is mapped to the ``allaccess-callback`` URL name. Similar to the :py:class:`OAuthRedirect` view, 79 | this view takes one keyword argument ``provider`` which corresponds to the ``Provider.name`` 80 | for an enabled provider. If no enabled provider is found for the name, this view will return a 404. 81 | 82 | .. class:: OAuthCallback() 83 | 84 | .. attribute:: client_class 85 | 86 | Used to change the :py:class:`BaseOAuthClient` used by the view. See 87 | :py:meth:`OAuthCallback.get_client` for more details. 88 | 89 | .. versionadded:: 0.8 90 | .. attribute:: provider_id 91 | 92 | Used to customize how the user identifier is found from the user profile response from 93 | the provider. If the provider response includes a nested response then this value 94 | can include a dotted path to the id value. 95 | 96 | For example if the response is `{'result': {'user': {'id': 'XXX'}}}` then you can 97 | set this attribute to `result.user.id` to access the value. 98 | See :py:meth:`OAuthCallback.get_user_id` for more details. 99 | 100 | .. method:: get_callback_url(provider) 101 | 102 | This returns the callback URL specified in the initial redirect if it is 103 | different than the current ``request.path``. By default the callback URL will be the same 104 | and this view will return ``None``. You will most likely not need to change this 105 | in your project. 106 | 107 | .. method:: get_client(provider) 108 | 109 | Here you can override the OAuth client class which is used to fetch the access 110 | token and user information. Another use case is to disable the enforcement of 111 | the OAuth 2.0 ``state`` parameter for providers which don't support it. If you 112 | are using the view for a single provider, it would be easiest to set the 113 | :py:attr:`OAuthCallback.client_class` attribute on the class instead. 114 | 115 | You should be sure to use the same client class for the redirect view as well. 116 | 117 | .. method:: get_error_redirect(provider, reason) 118 | 119 | Returns the URL to send the user in the case of an authentication failure. The 120 | ``reason`` is a brief text description of the problem. By default this will return 121 | the user to the original login URL as defined by the ``LOGIN_URL`` setting. 122 | 123 | .. method:: get_login_redirect(provider, user, access, new=False) 124 | 125 | You can use this to customize the URL to send the user on a successful authentication. 126 | By default this will be the ``LOGIN_REDIRECT_URL`` setting. The ``new`` parameter 127 | is there to indicate if this was a newly created or a previously existing user. 128 | 129 | .. method:: get_or_create_user(provider, access, info) 130 | 131 | This method is used by :py:meth:`OAuthCallback.handle_new_user` to construct a new user with a 132 | random username, no email and an unusable password. You may want to override 133 | this user to complete more of their infomation or attempt to match them 134 | to an existing user by either their username or email. 135 | 136 | :py:meth:`OAuthCallback.handle_new_user` will connect the user to the ``access`` record and 137 | does not need to be handled here. 138 | 139 | :note: 140 | 141 | If you are using Django 1.5 support for a custom User model, you 142 | should override this method to ensure the user is created correctly. 143 | 144 | .. method:: get_user_id(provider, info) 145 | 146 | This method should return the unique identifier from the profile information. If 147 | the id cannot be determined, this should return ``None``. The ``info`` parameter 148 | will be the parsed JSON response from the user's profile. If the response wasn't 149 | JSON, it will be the plain text response. By default this looks for a key 150 | ``id`` in the JSON dictionary. This will work for a number of providers, but 151 | will need to be changed to fit more complex response structures. 152 | 153 | You can customize how this lookup is done by setting the :py:attr:`OAuthCallback.provider_id`. 154 | This can be done either in the class definition or when calling `.as_view`. 155 | 156 | .. method:: handle_existing_user(provider, user, access, info) 157 | 158 | At this point the ``user`` has been authenticated via their ``access`` model 159 | with this provider, but they have not been logged in. This method will login 160 | the user and redirect them to the URL returned by 161 | :py:meth:`OAuthCallback.get_login_redirect` with ``new=False``. 162 | 163 | The user's profile info is passed to this method to allow for updating their 164 | data from their provider profile, but this is not done by default. 165 | 166 | .. method:: handle_login_failure(provider, reason) 167 | 168 | In the case of a failure to fetch the user's access token or remote profile information 169 | or determine their id from that info, this method will be called. It attachs a 170 | brief error message to the request via ``contrib.messages`` and redirects the 171 | user to the result of the :py:meth:`OAuthCallback.get_error_redirect` method. You should override 172 | this function to add any additional logging or handling. 173 | 174 | .. method:: handle_new_user(provider, access, info) 175 | 176 | If the user could not be matched to an existing ``AccountAccess`` record for 177 | this provider or that record did not contain a user, this method will be called. 178 | At this point the ``access`` record has already been saved but is not tied to 179 | a user. This will call :py:meth:`OAuthCallback.get_or_create_user` to construct a new user record. 180 | The user is then logged in and redirected to the result of the 181 | :py:meth:`OAuthCallback.get_login_redirect` call with ``new=True`` 182 | 183 | You may want to override this user to complete more of their infomation or 184 | attempt to match them to an existing user by either their username or email. 185 | You may want to override this to redirect them without creating a new user 186 | in order to have them complete another registration form 187 | (i.e. pick a username or provide an email if not returned by the provider). 188 | 189 | 190 | Customization in URLs 191 | ---------------------------------- 192 | 193 | For some minor customizations to the redirects and callbacks, it's possible to 194 | handle that in the URL inclusion rather than by creating a subclass of the view. 195 | The most common customizations are adding additional scope on the redirect 196 | and changing how the provider identifier is found on the callback. Below is an example 197 | ``urls.py`` which handles both of these cases. 198 | 199 | .. code-block:: python 200 | 201 | from django.conf.urls import include, url 202 | 203 | from allaccess.views import OAuthRedirect, OAuthCallback 204 | 205 | urlpatterns = [ 206 | # Customize Facebook redirect to request additional scope 207 | url(r'^accounts/login/(?Pfacebook)/$', 208 | OAuthRedirect.as_view(params={'scope': 'email'})), 209 | # Customize Foursqaure callback to handle nested response 210 | url(r'^accounts/callback/(?Pfoursquare)/$', 211 | OAuthCallback.as_view(provider_id='response.user.id')), 212 | # All other provider cases are handled by the defaults 213 | url(r'^accounts/', include('allaccess.urls')), 214 | ] 215 | 216 | 217 | Additional Scope Example 218 | ---------------------------------- 219 | 220 | As noted above, the default :py:class:`OAuthRedirect` redirect does not request any additional 221 | permissions from the provider. It is recommended by most providers that you limit 222 | the number of additional permissions that you request. The user will see the list 223 | of permissions you are requesting and if they see a long list of permissions they 224 | may decline the authorization. The below example shows how you can request 225 | additional parameters for various providers. 226 | 227 | .. code-block:: python 228 | 229 | from allaccess.views import OAuthRedirect 230 | 231 | class AdditionalPermissionsRedirect(OAuthRedirect): 232 | 233 | def get_additional_parameters(self, provider): 234 | if provider.name == 'facebook': 235 | # Request permission to see user's email 236 | return {'scope': 'email'} 237 | if provider.name == 'google': 238 | # Request permission to see user's profile and email 239 | perms = ['userinfo.email', 'userinfo.profile'] 240 | scope = ' '.join(['https://www.googleapis.com/auth/' + p for p in perms]) 241 | return {'scope': scope} 242 | return super(AdditionalPermissionsRedirect, self).get_additional_parameters(provider) 243 | 244 | This would be used instead of the default :py:class:`OAuthRedirect` for the ``allaccess-login`` URL. 245 | Remember that this logic can be based on the provider or even the current request. That 246 | would allow your project to A/B test requesting more or less permissions to see its 247 | impact on user registrations. 248 | 249 | 250 | Additional Accounts Example 251 | ---------------------------------- 252 | 253 | You may want to allow a user to associate their account on your website with multiple 254 | providers. This example will show a basic outline of how you can customize these 255 | views for that purpose. 256 | 257 | First we will define a new callback which will associate the provider with the current 258 | user rather than creating a new user. This view will also have to handle the case that 259 | another user is associated with the new provider. For this the view will just return 260 | an error. 261 | 262 | .. code-block:: python 263 | 264 | from allaccess.views import OAuthCallback 265 | 266 | class AssociateCallback(OAuthCallback): 267 | 268 | def get_or_create_user(self, provider, access, info): 269 | return self.request.user 270 | 271 | def handle_existing_user(self, provider, user, access, info): 272 | if user != self.request.user: 273 | return self.handle_login_failure(provider, "Another user is associated with this account") 274 | # User was already associated with this account 275 | return super(AssociateCallback, self).handle_existing_user(provider, user, access, info) 276 | 277 | This view will require authentication which is handled in the URL pattern. There 278 | are multiple methods for decorating class based views which are detailed in the 279 | `Django docs `_. 280 | 281 | Next we will need a redirect view to send the user to this callback. This view 282 | will also require that the user already be authenticated which can be handled in 283 | the URL pattern. 284 | 285 | .. code-block:: python 286 | 287 | from django.core.urlresolvers import reverse 288 | from allaccess.views import OAuthRedirect 289 | 290 | class AssociateRedirect(OAuthRedirect): 291 | 292 | def get_callback_url(self, provider): 293 | return reverse('associate-callback', kwargs={'provider': provider.name}) 294 | 295 | This assumes that we named the pattern for the above callback ``associate-callback``. An 296 | example set of URL patterns is given below. 297 | 298 | .. code-block:: python 299 | 300 | from django.contrib.auth.decorators import login_required 301 | 302 | from .views import AssociateRedirect, AssociateCallback 303 | 304 | urlpatterns = [ 305 | url(r'^associate/(?P(\w|-)+)/$', login_required(AssociateRedirect.as_view()), name='associate'), 306 | url(r'^associate-callback/(?P(\w|-)+)/$', login_required(AssociateCallback.as_view()), name='associate-callback'), 307 | ] 308 | 309 | That is the basic outline of how you would allow multiple account associations. This 310 | could be further customized using the hooks described earlier. 311 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-all-access documentation master file, created by 2 | sphinx-quickstart on Tue Jun 19 22:50:45 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../README.rst 7 | 8 | Contents 9 | ------------------------------------ 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | quick-start 15 | providers 16 | customize-views 17 | api-access 18 | contributing 19 | releases 20 | 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-all-access.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-all-access.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/providers.rst: -------------------------------------------------------------------------------- 1 | Configuring Providers 2 | ==================================== 3 | 4 | django-all-access configures and stores the set of OAuth providers in the database. 5 | To enable your users to authenticate with a particular provider, you will need to add 6 | the OAuth API URLs as well as your application's consumer key and consumer secret. 7 | The process of registering your application with each provider will vary and 8 | you should refer to the provider's API documentation for more information. 9 | 10 | .. note:: 11 | 12 | While the consumer key/secret pairs are stored in the database 13 | as opposed to putting them in the settings file, they are encrypted using the 14 | `AES specification `_. 15 | Since this is a symmetric-key encryption the key/secret pairs can still be read 16 | if the encryption key is compromised. In this case django-all-access uses a 17 | key based on the standard ``SECRET_KEY`` setting. You should take care to keep 18 | this setting secret as its name would imply. 19 | 20 | 21 | Common Providers 22 | ------------------------------------ 23 | 24 | To get you started, there is an initial fixture of commonly used providers. This includes 25 | the URLs needed for Facebook, Twitter, Google, Microsoft Live, Github and Bitbucket. Once you've 26 | added ``allaccess`` to your ``INSTALLED_APP`` and created the tables with ``migrate``, 27 | you can load this fixture via:: 28 | 29 | python manage.py loaddata common_providers.json 30 | 31 | This does not include the consumer id/key or secret which will still need to be added 32 | to the records. The below examples will help you understand what these values mean 33 | and how they would be populated for additional providers you might want to use. 34 | 35 | 36 | OAuth 1.0 Providers 37 | ------------------------------------ 38 | 39 | OAuth 1.0 Protocol is defined by `RFC 5849 `_. 40 | It is sometimes referred to as 3-Legged OAuth due to the number of requests 41 | between the provider and consumer. 42 | 43 | To enable an OAuth provider, you should add a ``Provider`` record with the necessary 44 | ``request_token_url``, ``authorization_url`` and ``access_token_url`` as defined 45 | by the protocol. The provider's API documentation should detail these for you. You 46 | will also need to define a ``profile_url`` which is the API endpoint for requesting 47 | the currently authenticated user's profile information. You will also need to 48 | register for a key/secret pair from the provider. 49 | 50 | This protocol is implemented by a number of providers. These providers 51 | include Twitter, Netflix, Yahoo, Linkedin, Flickr, Bitbucket, and Dropbox. 52 | Additional providers can be found on the 53 | `OAuth.net Wiki `_. 54 | 55 | 56 | Twitter Example 57 | ------------------------------------ 58 | 59 | Twitter is a popular social website which provides a REST API with OAuth 1.0 60 | authentication. If you wanted to enable Twitter authentication on your website 61 | using django-all-access, you would create the following ``Provider`` record:: 62 | 63 | name: twitter 64 | request_token_url: https://api.twitter.com/oauth/request_token 65 | authorization_url: https://api.twitter.com/oauth/authenticate 66 | access_token_url: https://api.twitter.com/oauth/access_token 67 | profile_url: https://api.twitter.com/1.1/account/verify_credentials.json 68 | 69 | After adding your consumer key and secret to this record you should now be able 70 | to authenticate with Twitter by visiting ``/accounts/login/twitter/``. 71 | You can find more information on the Twitter API on their `developer site `_. 72 | 73 | 74 | OAuth 2.0 Providers 75 | ------------------------------------ 76 | 77 | Unlike OAuth 1.0, OAuth 2.0 is only a `working draft `_ 78 | and not an official standard. In many ways it is much simpler than its predecessor. 79 | It is often referred to as 2-Legged OAuth because it removes the need for the 80 | request token step. 81 | 82 | To enable an OAuth provider, you should add a ``Provider`` record with the necessary 83 | ``authorization_url`` and ``access_token_url`` as defined by the protocol. 84 | The provider's API documentation should detail these for you. You 85 | will also need to define a ``profile_url`` which is the API endpoint for requesting 86 | the currently authenticated user's profile information. You will also need to 87 | register for a key/secret pair from the provider. 88 | 89 | Providers which implement the OAuth 2.0 protocol include Facebook, Google, 90 | FourSquare, Meetup, Github, and Yammer. 91 | 92 | 93 | Facebook Example 94 | ------------------------------------ 95 | 96 | Facebook is a large social network which provides a REST API with OAuth 2.0 97 | authentication. The below ``Provider`` record will enable Facebook authentication:: 98 | 99 | name: facebook 100 | authorization_url: https://www.facebook.com/v2.8/dialog/oauth 101 | access_token_url: https://graph.facebook.com/v2.8/oauth/access_token 102 | profile_url: https://graph.facebook.com/v2.8/me 103 | 104 | As you can see, the ``request_token_url`` is not included because it is not needed. 105 | After adding your consumer key and secret to this record you should now be able 106 | to authenticate with Facebook by visiting ``/accounts/login/facebook/``. 107 | Facebook also has `developer docs `_ 108 | for additional information on using their API. 109 | 110 | .. note:: 111 | 112 | Facebook began using the version number in the URL as part of their 2.0 API. 113 | Since then very little has changed with regard to the OAuth flow but the 114 | version number is now required. The latest version of the API might not 115 | match the documentation here. For the most up to date info on the Facebook 116 | API you should consult their API docs. 117 | -------------------------------------------------------------------------------- /docs/quick-start.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | ==================================== 3 | 4 | Below are the basic steps need to get django-all-access integrated into your 5 | Django project. 6 | 7 | 8 | Configure Settings 9 | ------------------------------------ 10 | 11 | You need to add ``allaccess`` to your installed apps as well as include an 12 | additional authentication backend in your project settings. django-all-access requires 13 | ``django.contrib.auth``, ``django.contrib.sessions`` and ``django.contrib.messages`` 14 | which are enabled in Django by default. ``django.contrib.admin`` is recommended 15 | for managing the set of providers, but is not required. 16 | 17 | .. code-block:: python 18 | 19 | INSTALLED_APPS = ( 20 | # Required contrib apps 21 | 'django.contrib.auth', 22 | 'django.contrib.sessions', 23 | 'django.contrib.messages', 24 | # Optional 25 | 'django.contrib.admin', 26 | # Other installed apps would go here 27 | 'allaccess', 28 | ) 29 | 30 | AUTHENTICATION_BACKENDS = ( 31 | # Default backend 32 | 'django.contrib.auth.backends.ModelBackend', 33 | # Additional backend 34 | 'allaccess.backends.AuthorizedServiceBackend', 35 | ) 36 | 37 | Note that ``AUTHENTICATION_BACKENDS`` is not included in the default settings 38 | created by ``startproject``. If you want to continue to use the default 39 | username/password based authentication, you should be sure to include 40 | ``django.contrib.auth.backends.ModelBackend`` in this setting. 41 | 42 | By default, django-all-access uses the built-in Django settings ``LOGIN_URL`` and 43 | ``LOGIN_REDIRECT_URL``. You should be sure that these are set to valid URLs for 44 | your site. 45 | 46 | 47 | Configure Urls 48 | ------------------------------------ 49 | 50 | To use the default redirect and callback views, you should include them in 51 | your root URL configuration. 52 | 53 | .. code-block:: python 54 | 55 | from django.conf.urls import include 56 | 57 | 58 | urlpatterns = [ 59 | # Other URL patterns would go here 60 | url(r'^accounts/', include('allaccess.urls')), 61 | ] 62 | 63 | This makes the login URL for a particular provider ``/accounts/login//``, 64 | such as ``/accounts/login/twitter/`` or ``/accounts/login/facebook/``. Once the user 65 | has authenticated with the remote provider, they will be sent back to 66 | ``/accounts/callback//``, such as ``/accounts/callback/twitter/`` 67 | or ``/accounts/callback/facebook/``. 68 | 69 | 70 | Create Database Tables 71 | ------------------------------------ 72 | 73 | You'll need to create the necessary database tables for storing OAuth providers and 74 | user associations with those providers. This is done with the ``migrate`` management 75 | command built into Django:: 76 | 77 | python manage.py migrate allaccess 78 | 79 | 80 | Next Steps 81 | ------------------------------------ 82 | 83 | At this point your project is configured to use the default django-all-access 84 | authentication, but no providers have been added. Continue reading to learn how 85 | to add providers for your project. 86 | -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | Release History 2 | ==================================== 3 | 4 | Release and change history for django-all-access 5 | 6 | 7 | v0.9.0 (2016-11-12) 8 | ----------------------------------- 9 | 10 | Encrypted fields for storing the provider configurations and access tokens 11 | now sign the values after encryption to dectect if the key is valid before 12 | attempting to decrypt. This was added thanks to Florian Demmer (@fdemmer). 13 | 14 | Other small changes include: 15 | 16 | - Added Django 1.10 and Python 3.5 to the test suite coverage. 17 | - Updated documentation on Facebook version numbers. 18 | - Update provider fixtures to include the latest version number for Facebook. 19 | 20 | 21 | v0.8.0 (2016-01-23) 22 | ----------------------------------- 23 | 24 | Minor clean up release which drops support for outdated versions of Django. As 25 | such it also removes the old South migrations and the commands related to 26 | django-social-auth. 27 | 28 | - Added support for additional parameters in the redirect view. 29 | - Added support for more complex id lookups in the callback view. 30 | - Additional documentation examples for customizing the views. 31 | - Added support for Django 1.9. 32 | - Tracking code coverage reports with Codecov.io. 33 | 34 | 35 | Backwards Incompatible Changes 36 | __________________________________ 37 | 38 | - Python 3.2 is no longer officially supported or tested. 39 | - Django < 1.8 is no longer officially supported or tested. 40 | - requests_oauthlib < 0.4.2 is no longer officially supported. 41 | - ``migrate_social_accounts`` and ``migrate_social_accounts`` commands have been removed. 42 | 43 | 44 | v0.7.2 (2015-05-13) 45 | ------------------------------------ 46 | 47 | - Model updates for Django 1.8 compatibility. Requires a non-DB altering migration. 48 | 49 | 50 | v0.7.1 (2015-04-19) 51 | ------------------------------------ 52 | 53 | - Fixed issue in ``migrate_social_accounts`` where output was overly verbose. 54 | - Fixed issue in ``migrate_social_accounts`` with handling skipped providers. 55 | 56 | 57 | v0.7.0 (2014-09-07) 58 | ------------------------------------ 59 | 60 | This release adds support for 1.7 and the new style migrations. If you are using Django < 1.7 61 | and South >= 1.0 this should continue to work without issue. 62 | 63 | For those using Django < 1.7 and South < 1.0 you'll need 64 | to add the ``SOUTH_MIGRATION_MODULES`` setting to point to the old South migrations. 65 | 66 | .. code-block:: python 67 | 68 | SOUTH_MIGRATION_MODULES = { 69 | 'allaccess': 'allaccess.south_migrations', 70 | } 71 | 72 | No new migrations were added for this release, but this will be the new location for future migrations. If your 73 | DB tables are up to date from v0.6, upgrading to 1.7 and running:: 74 | 75 | python manage.py migrate allaccess 76 | 77 | should automatically fake the initial migration using the new-style migrations. 78 | 79 | 80 | Backwards Incompatible Changes 81 | __________________________________ 82 | 83 | - Python 2.6 is no longer officially supported or tested. 84 | 85 | 86 | v0.6.0 (2014-02-01) 87 | ------------------------------------ 88 | 89 | This release adds a better migration path for moving from django-social-auth and includes changes to support 90 | running on the Google App Engine. There are two South migrations included with this release. To upgrade, you should run:: 91 | 92 | python manage.py migrate allaccess 93 | 94 | More details for this change are noted under the "Backwards Incompatible Changes". 95 | 96 | - Added ``migrate_social_accounts`` and ``migrate_social_providers`` management commands to help migrate data from django-social-auth. 97 | - Updated ``Provider`` model for compatibility with running on the Google App Engine. Thanks to Marco Seguri for the report and fix. 98 | - Increased the URL lengths for the fields on the ``Provider`` model. Thanks to Marco Seguri for the fix. 99 | - Added support for serialization of ``Provider`` and ``AccountAccess`` records by natural keys. 100 | - Included a fixture of common providers (Facebook, Twitter, Google, Microsoft Live, Github and Bitbucket). Thanks to Marco Seguri for the initial patch. 101 | 102 | 103 | Backwards Incompatible Changes 104 | __________________________________ 105 | 106 | - The ``key`` and ``secret`` columns on ``Provider`` were renamed to ``consumer_key`` and ``consumer_secret``. ``key`` is a reserved property 107 | name when using Google App Engine and ``secret`` was changed as well for consistency. A migration has been added for the change but 108 | if you were referencing the ``key``/``secret`` explicitly in your code those references need to be updated as well. 109 | - ``ProviderManager.enabled`` has been removed. This was a short-cut method for filtering out providers with key or secret values. However, 110 | it doesn't work on Google App Engine. It was only used in a few places internally so it was removed. The equivalent query is 111 | ``Provider.objects.filter(consumer_secret__isnull=False, consumer_key__isnull=False)`` 112 | 113 | 114 | v0.5.1 (2013-08-16) 115 | ------------------------------------ 116 | 117 | - Fix incompatibility with the existing South migrations and a customized User model. Thanks to Jharrod LaFon for the report and fix. 118 | 119 | 120 | v0.5.0 (2013-03-18) 121 | ------------------------------------ 122 | 123 | This release adds additional hooks for changing the OAuth client behaviors. It also 124 | adds support for Python 3.2+. 125 | 126 | - New view hooks for customizing the OAuth client 127 | - Fixed issue with including oauth_verifier in POST when fetching the access token 128 | - Documented the API for :py:class:`OAuthClient` and :py:class:`OAuth2Client` 129 | - Updated requirements to requests >= 1.0 and requests_oauthlib >= 0.3.0 130 | - Updated requirement for PyCrypto >= 2.4 131 | 132 | Backwards Incompatible Changes 133 | __________________________________ 134 | 135 | - Dropped support for requests < 1.0 136 | - Dropped support for Django < 1.4.2 137 | 138 | 139 | v0.4.1 (2013-01-02) 140 | ------------------------------------ 141 | 142 | There were incompatibilty issues with requests-oauthlib (0.2) and requests which 143 | required dropping requests 1.0 support. The requirement of oauthlib was also raised 144 | to 0.3.4 due to similar issues. For more detail see the below issues. 145 | 146 | - https://github.com/requests/requests-oauthlib/issues/1 147 | - https://github.com/requests/requests-oauthlib/pull/10 148 | 149 | 150 | v0.4.0 (2012-12-19) 151 | ------------------------------------ 152 | 153 | This release is largely to keep pace with features/changes to some of the 154 | dependencies. This also helps work toward Python 3.0 support. 155 | 156 | - Updated for compatibility with Django 1.4 timezone support 157 | - Updated for compatibility with Django 1.5 swappable ``auth.User`` 158 | - Updated for compatibility with Requests 1.0 159 | - Added requests_oauthlib requirement 160 | - Updated requirement of oauthlib to 0.3 or higher 161 | 162 | 163 | v0.3.0 (2012-07-13) 164 | ------------------------------------ 165 | 166 | This release added some basic logging to django-all-access. To enable this logging 167 | in your project, you should update your ``LOGGING`` configuration to include 168 | ``allaccess`` in the ``loggers`` section. Below is an example: 169 | 170 | .. code-block:: python 171 | 172 | LOGGING = { 173 | 'handlers': { 174 | 'console':{ 175 | 'level':'DEBUG', 176 | 'class':'logging.StreamHandler', 177 | }, 178 | 'mail_admins': { 179 | 'level': 'ERROR', 180 | 'class': 'django.utils.log.AdminEmailHandler', 181 | 'filters': ['special'] 182 | } 183 | }, 184 | 'loggers': { 185 | 'django.request': { 186 | 'handlers': ['mail_admins', ], 187 | 'level': 'ERROR', 188 | 'propagate': True, 189 | }, 190 | 'allaccess': { 191 | 'handlers': ['console', ], 192 | 'level': 'INFO', 193 | } 194 | } 195 | } 196 | 197 | For more information on logging please see the 198 | `Django documentation `_ 199 | or the `Python documentation `_. 200 | 201 | 202 | Features 203 | _________________ 204 | 205 | - Added access to simple API wrapper through the ``AccountAccess`` model 206 | - Added state parameter for OAuth 2.0 by default 207 | - Added basic error logging to OAuth clients and views 208 | - Added contributing guide and mailing list info 209 | 210 | 211 | v0.2.1 (2012-06-29) 212 | ------------------------------------ 213 | 214 | Bug Fixes 215 | _________________ 216 | 217 | - Fixes missing Content-Length header when requesting OAuth 2.0 access token 218 | 219 | 220 | v0.2.0 (2012-06-24) 221 | ------------------------------------ 222 | 223 | There are two South migrations included with this release. To upgrade you should run:: 224 | 225 | python manage.py migrate allaccess 226 | 227 | If you are not using South, you will not need to change your database schema because 228 | the underlying field type did not change. However, you should re-save all existing 229 | ``AccountAccess`` instances to ensure that their access tokens go through the encryption step 230 | 231 | .. code-block:: python 232 | 233 | from allaccess.models import AccountAccess 234 | 235 | for access in AccountAccess.objects.all(): 236 | access.save() 237 | 238 | 239 | Features 240 | _________________ 241 | 242 | - ``OAuthRedirect`` view can now specify a callback URL 243 | - ``OAuthRedirect`` view can now specify additional permissions 244 | - Context processor for adding enabled providers to the template context 245 | - User access tokens are stored with AES encryption 246 | - Documentation on customizing the view workflow behaviors 247 | - Travis CI integration 248 | 249 | Bug Fixes 250 | _________________ 251 | 252 | - Fixed OAuth2Client to include ``grant_type`` paramater when requesting access token 253 | - Fixed OAuth2Client to match current OAuth draft for access token response as well as legacy response from Facebook 254 | 255 | 256 | Backwards Incompatible Changes 257 | __________________________________ 258 | 259 | - Moving the construction on the callback from the client to the view changed the signature of the client ``get_redirect_url``, ``get_redirect_args``, ``get_request_token`` (OAuth 1.0 only) and ``get_access_token`` to include the callback. These are largely internal functions and likely will not impact existing applications. 260 | - The ``AccountAccess.access_token`` field was changed from a plain text field to an encrypted field. See previous note on migrating this data. 261 | 262 | 263 | v0.1.1 (2012-06-22) 264 | ------------------------------------ 265 | 266 | - Fixed bug with passing incorrect callback parameter for OAuth 1.0 267 | - Additional documentation on configuring ``LOGIN_URL`` and ``LOGIN_REDIRECT_URL`` 268 | - Additional view tests 269 | - Handled poor ``LOGIN_URL`` and ``LOGIN_REDIRECT_URL`` settings in view tests 270 | 271 | 272 | v0.1.0 (2012-06-21) 273 | ------------------------------------ 274 | 275 | - Initial public release. 276 | -------------------------------------------------------------------------------- /example/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/example/db.sqlite3 -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | import os 3 | import dj_database_url 4 | 5 | 6 | BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 7 | 8 | DEBUG = os.environ.get('DEBUG', 'on') == 'on' 9 | 10 | ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost').split(';') 11 | 12 | DATABASES = { 13 | 'default': dj_database_url.config(default='postgres:///allaccess'), 14 | } 15 | 16 | # Local time zone for this installation. Choices can be found here: 17 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 18 | # although not all choices may be available on all operating systems. 19 | # On Unix systems, a value of None will cause Django to use the same 20 | # timezone as the operating system. 21 | # If running in a Windows environment this must be set to the same as your 22 | # system time zone. 23 | TIME_ZONE = 'UTC' 24 | 25 | # Language code for this installation. All choices can be found here: 26 | # http://www.i18nguy.com/unicode/language-identifiers.html 27 | LANGUAGE_CODE = 'en-us' 28 | 29 | # If you set this to False, Django will make some optimizations so as not 30 | # to load the internationalization machinery. 31 | USE_I18N = True 32 | 33 | # If you set this to False, Django will not format dates, numbers and 34 | # calendars according to the current locale. 35 | USE_L10N = True 36 | 37 | # If you set this to False, Django will not use timezone-aware datetimes. 38 | USE_TZ = True 39 | 40 | # Absolute filesystem path to the directory that will hold user-uploaded files. 41 | # Example: "/home/media/media.lawrence.com/media/" 42 | MEDIA_ROOT = '' 43 | 44 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 45 | # trailing slash. 46 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 47 | MEDIA_URL = '' 48 | 49 | # Absolute path to the directory static files should be collected to. 50 | # Don't put anything in this directory yourself; store your static files 51 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 52 | # Example: "/home/media/media.lawrence.com/static/" 53 | STATIC_ROOT = os.path.join(BASE_DIR, os.pardir, 'static') 54 | 55 | # URL prefix for static files. 56 | # Example: "http://media.lawrence.com/static/" 57 | STATIC_URL = '/static/' 58 | 59 | # Additional locations of static files 60 | STATICFILES_DIRS = ( 61 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 62 | # Always use forward slashes, even on Windows. 63 | # Don't forget to use absolute paths, not relative paths. 64 | os.path.join(BASE_DIR, 'static'), 65 | ) 66 | 67 | # Make this unique, and don't share it with anybody. 68 | DEFAULT_SECRET_KEY = '&k9%jt6ozi$7o+xalyhx9=^e6+*mtb*s9)crr9mhp=ti8utykl' 69 | 70 | SECRET_KEY = os.environ.get('SECRET_KEY', DEFAULT_SECRET_KEY) 71 | 72 | 73 | MIDDLEWARE_CLASSES = ( 74 | 'django.middleware.security.SecurityMiddleware', 75 | 'django.contrib.sessions.middleware.SessionMiddleware', 76 | 'django.middleware.common.CommonMiddleware', 77 | 'django.middleware.csrf.CsrfViewMiddleware', 78 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 79 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 80 | 'django.contrib.messages.middleware.MessageMiddleware', 81 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 82 | ) 83 | 84 | ROOT_URLCONF = 'example.urls' 85 | 86 | # Python dotted path to the WSGI application used by Django's runserver. 87 | WSGI_APPLICATION = 'example.wsgi.application' 88 | 89 | INSTALLED_APPS = ( 90 | 'django.contrib.auth', 91 | 'django.contrib.contenttypes', 92 | 'django.contrib.sessions', 93 | 'django.contrib.messages', 94 | 'django.contrib.staticfiles', 95 | 'django.contrib.admin', 96 | 'allaccess', 97 | ) 98 | 99 | # A sample logging configuration. The only tangible logging 100 | # performed by this configuration is to send an email to 101 | # the site admins on every HTTP 500 error when DEBUG=False. 102 | # See http://docs.djangoproject.com/en/dev/topics/logging for 103 | # more details on how to customize your logging configuration. 104 | LOGGING = { 105 | 'version': 1, 106 | 'disable_existing_loggers': False, 107 | 'filters': { 108 | 'require_debug_false': { 109 | '()': 'django.utils.log.RequireDebugFalse' 110 | } 111 | }, 112 | 'handlers': { 113 | 'console': { 114 | 'level': 'DEBUG', 115 | 'class': 'logging.StreamHandler', 116 | }, 117 | 'mail_admins': { 118 | 'level': 'ERROR', 119 | 'filters': ['require_debug_false'], 120 | 'class': 'django.utils.log.AdminEmailHandler' 121 | } 122 | }, 123 | 'loggers': { 124 | 'django.request': { 125 | 'handlers': ['mail_admins'], 126 | 'level': 'ERROR', 127 | 'propagate': True, 128 | }, 129 | 'allaccess': { 130 | 'handlers': ['console', ], 131 | 'level': 'INFO', 132 | }, 133 | } 134 | } 135 | 136 | AUTHENTICATION_BACKENDS = ( 137 | 'django.contrib.auth.backends.ModelBackend', 138 | 'allaccess.backends.AuthorizedServiceBackend', 139 | ) 140 | 141 | LOGIN_URL = '/' 142 | 143 | LOGIN_REDIRECT_URL = '/' 144 | 145 | TEMPLATES = [ 146 | { 147 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 148 | 'DIRS': [os.path.join(BASE_DIR, 'templates'), ], 149 | 'APP_DIRS': True, 150 | 'OPTIONS': { 151 | 'context_processors': [ 152 | 'django.template.context_processors.debug', 153 | 'django.template.context_processors.request', 154 | 'django.contrib.auth.context_processors.auth', 155 | 'django.contrib.messages.context_processors.messages', 156 | 'allaccess.context_processors.available_providers', 157 | ], 158 | }, 159 | }, 160 | ] 161 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | from django.contrib.auth.views import logout_then_login 4 | 5 | from .views import home 6 | 7 | 8 | urlpatterns = [ 9 | url(r'^admin/', admin.site.urls), 10 | url(r'^accounts/', include('allaccess.urls')), 11 | url(r'^logout/$', logout_then_login, name='logout'), 12 | url(r'^$', home, name='home'), 13 | ] 14 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def home(request): 5 | "Simple homepage view." 6 | context = {} 7 | if request.user.is_authenticated(): 8 | try: 9 | access = request.user.accountaccess_set.all()[0] 10 | except IndexError: 11 | access = None 12 | else: 13 | client = access.api_client 14 | context['info'] = client.get_profile_info(raw_token=access.access_token) 15 | return render(request, 'home.html', context) 16 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | from dj_static import Cling 25 | 26 | application = Cling(get_wsgi_application()) 27 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/static/font/zocial-regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/example/static/font/zocial-regular-webfont.eot -------------------------------------------------------------------------------- /example/static/font/zocial-regular-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/example/static/font/zocial-regular-webfont.ttf -------------------------------------------------------------------------------- /example/static/font/zocial-regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlavin/django-all-access/4b15b6c9dedf8080a7c477e0af1142c609ec5598/example/static/font/zocial-regular-webfont.woff -------------------------------------------------------------------------------- /example/templates/home.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | {% if user.is_authenticated %} 16 |

You are now signed in!

17 | 24 | {% else %} 25 |

django-all-access Demo

26 |

This is a simple demo application for django-all-access. Use the buttons below to sign in with your favorite OAuth provider.

27 | 34 | {% endif %} 35 |
36 | Fork me on GitHub 37 | 38 | 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Deployment requirements 2 | django-all-access==0.9 3 | requests-oauthlib==0.7.0 4 | requests==2.11.1 5 | oauthlib==2.0.0 6 | pycrypto==2.6.1 7 | Django==1.10.3 8 | gunicorn>=19.6,<19.7 9 | dj-database-url==0.3.0 10 | dj-static==0.0.6 11 | static3==0.7.0 12 | psycopg2==2.6.2 13 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | 8 | SWAPPED = os.environ.get('SWAPPED', False) 9 | 10 | INSTALLED_APPS = [ 11 | 'django.contrib.auth', 12 | 'django.contrib.contenttypes', 13 | 'django.contrib.sessions', 14 | 'django.contrib.messages', 15 | 'django.contrib.staticfiles', 16 | 'django.contrib.admin', 17 | 'allaccess', 18 | ] 19 | 20 | if not settings.configured: 21 | settings.configure( 22 | DATABASES={ 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.sqlite3', 25 | 'NAME': ':memory:', 26 | } 27 | }, 28 | INSTALLED_APPS=INSTALLED_APPS, 29 | MIDDLEWARE_CLASSES=( 30 | 'django.middleware.common.CommonMiddleware', 31 | 'django.contrib.sessions.middleware.SessionMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.contrib.messages.middleware.MessageMiddleware', 35 | ), 36 | AUTHENTICATION_BACKENDS=( 37 | 'allaccess.backends.AuthorizedServiceBackend', 38 | ), 39 | SECRET_KEY='9a0e2569bccc45e49ba8e393233fc427', 40 | ROOT_URLCONF='allaccess.tests.urls', 41 | LOGIN_URL='/login/', 42 | LOGIN_REDIRECT_URL='/', 43 | USE_TZ=True, 44 | ) 45 | 46 | 47 | if SWAPPED: 48 | settings.INSTALLED_APPS.append('allaccess.tests.custom') 49 | settings.AUTH_USER_MODEL = 'custom.MyUser' 50 | 51 | from django.test.utils import get_runner 52 | 53 | 54 | def runtests(): 55 | django.setup() 56 | apps = sys.argv[1:] or ['allaccess', ] 57 | TestRunner = get_runner(settings) 58 | test_runner = TestRunner(verbosity=1, interactive=True, failfast=False) 59 | failures = test_runner.run_tests(apps) 60 | sys.exit(failures) 61 | 62 | 63 | if __name__ == '__main__': 64 | runtests() 65 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.4.2 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | omit = */tests/*, example/*, */migrations/*, .tox/*, setup.py, runtests.py 4 | source = . 5 | 6 | [coverage:report] 7 | show_missing = true 8 | 9 | [bdist_wheel] 10 | universal = 1 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | def read_file(filename): 6 | """Read a file into a string""" 7 | path = os.path.abspath(os.path.dirname(__file__)) 8 | filepath = os.path.join(path, filename) 9 | try: 10 | return open(filepath).read() 11 | except IOError: 12 | return '' 13 | 14 | 15 | setup( 16 | name='django-all-access', 17 | version=__import__('allaccess').__version__, 18 | author='Mark Lavin', 19 | author_email='markdlavin@gmail.com', 20 | packages=find_packages(), 21 | include_package_data=True, 22 | url='https://github.com/mlavin/django-all-access', 23 | license='BSD', 24 | description=' '.join(__import__('allaccess').__doc__.splitlines()).strip(), 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'Framework :: Django', 29 | 'Framework :: Django :: 1.8', 30 | 'Framework :: Django :: 1.9', 31 | 'Framework :: Django :: 1.10', 32 | 'License :: OSI Approved :: BSD License', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 2', 36 | 'Programming Language :: Python :: 2.7', 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.3', 39 | 'Programming Language :: Python :: 3.4', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | ], 44 | long_description=read_file('README.rst'), 45 | install_requires=( 46 | 'pycrypto>=2.4', 47 | 'requests>=2.0', 48 | 'requests_oauthlib>=0.4.2', 49 | 'oauthlib>=0.6.2', 50 | ), 51 | tests_require=('mock>=0.8', ), 52 | test_suite="runtests.runtests", 53 | zip_safe=False, 54 | ) 55 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{27,33}-django{18}-{normal,custom},py{27,34,35}-django{19,110}-{normal,custom},docs 3 | 4 | [testenv] 5 | commands = coverage run runtests.py 6 | basepython = 7 | py27: python2.7 8 | py33: python3.3 9 | py34: python3.4 10 | py35: python3.5 11 | deps = 12 | coverage>=4.2,<4.3 13 | django18: Django>=1.8,<1.9 14 | django19: Django>=1.9,<1.10 15 | django110: Django>=1.10,<1.11 16 | py27: mock>=1.0,<2.0 17 | setenv = 18 | custom: SWAPPED=1 19 | 20 | [testenv:docs] 21 | basepython = python2.7 22 | deps = Sphinx==1.1.3 23 | commands = 24 | {envbindir}/sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html 25 | --------------------------------------------------------------------------------