\d+)$', UserView.as_view(), name='user'),
42 | ]
43 |
--------------------------------------------------------------------------------
/provider/oauth2/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | The default implementation of the OAuth provider includes two public endpoints
3 | that are meant for client (as defined in :rfc:`1`) interaction.
4 |
5 | .. attribute:: ^authorize/$
6 |
7 | This is the URL where a client should redirect a user to for authorization.
8 |
9 | This endpoint expects the parameters defined in :rfc:`4.1.1` and returns
10 | responses as defined in :rfc:`4.1.2` and :rfc:`4.1.2.1`.
11 |
12 | .. attribute:: ^access_token/$
13 |
14 | This is the URL where a client exchanges a grant for an access tokens.
15 |
16 | This endpoint expects different parameters depending on the grant type:
17 |
18 | * Access tokens: :rfc:`4.1.3`
19 | * Refresh tokens: :rfc:`6`
20 | * Password grant: :rfc:`4.3.2`
21 |
22 | This endpoint returns responses depending on the grant type:
23 |
24 | * Access tokens: :rfc:`4.1.4` and :rfc:`5.1`
25 | * Refresh tokens: :rfc:`4.1.4` and :rfc:`5.1`
26 | * Password grant: :rfc:`5.1`
27 |
28 | To override, remove or add grant types, override the appropriate methods on
29 | :class:`provider.views.AccessToken` and / or
30 | :class:`provider.oauth2.views.AccessTokenView`.
31 |
32 | Errors are outlined in :rfc:`5.2`.
33 |
34 | """
35 |
36 | from django.contrib.auth.decorators import login_required
37 | from django.views.decorators.csrf import csrf_exempt
38 | from django.conf.urls import url, include
39 | from provider.oauth2 import views
40 |
41 | app_name = 'oauth2'
42 |
43 | urlpatterns = [
44 | url('^authorize/?$',
45 | login_required(views.CaptureView.as_view()),
46 | name='capture'),
47 | url('^authorize/confirm/?$',
48 | login_required(views.AuthorizeView.as_view()),
49 | name='authorize'),
50 | url('^redirect/?$',
51 | login_required(views.RedirectView.as_view()),
52 | name='redirect'),
53 | url('^access_token/?$',
54 | csrf_exempt(views.AccessTokenView.as_view()),
55 | name='access_token'),
56 | ]
57 |
--------------------------------------------------------------------------------
/provider/oauth2/views.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from django.shortcuts import reverse
3 | from provider import constants
4 | from provider.views import CaptureViewBase, AuthorizeViewBase, RedirectViewBase
5 | from provider.views import AccessTokenViewBase, OAuthError
6 | from provider.utils import now
7 | from provider.oauth2 import forms
8 | from provider.oauth2 import models
9 | from provider.oauth2 import backends
10 |
11 | class CaptureView(CaptureViewBase):
12 | """
13 | Implementation of :class:`provider.views.Capture`.
14 | """
15 |
16 | def validate_scopes(self, scope_list):
17 | scopes = {s.name for s in
18 | models.Scope.objects.filter(name__in=scope_list)}
19 | return set(scope_list).issubset(scopes)
20 |
21 | def get_redirect_url(self, request):
22 | return reverse('oauth2:authorize')
23 |
24 |
25 | class AuthorizeView(AuthorizeViewBase):
26 | """
27 | Implementation of :class:`provider.views.Authorize`.
28 | """
29 | def get_request_form(self, client, data):
30 | return forms.AuthorizationRequestForm(data, client=client)
31 |
32 | def get_authorization_form(self, request, client, data, client_data):
33 | return forms.AuthorizationForm(data)
34 |
35 | def get_client(self, client_id):
36 | try:
37 | return models.Client.objects.get(client_id=client_id)
38 | except models.Client.DoesNotExist:
39 | return None
40 |
41 | def get_redirect_url(self, request):
42 | return reverse('oauth2:redirect')
43 |
44 | def has_authorization(self, request, client, scope_list):
45 | if client.auto_authorize:
46 | return True
47 | if client.authorize_every_time:
48 | return False
49 |
50 | authclient_mgr = models.AuthorizedClient.objects
51 | auth = authclient_mgr.check_authorization_scope(request.user,
52 | client,
53 | scope_list)
54 | return bool(auth)
55 |
56 | def save_authorization(self, request, client, form, client_data):
57 |
58 | scope_list = {s for s in form.cleaned_data['scope']}
59 | models.AuthorizedClient.objects.set_authorization_scope(request.user,
60 | client,
61 | scope_list)
62 |
63 | grant = form.save(user=request.user,
64 | client=client,
65 | redirect_uri=client_data.get('redirect_uri', ''))
66 |
67 | if grant is None:
68 | return None
69 |
70 | grant.user = request.user
71 | grant.client = client
72 | grant.redirect_uri = client_data.get('redirect_uri', '')
73 | grant.save()
74 | return grant.code
75 |
76 |
77 | class RedirectView(RedirectViewBase):
78 | """
79 | Implementation of :class:`provider.views.Redirect`
80 | """
81 | pass
82 |
83 |
84 | class AccessTokenView(AccessTokenViewBase):
85 | """
86 | Implementation of :class:`provider.views.AccessToken`.
87 |
88 | .. note:: This implementation does provide all default grant types defined
89 | in :attr:`provider.views.AccessToken.grant_types`. If you
90 | wish to disable any, you can override the :meth:`get_handler` method
91 | *or* the :attr:`grant_types` list.
92 | """
93 | authentication = (
94 | backends.BasicClientBackend,
95 | backends.RequestParamsClientBackend,
96 | backends.PublicPasswordBackend,
97 | backends.PublicClientBackend,
98 | )
99 |
100 | def get_authorization_code_grant(self, request, data, client):
101 | form = forms.AuthorizationCodeGrantForm(data, client=client)
102 | if not form.is_valid():
103 | raise OAuthError(form.errors)
104 | return form.cleaned_data.get('grant')
105 |
106 | def get_refresh_token_grant(self, request, data, client):
107 | form = forms.RefreshTokenGrantForm(data, client=client)
108 | if not form.is_valid():
109 | raise OAuthError(form.errors)
110 | return form.cleaned_data.get('refresh_token')
111 |
112 | def get_password_grant(self, request, data, client):
113 | form = forms.PasswordGrantForm(data, client=client)
114 | if not form.is_valid():
115 | raise OAuthError(form.errors)
116 | return form.cleaned_data
117 |
118 | def get_access_token(self, request, user, scope, client):
119 | try:
120 | # Attempt to fetch an existing access token.
121 | at = models.AccessToken.objects.get_scoped_token(user, client, scope)
122 | except models.AccessToken.DoesNotExist:
123 | # None found... make a new one!
124 | at = self.create_access_token(request, user, scope, client)
125 | if client.client_type != constants.PUBLIC:
126 | self.create_refresh_token(request, user, scope, at, client)
127 | return at
128 |
129 | def create_access_token(self, request, user, scope, client):
130 | at = models.AccessToken.objects.create(
131 | user=user,
132 | client=client,
133 | )
134 | for s in scope:
135 | at.scope.add(s)
136 | return at
137 |
138 | def create_refresh_token(self, request, user, scope, access_token, client):
139 | return models.RefreshToken.objects.create(
140 | user=user,
141 | access_token=access_token,
142 | client=client,
143 | )
144 |
145 | def invalidate_grant(self, grant):
146 | if constants.DELETE_EXPIRED:
147 | grant.delete()
148 | else:
149 | grant.expires = now() - timedelta(days=1)
150 | grant.save()
151 |
152 | def invalidate_refresh_token(self, rt):
153 | if constants.DELETE_EXPIRED:
154 | rt.delete()
155 | else:
156 | rt.expired = True
157 | rt.save()
158 |
159 | def invalidate_access_token(self, at):
160 | if constants.DELETE_EXPIRED:
161 | at.delete()
162 | else:
163 | at.expires = now() - timedelta(days=1)
164 | at.save()
165 |
--------------------------------------------------------------------------------
/provider/scope.py:
--------------------------------------------------------------------------------
1 | """
2 | Default scope implementation relying on bit shifting. See
3 | :attr:`provider.constants.SCOPES` for the list of available scopes.
4 |
5 | Scopes can be combined, such as ``"read write"``. Note that a single
6 | ``"write"`` scope is *not* the same as ``"read write"``.
7 |
8 | See :class:`provider.scope.to_int` on how scopes are combined.
9 | """
10 | from functools import reduce
11 |
12 | from .constants import SCOPES
13 |
14 | SCOPE_NAMES = [(name, name) for (value, name) in SCOPES]
15 | SCOPE_NAME_DICT = dict([(name, value) for (value, name) in SCOPES])
16 | SCOPE_VALUE_DICT = dict([(value, name) for (value, name) in SCOPES])
17 |
18 |
19 | def check(wants, has):
20 | """
21 | Check if a desired scope ``wants`` is part of an available scope ``has``.
22 |
23 | Returns ``False`` if not, return ``True`` if yes.
24 |
25 | :example:
26 |
27 | If a list of scopes such as
28 |
29 | ::
30 |
31 | READ = 1 << 1
32 | WRITE = 1 << 2
33 | READ_WRITE = READ | WRITE
34 |
35 | SCOPES = (
36 | (READ, 'read'),
37 | (WRITE, 'write'),
38 | (READ_WRITE, 'read+write'),
39 | )
40 |
41 | is defined, we can check if a given scope is part of another:
42 |
43 | ::
44 |
45 | >>> from provider import scope
46 | >>> scope.check(READ, READ)
47 | True
48 | >>> scope.check(WRITE, READ)
49 | False
50 | >>> scope.check(WRITE, WRITE)
51 | True
52 | >>> scope.check(READ, WRITE)
53 | False
54 | >>> scope.check(READ, READ_WRITE)
55 | True
56 | >>> scope.check(WRITE, READ_WRITE)
57 | True
58 |
59 | """
60 | if wants & has == 0:
61 | return False
62 | if wants & has < wants:
63 | return False
64 | return True
65 |
66 |
67 | def to_names(scope):
68 | """
69 | Returns a list of scope names as defined in
70 | :attr:`provider.constants.SCOPES` for a given scope integer.
71 |
72 | >>> assert ['read', 'write'] == provider.scope.names(provider.constants.READ_WRITE)
73 |
74 | """
75 | return [
76 | name
77 | for (name, value) in SCOPE_NAME_DICT.items()
78 | if check(value, scope)
79 | ]
80 |
81 | # Keep it compatible
82 | names = to_names
83 |
84 |
85 | def to_int(*names, **kwargs):
86 | """
87 | Turns a list of scope names into an integer value.
88 |
89 | ::
90 |
91 | >>> scope.to_int('read')
92 | 2
93 | >>> scope.to_int('write')
94 | 6
95 | >>> scope.to_int('read', 'write')
96 | 6
97 | >>> scope.to_int('invalid')
98 | 0
99 | >>> scope.to_int('invalid', default = 1)
100 | 1
101 |
102 | """
103 |
104 | return reduce(lambda prev, next: (prev | SCOPE_NAME_DICT.get(next, 0)),
105 | names, kwargs.pop('default', 0))
106 |
--------------------------------------------------------------------------------
/provider/sphinx.py:
--------------------------------------------------------------------------------
1 | """
2 | Custom Sphinx documentation module to link to parts of the OAuth2 draft.
3 | """
4 | from docutils import nodes, utils
5 |
6 | base_url = "http://tools.ietf.org/html/rfc6749"
7 |
8 | def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]):
9 | """Link to the OAuth2 draft.
10 |
11 | Returns 2 part tuple containing list of nodes to insert into the
12 | document and a list of system messages. Both are allowed to be
13 | empty.
14 |
15 | :param name: The role name used in the document.
16 | :param rawtext: The entire markup snippet, with role.
17 | :param text: The text marked with the role.
18 | :param lineno: The line number where rawtext appears in the input.
19 | :param inliner: The inliner instance that called us.
20 | :param options: Directive options for customization.
21 | :param content: The directive content for customization.
22 | """
23 |
24 | node = nodes.reference(rawtext, "Section " + text, refuri="%s#section-%s" % (base_url, text))
25 |
26 | return [node], []
27 |
28 | def setup(app):
29 | """
30 | Install the plugin.
31 |
32 | :param app: Sphinx application context.
33 | """
34 | app.add_role('rfc', rfclink)
35 | return
36 |
--------------------------------------------------------------------------------
/provider/templates/provider/authorize.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load scope %}
3 | {% block content %}
4 | {% if not error %}
5 | {{ client.name }} would like to access your data with the following permissions:
6 |
7 | {% for permission in oauth_data.scope %}
8 | -
9 | {% if permission.description %}
10 | {{ permission.description }}
11 | {% else %}
12 | {{ permission.name }}
13 | {% endif %}
14 |
15 | {% endfor %}
16 |
17 |
39 | {% else %}
40 | {{ error }}
41 | {{ error_description }}
42 | {% endif %}
43 | {% endblock %}
44 |
--------------------------------------------------------------------------------
/provider/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/templatetags/__init__.py
--------------------------------------------------------------------------------
/provider/templatetags/scope.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from .. import scope
3 |
4 | register = template.Library()
5 |
6 |
7 | @register.filter
8 | def scopes(scope_int):
9 | """
10 | Wrapper around :attr:`provider.scope.names` to turn an int into a list
11 | of scope names in templates.
12 | """
13 | return scope.to_names(scope_int)
14 |
--------------------------------------------------------------------------------
/provider/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/tests/__init__.py
--------------------------------------------------------------------------------
/provider/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Test cases for functionality provided by the provider.utils module
3 | """
4 |
5 | from django.test import TestCase
6 |
7 |
8 | class UtilsTestCase(TestCase):
9 | pass
10 |
--------------------------------------------------------------------------------
/provider/urls.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/urls.py
--------------------------------------------------------------------------------
/provider/utils.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import shortuuid
3 | from django.conf import settings
4 | from provider.constants import EXPIRE_DELTA, EXPIRE_DELTA_PUBLIC, EXPIRE_CODE_DELTA
5 |
6 | from django.utils import timezone
7 |
8 |
9 | def now():
10 | return timezone.now()
11 |
12 |
13 | def short_token():
14 | """
15 | Generate a hash that can be used as an application identifier
16 | """
17 | hash = hashlib.sha1(shortuuid.uuid().encode('utf8'))
18 | hash.update(settings.SECRET_KEY.encode('utf8'))
19 | return hash.hexdigest()[::2]
20 |
21 |
22 | def long_token():
23 | """
24 | Generate a hash that can be used as an application secret
25 | """
26 | hash = hashlib.sha1(shortuuid.uuid().encode('utf8'))
27 | hash.update(settings.SECRET_KEY.encode('utf8'))
28 | return hash.hexdigest()
29 |
30 |
31 | def get_token_expiry(public=True):
32 | """
33 | Return a datetime object indicating when an access token should expire.
34 | Can be customized by setting :attr:`settings.OAUTH_EXPIRE_DELTA` to a
35 | :attr:`datetime.timedelta` object.
36 | """
37 | if public:
38 | return now() + EXPIRE_DELTA_PUBLIC
39 | else:
40 | return now() + EXPIRE_DELTA
41 |
42 |
43 | def get_code_expiry():
44 | """
45 | Return a datetime object indicating when an authorization code should
46 | expire.
47 | Can be customized by setting :attr:`settings.OAUTH_EXPIRE_CODE_DELTA` to a
48 | :attr:`datetime.timedelta` object.
49 | """
50 | return now() + EXPIRE_CODE_DELTA
51 |
--------------------------------------------------------------------------------
/provider/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | import json
4 |
5 | from six.moves.urllib_parse import urlparse, ParseResult
6 |
7 | from django.http import HttpResponse
8 | from django.http import HttpResponseRedirect, QueryDict
9 | from django.utils.translation import ugettext as _
10 | from django.views.generic.base import TemplateView, View
11 | from django.core.exceptions import ObjectDoesNotExist
12 | from provider.oauth2.models import Client, Scope
13 | from provider import constants
14 |
15 |
16 | class OAuthError(Exception):
17 | """
18 | Exception to throw inside any views defined in :attr:`provider.views`.
19 |
20 | Any :attr:`OAuthError` thrown will be signalled to the API consumer.
21 |
22 | :attr:`OAuthError` expects a dictionary as its first argument outlining the
23 | type of error that occured.
24 |
25 | :example:
26 |
27 | ::
28 |
29 | raise OAuthError({'error': 'invalid_request'})
30 |
31 | The different types of errors are outlined in :rfc:`4.2.2.1` and
32 | :rfc:`5.2`.
33 |
34 | """
35 |
36 |
37 | class AuthUtilMixin(object):
38 | """
39 | Mixin providing common methods required in the OAuth view defined in
40 | :attr:`provider.views`.
41 | """
42 | def get_data(self, request, key='params'):
43 | """
44 | Return stored data from the session store.
45 |
46 | :param key: `str` The key under which the data was stored.
47 | """
48 | return request.session.get('%s:%s' % (constants.SESSION_KEY, key))
49 |
50 | def cache_data(self, request, data, key='params'):
51 | """
52 | Cache data in the session store.
53 |
54 | :param request: :attr:`django.http.HttpRequest`
55 | :param data: Arbitrary data to store.
56 | :param key: `str` The key under which to store the data.
57 | """
58 | request.session['%s:%s' % (constants.SESSION_KEY, key)] = data
59 |
60 | def clear_data(self, request):
61 | """
62 | Clear all OAuth related data from the session store.
63 | """
64 | for key in list(request.session.keys()):
65 | if key.startswith(constants.SESSION_KEY):
66 | del request.session[key]
67 |
68 | def authenticate(self, request):
69 | """
70 | Authenticate a client against all the backends configured in
71 | :attr:`authentication`.
72 | """
73 | for backend in self.authentication:
74 | client = backend().authenticate(request)
75 | if client is not None:
76 | return client
77 | return None
78 |
79 |
80 | class CaptureViewBase(AuthUtilMixin, TemplateView):
81 | """
82 | As stated in section :rfc:`3.1.2.5` this view captures all the request
83 | parameters and redirects to another URL to avoid any leakage of request
84 | parameters to potentially harmful JavaScripts.
85 |
86 | This application assumes that whatever web-server is used as front-end will
87 | handle SSL transport.
88 |
89 | If you want strict enforcement of secure communication at application
90 | level, set :attr:`settings.OAUTH_ENFORCE_SECURE` to ``True``.
91 |
92 | The actual implementation is required to override :meth:`get_redirect_url`.
93 | """
94 | template_name = 'provider/authorize.html'
95 |
96 | def get_redirect_url(self, request):
97 | """
98 | Return a redirect to a URL where the resource owner (see :rfc:`1`)
99 | authorizes the client (also :rfc:`1`).
100 |
101 | :return: :class:`django.http.HttpResponseRedirect`
102 |
103 | """
104 | raise NotImplementedError
105 |
106 | def validate_scopes(self, scope_list):
107 | raise NotImplementedError
108 |
109 | def handle(self, request, data):
110 | self.cache_data(request, data)
111 |
112 | if constants.ENFORCE_SECURE and not request.is_secure():
113 | return self.render_to_response({'error': 'access_denied',
114 | 'error_description': _("A secure connection is required."),
115 | 'next': None},
116 | status=400)
117 |
118 | scope_list = [s for s in
119 | data.get('scope', '').split(' ') if s != '']
120 | if self.validate_scopes(scope_list):
121 | return HttpResponseRedirect(self.get_redirect_url(request))
122 | else:
123 | return HttpResponse("Invalid scope.", status=400)
124 |
125 | def get(self, request):
126 | return self.handle(request, request.GET)
127 |
128 | def post(self, request):
129 | return self.handle(request, request.POST)
130 |
131 |
132 | class AuthorizeViewBase(AuthUtilMixin, TemplateView):
133 | """
134 | View to handle the client authorization as outlined in :rfc:`4`.
135 | Implementation must override a set of methods:
136 |
137 | * :attr:`get_redirect_url`
138 | * :attr:`get_request_form`
139 | * :attr:`get_authorization_form`
140 | * :attr:`get_client`
141 | * :attr:`save_authorization`
142 |
143 | :attr:`Authorize` renders the ``provider/authorize.html`` template to
144 | display the authorization form.
145 |
146 | On successful authorization, it redirects the user back to the defined
147 | client callback as defined in :rfc:`4.1.2`.
148 |
149 | On authorization fail :attr:`Authorize` displays an error message to the
150 | user with a modified redirect URL to the callback including the error
151 | and possibly description of the error as defined in :rfc:`4.1.2.1`.
152 | """
153 | template_name = 'provider/authorize.html'
154 |
155 | def get_redirect_url(self, request):
156 | """
157 | :return: ``str`` - The client URL to display in the template after
158 | authorization succeeded or failed.
159 | """
160 | raise NotImplementedError
161 |
162 | def get_request_form(self, client, data):
163 | """
164 | Return a form that is capable of validating the request data captured
165 | by the :class:`Capture` view.
166 | The form must accept a keyword argument ``client``.
167 | """
168 | raise NotImplementedError
169 |
170 | def get_authorization_form(self, request, client, data, client_data):
171 | """
172 | Return a form that is capable of authorizing the client to the resource
173 | owner.
174 |
175 | :return: :attr:`django.forms.Form`
176 | """
177 | raise NotImplementedError
178 |
179 | def get_client(self, client_id):
180 | """
181 | Return a client object from a given client identifier. Return ``None``
182 | if no client is found. An error will be displayed to the resource owner
183 | and presented to the client upon the final redirect.
184 | """
185 | raise NotImplementedError
186 |
187 | def save_authorization(self, request, client, form, client_data):
188 | """
189 | Save the authorization that the user granted to the client, involving
190 | the creation of a time limited authorization code as outlined in
191 | :rfc:`4.1.2`.
192 |
193 | Should return ``None`` in case authorization is not granted.
194 | Should return a string representing the authorization code grant.
195 |
196 | :return: ``None``, ``str``
197 | """
198 | raise NotImplementedError
199 |
200 | def has_authorization(self, request, client, scope_list):
201 | """
202 | Check to see if there is a previous authorization request with the
203 | requested scope permissions.
204 |
205 | :param request:
206 | :param client:
207 | :param scope_list:
208 | :return: ``False``, ``AuthorizedClient``
209 | """
210 | return False
211 |
212 | def _validate_client(self, request, data):
213 | """
214 | :return: ``tuple`` - ``(client or False, data or error)``
215 | """
216 | client = self.get_client(data.get('client_id'))
217 |
218 | if client is None:
219 | raise OAuthError({
220 | 'error': 'unauthorized_client',
221 | 'error_description': _("An unauthorized client tried to access"
222 | " your resources.")
223 | })
224 |
225 | form = self.get_request_form(client, data)
226 |
227 | if not form.is_valid():
228 | raise OAuthError(form.errors)
229 |
230 | return client, form.cleaned_data
231 |
232 | def error_response(self, request, error, **kwargs):
233 | """
234 | Return an error to be displayed to the resource owner if anything goes
235 | awry. Errors can include invalid clients, authorization denials and
236 | other edge cases such as a wrong ``redirect_uri`` in the authorization
237 | request.
238 |
239 | :param request: :attr:`django.http.HttpRequest`
240 | :param error: ``dict``
241 | The different types of errors are outlined in :rfc:`4.2.2.1`
242 | """
243 | ctx = {}
244 | ctx.update(error)
245 |
246 | # If we got a malicious redirect_uri or client_id, remove all the
247 | # cached data and tell the resource owner. We will *not* redirect back
248 | # to the URL.
249 |
250 | if error.get('error') in ['redirect_uri', 'unauthorized_client']:
251 | ctx.update(next='/')
252 | return self.render_to_response(ctx, **kwargs)
253 |
254 | ctx.update(next=self.get_redirect_url(request))
255 |
256 | return self.render_to_response(ctx, **kwargs)
257 |
258 | def handle(self, request, post_data=None):
259 | data = self.get_data(request)
260 |
261 | if data is None:
262 | return self.error_response(request, {
263 | 'error': 'expired_authorization',
264 | 'error_description': _('Authorization session has expired.')})
265 |
266 | try:
267 | client, data = self._validate_client(request, data)
268 | except OAuthError as e:
269 | return self.error_response(request, e.args[0], status=400)
270 |
271 | scope_list = [s.name for s in
272 | data.get('scope', [])]
273 | if self.has_authorization(request, client, scope_list):
274 | post_data = {
275 | 'scope': scope_list,
276 | 'authorize': u'Authorize',
277 | }
278 |
279 | authorization_form = self.get_authorization_form(request, client,
280 | post_data, data)
281 |
282 | if not authorization_form.is_bound or not authorization_form.is_valid():
283 | return self.render_to_response({
284 | 'client': client,
285 | 'form': authorization_form,
286 | 'oauth_data': data,
287 | })
288 |
289 | code = self.save_authorization(request, client,
290 | authorization_form, data)
291 |
292 | # be sure to serialize any objects that aren't natively json
293 | # serializable because these values are stored as session data
294 | data['scope'] = scope_list
295 | self.cache_data(request, data)
296 | self.cache_data(request, code, "code")
297 | self.cache_data(request, client.pk, "client_pk")
298 |
299 | return HttpResponseRedirect(self.get_redirect_url(request))
300 |
301 | def get(self, request):
302 | return self.handle(request, None)
303 |
304 | def post(self, request):
305 | return self.handle(request, request.POST)
306 |
307 |
308 | class RedirectViewBase(AuthUtilMixin, View):
309 | """
310 | Redirect the user back to the client with the right query parameters set.
311 | This can be either parameters indicating success or parameters indicating
312 | an error.
313 | """
314 |
315 | def error_response(self, error, mimetype='application/json', status=400,
316 | **kwargs):
317 | """
318 | Return an error response to the client with default status code of
319 | *400* stating the error as outlined in :rfc:`5.2`.
320 | """
321 | return HttpResponse(json.dumps(error), content_type=mimetype,
322 | status=status, **kwargs)
323 |
324 | def get(self, request):
325 | data = self.get_data(request)
326 | code = self.get_data(request, "code")
327 | error = self.get_data(request, "error")
328 | client_pk = self.get_data(request, "client_pk")
329 |
330 | client = Client.objects.get(pk=client_pk)
331 |
332 | # this is an edge case that is caused by making a request with no data
333 | # it should only happen if this view is called manually, out of the
334 | # normal capture-authorize-redirect flow.
335 | if data is None or client is None:
336 | return self.error_response({
337 | 'error': 'invalid_data',
338 | 'error_description': _('Data has not been captured')})
339 |
340 | redirect_uri = data.get('redirect_uri', None) or client.redirect_uri
341 |
342 | parsed = urlparse(redirect_uri)
343 |
344 | query = QueryDict('', mutable=True)
345 |
346 | if 'state' in data:
347 | query['state'] = data['state']
348 |
349 | if error is not None:
350 | query.update(error)
351 | elif code is None:
352 | query['error'] = 'access_denied'
353 | else:
354 | query['code'] = code
355 |
356 | parsed = parsed[:4] + (query.urlencode(), '')
357 |
358 | redirect_uri = ParseResult(*parsed).geturl()
359 |
360 | self.clear_data(request)
361 |
362 | return HttpResponseRedirect(redirect_uri)
363 |
364 |
365 | class AccessTokenViewBase(AuthUtilMixin, TemplateView):
366 | """
367 | :attr:`AccessToken` handles creation and refreshing of access tokens.
368 |
369 | Implementations must implement a number of methods:
370 |
371 | * :attr:`get_authorization_code_grant`
372 | * :attr:`get_refresh_token_grant`
373 | * :attr:`get_password_grant`
374 | * :attr:`get_access_token`
375 | * :attr:`create_access_token`
376 | * :attr:`create_refresh_token`
377 | * :attr:`invalidate_grant`
378 | * :attr:`invalidate_access_token`
379 | * :attr:`invalidate_refresh_token`
380 |
381 | The default implementation supports the grant types defined in
382 | :attr:`grant_types`.
383 |
384 | According to :rfc:`4.4.2` this endpoint too must support secure
385 | communication. For strict enforcement of secure communication at
386 | application level set :attr:`settings.OAUTH_ENFORCE_SECURE` to ``True``.
387 |
388 | According to :rfc:`3.2` we can only accept POST requests.
389 |
390 | Returns with a status code of *400* in case of errors. *200* in case of
391 | success.
392 | """
393 |
394 | authentication = ()
395 | """
396 | Authentication backends used to authenticate a particular client.
397 | """
398 |
399 | grant_types = ['authorization_code', 'refresh_token', 'password']
400 | """
401 | The default grant types supported by this view.
402 | """
403 |
404 | def get_authorization_code_grant(self, request, data, client):
405 | """
406 | Return the grant associated with this request or an error dict.
407 |
408 | :return: ``tuple`` - ``(True or False, grant or error_dict)``
409 | """
410 | raise NotImplementedError
411 |
412 | def get_refresh_token_grant(self, request, data, client):
413 | """
414 | Return the refresh token associated with this request or an error dict.
415 |
416 | :return: ``tuple`` - ``(True or False, token or error_dict)``
417 | """
418 | raise NotImplementedError
419 |
420 | def get_password_grant(self, request, data, client):
421 | """
422 | Return a user associated with this request or an error dict.
423 |
424 | :return: ``tuple`` - ``(True or False, user or error_dict)``
425 | """
426 | raise NotImplementedError
427 |
428 | def get_access_token(self, request, user, scope, client):
429 | """
430 | Override to handle fetching of an existing access token.
431 |
432 | :return: ``object`` - Access token
433 | """
434 | raise NotImplementedError
435 |
436 | def create_access_token(self, request, user, scope, client):
437 | """
438 | Override to handle access token creation.
439 |
440 | :return: ``object`` - Access token
441 | """
442 | raise NotImplementedError
443 |
444 | def create_refresh_token(self, request, user, scope, access_token, client):
445 | """
446 | Override to handle refresh token creation.
447 |
448 | :return: ``object`` - Refresh token
449 | """
450 | raise NotImplementedError
451 |
452 | def invalidate_grant(self, grant):
453 | """
454 | Override to handle grant invalidation. A grant is invalidated right
455 | after creating an access token from it.
456 |
457 | :return None:
458 | """
459 | raise NotImplementedError
460 |
461 | def invalidate_refresh_token(self, refresh_token):
462 | """
463 | Override to handle refresh token invalidation. When requesting a new
464 | access token from a refresh token, the old one is *always* invalidated.
465 |
466 | :return None:
467 | """
468 | raise NotImplementedError
469 |
470 | def invalidate_access_token(self, access_token):
471 | """
472 | Override to handle access token invalidation. When a new access token
473 | is created from a refresh token, the old one is *always* invalidated.
474 |
475 | :return None:
476 | """
477 | raise NotImplementedError
478 |
479 | def error_response(self, error, mimetype='application/json', status=400,
480 | **kwargs):
481 | """
482 | Return an error response to the client with default status code of
483 | *400* stating the error as outlined in :rfc:`5.2`.
484 | """
485 | return HttpResponse(json.dumps(error), content_type=mimetype,
486 | status=status, **kwargs)
487 |
488 | def access_token_response(self, access_token):
489 | """
490 | Returns a successful response after creating the access token
491 | as defined in :rfc:`5.1`.
492 | """
493 |
494 | response_data = {
495 | 'access_token': access_token.token,
496 | 'token_type': constants.TOKEN_TYPE,
497 | 'expires_in': access_token.get_expire_delta(),
498 | 'scope': access_token.get_scope_string(),
499 | }
500 |
501 | # Not all access_tokens are given a refresh_token
502 | # (for example, public clients doing password auth)
503 | try:
504 | rt = access_token.refresh_token
505 | response_data['refresh_token'] = rt.token
506 | except ObjectDoesNotExist:
507 | pass
508 |
509 | return HttpResponse(
510 | json.dumps(response_data), content_type='application/json'
511 | )
512 |
513 | def authorization_code(self, request, data, client):
514 | """
515 | Handle ``grant_type=authorization_code`` requests as defined in
516 | :rfc:`4.1.3`.
517 | """
518 | grant = self.get_authorization_code_grant(request, request.POST,
519 | client)
520 | at = self.create_access_token(request, grant.user,
521 | list(grant.scope.all()), client)
522 |
523 | suppress_refresh_token = False
524 | if client.client_type == constants.PUBLIC and client.allow_public_token:
525 | if not request.POST.get('client_secret'):
526 | suppress_refresh_token = True
527 |
528 | if not suppress_refresh_token:
529 | rt = self.create_refresh_token(request, grant.user,
530 | list(grant.scope.all()), at, client)
531 |
532 | self.invalidate_grant(grant)
533 |
534 | return self.access_token_response(at)
535 |
536 | def refresh_token(self, request, data, client):
537 | """
538 | Handle ``grant_type=refresh_token`` requests as defined in :rfc:`6`.
539 | """
540 | rt = self.get_refresh_token_grant(request, data, client)
541 |
542 | token_scope = list(rt.access_token.scope.all())
543 |
544 | # this must be called first in case we need to purge expired tokens
545 | self.invalidate_refresh_token(rt)
546 | self.invalidate_access_token(rt.access_token)
547 |
548 | at = self.create_access_token(request, rt.user,
549 | token_scope,
550 | client)
551 | rt = self.create_refresh_token(request, at.user,
552 | at.scope.all(), at, client)
553 |
554 | return self.access_token_response(at)
555 |
556 | def password(self, request, data, client):
557 | """
558 | Handle ``grant_type=password`` requests as defined in :rfc:`4.3`.
559 | """
560 |
561 | data = self.get_password_grant(request, data, client)
562 | user = data.get('user')
563 | scope = data.get('scope')
564 |
565 | at = self.create_access_token(request, user, scope, client)
566 | # Public clients don't get refresh tokens
567 | if client.client_type != constants.PUBLIC:
568 | rt = self.create_refresh_token(request, user, scope, at, client)
569 |
570 | return self.access_token_response(at)
571 |
572 | def get_handler(self, grant_type):
573 | """
574 | Return a function or method that is capable handling the ``grant_type``
575 | requested by the client or return ``None`` to indicate that this type
576 | of grant type is not supported, resulting in an error response.
577 | """
578 | if grant_type == 'authorization_code':
579 | return self.authorization_code
580 | elif grant_type == 'refresh_token':
581 | return self.refresh_token
582 | elif grant_type == 'password':
583 | return self.password
584 | return None
585 |
586 | def get(self, request):
587 | """
588 | As per :rfc:`3.2` the token endpoint *only* supports POST requests.
589 | Returns an error response.
590 | """
591 | return self.error_response({
592 | 'error': 'invalid_request',
593 | 'error_description': _("Only POST requests allowed.")})
594 |
595 | def post(self, request):
596 | """
597 | As per :rfc:`3.2` the token endpoint *only* supports POST requests.
598 | """
599 | if constants.ENFORCE_SECURE and not request.is_secure():
600 | return self.error_response({
601 | 'error': 'invalid_request',
602 | 'error_description': _("A secure connection is required.")})
603 |
604 | if not 'grant_type' in request.POST:
605 | return self.error_response({
606 | 'error': 'invalid_request',
607 | 'error_description': _("No 'grant_type' included in the "
608 | "request.")})
609 |
610 | grant_type = request.POST['grant_type']
611 |
612 | if grant_type not in self.grant_types:
613 | return self.error_response({'error': 'unsupported_grant_type'})
614 |
615 | client = self.authenticate(request)
616 |
617 | if client is None:
618 | return self.error_response({'error': 'invalid_client'})
619 |
620 | handler = self.get_handler(grant_type)
621 |
622 | try:
623 | return handler(request, request.POST, client)
624 | except OAuthError as e:
625 | return self.error_response(e.args[0])
626 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django==3.2
2 | shortuuid==1.0.11
3 | six>=0.16.0
4 | sqlparse>=0.4.3
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from setuptools import setup, find_packages
4 | import provider
5 |
6 | setup(
7 | name='django-oauth2',
8 | version=provider.__version__,
9 | description='Provide OAuth2 access to your app (fork of django-oauth2-provider)',
10 | long_description=open('README.rst').read(),
11 | author='Shaun Kruger',
12 | author_email='shaun.kruger@gmail.com',
13 | url = 'https://github.com/stormsherpa/django-oauth2-provider',
14 | packages=find_packages(exclude=('tests*',)),
15 | license='The MIT License: http://www.opensource.org/licenses/mit-license.php',
16 | platforms='all',
17 | classifiers=[
18 | 'Environment :: Web Environment',
19 | 'Intended Audience :: Developers',
20 | 'License :: OSI Approved :: MIT License',
21 | 'Operating System :: OS Independent',
22 | 'Programming Language :: Python',
23 | 'Framework :: Django',
24 | ],
25 | install_requires=[
26 | "shortuuid>=1.0.11",
27 | "six>=0.16.0",
28 | "sqlparse>=0.4.3",
29 | ],
30 | include_package_data=True,
31 | zip_safe=False,
32 | )
33 |
--------------------------------------------------------------------------------
/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | app_names=( provider provider.oauth2 )
4 |
5 | python manage.py test ${app_names[@]} --traceback
6 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/tests/__init__.py
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | # Django settings for example project.
2 | import os
3 | from django import VERSION as DJANGO_VERSION
4 |
5 | DEBUG = True
6 |
7 | ADMINS = (
8 | ('Tester', 'test@example.com'),
9 | )
10 |
11 | MANAGERS = ADMINS
12 |
13 | DATABASES = {
14 | 'default': {
15 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
16 | 'NAME': '%s/db.sqlite' % os.path.dirname(__file__), # Or path to database file if using sqlite3.
17 | 'USER': '', # Not used with sqlite3.
18 | 'PASSWORD': '', # Not used with sqlite3.
19 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
20 | 'PORT': '', # Set to empty string for default. Not used with sqlite3.
21 | }
22 | }
23 |
24 |
25 | SITE_ID = 1
26 |
27 | # Absolute filesystem path to the directory that will hold user-uploaded files.
28 | # Example: "/home/media/media.lawrence.com/media/"
29 | MEDIA_ROOT = ''
30 |
31 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
32 | # trailing slash.
33 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
34 | MEDIA_URL = ''
35 |
36 | # Absolute path to the directory static files should be collected to.
37 | # Don't put anything in this directory yourself; store your static files
38 | # in apps' "static/" subdirectories and in STATICFILES_DIRS.
39 | # Example: "/home/media/media.lawrence.com/static/"
40 | STATIC_ROOT = ''
41 |
42 | # URL prefix for static files.
43 | # Example: "http://media.lawrence.com/static/"
44 | STATIC_URL = '/static/'
45 |
46 | # Make this unique, and don't share it with anybody.
47 | SECRET_KEY = 'secret'
48 |
49 | ROOT_URLCONF = 'tests.urls'
50 |
51 | INSTALLED_APPS = (
52 | 'django.contrib.auth',
53 | 'django.contrib.contenttypes',
54 | 'django.contrib.sessions',
55 | 'django.contrib.sites',
56 | 'django.contrib.messages',
57 | 'django.contrib.staticfiles',
58 | 'django.contrib.admin',
59 | 'provider',
60 | 'provider.oauth2',
61 | )
62 |
63 | TEMPLATES = [
64 | {
65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
66 | 'DIRS': [os.path.join(os.path.dirname(__file__), 'templates')],
67 | 'APP_DIRS': True,
68 | 'OPTIONS': {
69 | 'context_processors': [
70 | # 'django.template.context_processors.debug',
71 | # 'django.template.context_processors.request',
72 | 'django.contrib.auth.context_processors.auth',
73 | 'django.contrib.messages.context_processors.messages',
74 | ],
75 | },
76 | },
77 | ]
78 |
79 | MIDDLEWARE = (
80 | 'django.contrib.sessions.middleware.SessionMiddleware',
81 | 'django.middleware.common.CommonMiddleware',
82 | 'django.middleware.csrf.CsrfViewMiddleware',
83 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
84 | 'provider.oauth2.middleware.Oauth2UserMiddleware',
85 | 'django.contrib.messages.middleware.MessageMiddleware',
86 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
87 | )
88 |
89 | AUTHENTICATION_BACKENDS = [
90 | 'django.contrib.auth.backends.RemoteUserBackend',
91 | 'django.contrib.auth.backends.ModelBackend',
92 | ]
93 |
94 | PASSWORD_HASHERS = [
95 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
96 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
97 | 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
98 | 'django.contrib.auth.hashers.SHA1PasswordHasher', # Used by unit tests
99 | ]
100 |
101 | USE_TZ = True
102 |
103 | # Use DiscoverRunner on Django 1.7 and above
104 | if DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 7:
105 | TEST_RUNNER = 'django.test.runner.DiscoverRunner'
106 |
107 |
--------------------------------------------------------------------------------
/tests/templates/base.html:
--------------------------------------------------------------------------------
1 | {% block content %}
2 | {% endblock %}
3 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url, include
2 | from django.contrib import admin
3 |
4 | admin.autodiscover()
5 |
6 | urlpatterns = [
7 | url(r'^admin/', admin.site.urls),
8 | url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')),
9 | url(r'^tests/', include('provider.oauth2.tests.urls', namespace='tests')),
10 | ]
11 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | toxworkdir={env:TOX_WORK_DIR:.tox}
3 | downloadcache = {toxworkdir}/cache/
4 | envlist = py{3.8,3.9,3.10}-django{3.0,3.1,3.2,4.0,4.1}
5 |
6 | [testenv]
7 | setenv =
8 | PYTHONPATH = {toxinidir}
9 | commands =
10 | {toxinidir}/test.sh
11 | deps =
12 |
13 | [travis]
14 | python =
15 | 3.8: py3.8-django{3.0,3.1,3.2,4.0,4.1}
16 |
17 |
18 | [testenv:py3.8-django3.0]
19 | basepython = python3.8
20 | deps = Django>=3.0,<3.1
21 | {[testenv]deps}
22 |
23 | [testenv:py3.8-django3.1]
24 | basepython = python3.8
25 | deps = Django>=3.1,<3.2
26 | {[testenv]deps}
27 |
28 | [testenv:py3.8-django3.2]
29 | basepython = python3.8
30 | deps = Django>=3.2,<4.0
31 | {[testenv]deps}
32 |
33 | [testenv:py3.8-django4.0]
34 | basepython = python3.8
35 | deps = Django>=4.0,<4.1
36 | {[testenv]deps}
37 |
38 | [testenv:py3.8-django4.1]
39 | basepython = python3.8
40 | deps = Django>=4.1,<4.2
41 | {[testenv]deps}
42 |
--------------------------------------------------------------------------------