From 24ee09630981a53d448264e281a989e6fcd3a1fb Mon Sep 17 00:00:00 2001 From: Dan Berglund Date: Mon, 28 Sep 2015 11:47:31 +0200 Subject: [PATCH 1/6] Approach for adding a CORS-middleware --- oauth2_provider/middleware.py | 46 ++++++++++ oauth2_provider/models.py | 19 +++++ oauth2_provider/tests/test_cors_middleware.py | 85 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 oauth2_provider/tests/test_cors_middleware.py diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 33eab12d5..4d9cf160c 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,6 +1,9 @@ +from django import http from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers +from .models import Application + class OAuth2TokenMiddleware(object): """ @@ -32,3 +35,46 @@ def process_request(self, request): def process_response(self, request, response): patch_vary_headers(response, ('Authorization',)) return response + +HEADERS = ('x-requested-with', 'content-type', 'accept', 'origin', + 'authorization', 'x-csrftoken') +METHODS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS') + + +class CorsMiddleware(object): + def process_request(self, request): + '''If this is a preflight-request, we must always return 200''' + if (request.method == 'OPTIONS' and + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' in request.META): + return http.HttpResponse() + return None + + def process_response(self, request, response): + '''Add cors-headers to request if they can be derived correctly''' + try: + cors_allow_origin = _get_cors_allow_origin_header(request) + except Application.NoSuitableOriginFoundError: + pass + else: + response['Access-Control-Allow-Origin'] = cors_allow_origin + response['Access-Control-Allow-Credentials'] = 'true' + if request.method == 'OPTIONS': + response['Access-Control-Allow-Headers'] = ', '.join(HEADERS) + response['Access-Control-Allow-Methods'] = ', '.join(METHODS) + return response + + +def _get_cors_allow_origin_header(request): + '''Fetch the oauth-application that is responsible for making the + request and return a sutible cors-header, or None + ''' + origin = request.META.get('HTTP_ORIGIN') + if origin: + try: + app = Application.objects.filter(redirect_uris__contains=origin)[0] + except IndexError: + # No application for this origin found + pass + else: + return app.get_cors_header(origin) + raise Application.NoSuitableOriginFoundError diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index f87395002..c07fb25ba 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -120,12 +120,31 @@ def clean(self): error = _('Redirect_uris could not be empty with {0} grant_type') raise ValidationError(error.format(self.authorization_grant_type)) + def get_cors_header(self, origin): + '''Return a proper cors-header for this origin, in the context of this + application. + + :param origin: Origin-url from HTTP-request. + :raises: Application.NoSuitableOriginFoundError + ''' + parsed_origin = urlparse(origin) + for allowed_uri in self.redirect_uris.split(): + parsed_allowed_uri = urlparse(allowed_uri) + if (parsed_allowed_uri.scheme == parsed_origin.scheme and + parsed_allowed_uri.netloc == parsed_origin.netloc and + parsed_allowed_uri.port == parsed_origin.port): + return origin + raise Application.NoSuitableOriginFoundError + def get_absolute_url(self): return reverse('oauth2_provider:detail', args=[str(self.id)]) def __str__(self): return self.name or self.client_id + class NoSuitableOriginFoundError(Exception): + pass + class Application(AbstractApplication): class Meta(AbstractApplication.Meta): diff --git a/oauth2_provider/tests/test_cors_middleware.py b/oauth2_provider/tests/test_cors_middleware.py new file mode 100644 index 000000000..f7d03e3da --- /dev/null +++ b/oauth2_provider/tests/test_cors_middleware.py @@ -0,0 +1,85 @@ +from datetime import timedelta + +from django.test import TestCase, Client, override_settings +from django.utils import timezone +from django.conf.urls import patterns, url +from django.http import HttpResponse +from django.views.generic import View + +from ..models import AccessToken, get_application_model +from django.contrib.auth import get_user_model + + +Application = get_application_model() +UserModel = get_user_model() + + +class MockView(View): + def post(self, request): + return HttpResponse() + +urlpatterns = patterns( + '', + url(r'^cors-test/$', MockView.as_view()), +) + + +@override_settings( + ROOT_URLCONF='oauth2_provider.tests.test_cors_middleware', + AUTHENTICATION_BACKENDS=('oauth2_provider.backends.OAuth2Backend',), + MIDDLEWARE_CLASSES=( + 'oauth2_provider.middleware.OAuth2TokenMiddleware', + 'oauth2_provider.middleware.CorsMiddleware', + )) +class TestCORSMiddleware(TestCase): + def setUp(self): + self.user = UserModel.objects.create_user('test_user', 'test@user.com') + self.application = Application.objects.create( + name='Test Application', + redirect_uris='https://foo.bar', + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + self.access_token = AccessToken.objects.create( + user=self.user, + scope='read write', + expires=timezone.now() + timedelta(seconds=300), + token='secret-access-token-key', + application=self.application + ) + + auth_header = "Bearer {0}".format(self.access_token.token) + self.client = Client(HTTP_AUTHORIZATION=auth_header) + + def test_cors_successful(self): + '''Ensure that we get cors-headers according to our oauth-app''' + resp = self.client.post('/cors-test/', HTTP_ORIGIN='https://foo.bar') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Access-Control-Allow-Origin'], 'https://foo.bar') + self.assertEqual(resp['Access-Control-Allow-Credentials'], 'true') + + def test_cors_no_auth(self): + '''Ensure that CORS-headers are sent non-authenticated requests''' + client = Client() + resp = client.post('/cors-test/', HTTP_ORIGIN='https://foo.bar') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Access-Control-Allow-Origin'], 'https://foo.bar') + self.assertEqual(resp['Access-Control-Allow-Credentials'], 'true') + + def test_cors_wrong_origin(self): + '''Ensure that CORS-headers aren't sent to requests from wrong origin''' + resp = self.client.post('/cors-test/', HTTP_ORIGIN='https://bar.foo') + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.has_header('Access-Control-Allow-Origin')) + + def test_cors_200_preflight(self): + '''Ensure that preflight always get 200 responses''' + resp = self.client.options('/cors-test/', + HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET', + HTTP_ORIGIN='https://foo.bar') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp['Access-Control-Allow-Origin'], 'https://foo.bar') + self.assertTrue(resp.has_header('Access-Control-Allow-Headers')) + self.assertTrue(resp.has_header('Access-Control-Allow-Methods')) From cafd4712df034ea9b32828149fbe14bfc27f3b28 Mon Sep 17 00:00:00 2001 From: Dan Berglund Date: Tue, 22 Mar 2016 14:16:12 +0100 Subject: [PATCH 2/6] Update tutorial to include cors-middleware --- docs/tutorial/tutorial_01.rst | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index fdb1c3edc..3eaf4cc99 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -8,17 +8,16 @@ You want to make your own :term:`Authorization Server` to issue access tokens to Start Your App -------------- During this tutorial you will make an XHR POST from a Heroku deployed app to your localhost instance. -Since the domain that will originate the request (the app on Heroku) is different than the destination domain (your local instance), -you will need to install the `django-cors-headers `_ app. +Since the domain that will originate the request (the app on Heroku) is different than the destination domain (your local instance), you will need to use the cors-middleware that we're providing. These "cross-domain" requests are by default forbidden by web browsers unless you use `CORS `_. -Create a virtualenv and install `django-oauth-toolkit` and `django-cors-headers`: +Create a virtualenv and install `django-oauth-toolkit`: :: - pip install django-oauth-toolkit django-cors-headers + pip install django-oauth-toolkit -Start a Django project, add `oauth2_provider` and `corsheaders` to the installed apps, and enable admin: +Start a Django project, add `oauth2_provider` to the installed apps, and enable admin: .. code-block:: python @@ -26,7 +25,6 @@ Start a Django project, add `oauth2_provider` and `corsheaders` to the installed 'django.contrib.admin', # ... 'oauth2_provider', - 'corsheaders', } Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace you prefer. For example: @@ -46,17 +44,11 @@ Include the CORS middleware in your `settings.py`: MIDDLEWARE_CLASSES = ( # ... - 'corsheaders.middleware.CorsMiddleware', + 'oauth2_provider.middleware.CorsMiddleware', # ... ) -Allow CORS requests from all domains (just for the scope of this tutorial): - -.. code-block:: python - - CORS_ORIGIN_ALLOW_ALL = True - -.. _loginTemplate: +This will allow CORS requests from the redirect uris of your applications. Include the required hidden input in your login template, `registration/login.html`. The ``{{ next }}`` template context variable will be populated with the correct From 4bac4f93c8c66eafebd05666c24e8b0f8039e408 Mon Sep 17 00:00:00 2001 From: Rebecca Claire Murphy Date: Wed, 4 May 2022 16:58:35 +0000 Subject: [PATCH 3/6] Update CORS middleware and tests --- oauth2_provider/middleware.py | 17 ++++++++++------- oauth2_provider/models.py | 23 +++++++++++++---------- tests/mig_settings.py | 1 + tests/settings.py | 1 + tests/test_cors_middleware.py | 15 --------------- tests/urls.py | 3 +++ tests/views.py | 7 +++++++ 7 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 tests/views.py diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index ddca71842..e52341cbd 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -2,7 +2,7 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers -from .models import Application +from .models import AbstractApplication, Application class OAuth2TokenMiddleware: @@ -45,18 +45,21 @@ def __call__(self, request): METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") -class CorsMiddleware(object): - def process_request(self, request): +class CorsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): """If this is a preflight-request, we must always return 200""" if request.method == "OPTIONS" and "HTTP_ACCESS_CONTROL_REQUEST_METHOD" in request.META: - return http.HttpResponse() - return None + response = http.HttpResponse() + else: + response = self.get_response(request) - def process_response(self, request, response): """Add cors-headers to request if they can be derived correctly""" try: cors_allow_origin = _get_cors_allow_origin_header(request) - except Application.NoSuitableOriginFoundError: + except AbstractApplication.NoSuitableOriginFoundError: pass else: response["Access-Control-Allow-Origin"] = cors_allow_origin diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 3308243a8..23144aae7 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -193,6 +193,16 @@ def clean(self): ): raise ValidationError(_("You cannot use HS256 with public grants or clients")) + def get_absolute_url(self): + return reverse("oauth2_provider:detail", args=[str(self.id)]) + + def get_allowed_schemes(self): + """ + Returns the list of redirect schemes allowed by the Application. + By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`. + """ + return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES + def get_cors_header(self, origin): """Return a proper cors-header for this origin, in the context of this application. @@ -211,16 +221,6 @@ def get_cors_header(self, origin): return origin raise Application.NoSuitableOriginFoundError - def get_absolute_url(self): - return reverse("oauth2_provider:detail", args=[str(self.id)]) - - def get_allowed_schemes(self): - """ - Returns the list of redirect schemes allowed by the Application. - By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`. - """ - return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES - def allows_grant_type(self, *grant_types): return self.authorization_grant_type in grant_types @@ -242,6 +242,9 @@ def jwk_key(self): return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret)) raise ImproperlyConfigured("This application does not support signed tokens") + class NoSuitableOriginFoundError(Exception): + pass + class ApplicationManager(models.Manager): def get_by_natural_key(self, client_id): diff --git a/tests/mig_settings.py b/tests/mig_settings.py index 8f77d1190..2039bf6cd 100644 --- a/tests/mig_settings.py +++ b/tests/mig_settings.py @@ -42,6 +42,7 @@ ] MIDDLEWARE = [ + "oauth2_provider.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", diff --git a/tests/settings.py b/tests/settings.py index 27dcfe9a3..5e8254c6b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -63,6 +63,7 @@ ] MIDDLEWARE = ( + "oauth2_provider.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", diff --git a/tests/test_cors_middleware.py b/tests/test_cors_middleware.py index 1f1d48d1b..f03d1e3e8 100644 --- a/tests/test_cors_middleware.py +++ b/tests/test_cors_middleware.py @@ -1,11 +1,8 @@ from datetime import timedelta -from django.conf.urls import patterns, url from django.contrib.auth import get_user_model -from django.http import HttpResponse from django.test import Client, TestCase, override_settings from django.utils import timezone -from django.views.generic import View from oauth2_provider.models import AccessToken, get_application_model @@ -14,19 +11,7 @@ UserModel = get_user_model() -class MockView(View): - def post(self, request): - return HttpResponse() - - -urlpatterns = patterns( - "", - url(r"^cors-test/$", MockView.as_view()), -) - - @override_settings( - ROOT_URLCONF="oauth2_provider.tests.test_cors_middleware", AUTHENTICATION_BACKENDS=("oauth2_provider.backends.OAuth2Backend",), MIDDLEWARE_CLASSES=( "oauth2_provider.middleware.OAuth2TokenMiddleware", diff --git a/tests/urls.py b/tests/urls.py index 0661a9336..bd09c815b 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,6 +1,8 @@ from django.contrib import admin from django.urls import include, path +from .views import MockView + admin.autodiscover() @@ -8,4 +10,5 @@ urlpatterns = [ path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("admin/", admin.site.urls), + path("cors-test/", MockView.as_view()), ] diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 000000000..f2f062a36 --- /dev/null +++ b/tests/views.py @@ -0,0 +1,7 @@ +from django.http import HttpResponse +from django.views.generic import View + + +class MockView(View): + def post(self, request): + return HttpResponse() From f51a741aca097cfdc010885160566d7861fa00d3 Mon Sep 17 00:00:00 2001 From: Rebecca Claire Murphy Date: Wed, 4 May 2022 17:39:36 +0000 Subject: [PATCH 4/6] Optimize CorsMiddleware Removes an exception used for control flow. --- oauth2_provider/middleware.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index e52341cbd..3f65165cf 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -76,11 +76,7 @@ def _get_cors_allow_origin_header(request): """ origin = request.META.get("HTTP_ORIGIN") if origin: - try: - app = Application.objects.filter(redirect_uris__contains=origin)[0] - except IndexError: - # No application for this origin found - pass - else: + app = Application.objects.filter(redirect_uris__contains=origin).first() + if app is not None: return app.get_cors_header(origin) - raise Application.NoSuitableOriginFoundError + raise AbstractApplication.NoSuitableOriginFoundError() From 12813829c02793152621ff129a6bc81d5662326e Mon Sep 17 00:00:00 2001 From: Rebecca Claire Murphy Date: Wed, 4 May 2022 18:57:20 +0000 Subject: [PATCH 5/6] Remove errant merge artifact --- oauth2_provider/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 23144aae7..cef815710 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -250,9 +250,6 @@ class ApplicationManager(models.Manager): def get_by_natural_key(self, client_id): return self.get(client_id=client_id) - class NoSuitableOriginFoundError(Exception): - pass - class Application(AbstractApplication): objects = ApplicationManager() From 8db6b45a8120f071f2e3ddd6b9c11b565b2afbf9 Mon Sep 17 00:00:00 2001 From: Rebecca Claire Murphy Date: Sat, 7 May 2022 16:57:34 +0000 Subject: [PATCH 6/6] Complete PR Checklist --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/AUTHORS b/AUTHORS index a5f652ea0..86c69ec26 100644 --- a/AUTHORS +++ b/AUTHORS @@ -54,6 +54,7 @@ Pavel Tvrdík Patrick Palacin Peter Carnesciali Petr Dlouhý +Rebecca Claire Murphy Rodney Richardson Rustem Saiargaliev Sandro Rodrigues diff --git a/CHANGELOG.md b/CHANGELOG.md index 7819fe616..d39f53f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* #1150 Automatic CORS Headers based on Application redirect_url. + + ## [2.0.0] 2022-04-24 This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: