From 691870c11dc113038f742665a16fef39eecc8d01 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 25 Jan 2022 10:00:18 -0500 Subject: [PATCH 01/20] Hash application client secrets using Django password hashing (#1093) * Add ClientSecretField field to use Django password hashing algorithms (#1020) Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Co-authored-by: Peter Karman Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 12 +++++ docs/settings.rst | 20 ++++---- .../0006_alter_application_client_secret.py | 31 ++++++++++++ oauth2_provider/models.py | 23 ++++++++- oauth2_provider/oauth2_validators.py | 5 +- tests/conftest.py | 6 ++- tests/test_authorization_code.py | 48 ++++++++++--------- tests/test_client_credential.py | 38 +++++++-------- tests/test_commands.py | 3 +- tests/test_hybrid.py | 31 ++++++------ tests/test_introspection_view.py | 13 ++--- tests/test_oauth2_validators.py | 24 +++++----- tests/test_password.py | 9 ++-- tests/test_scopes.py | 15 +++--- tests/test_token_revocation.py | 15 +++--- 16 files changed, 185 insertions(+), 109 deletions(-) create mode 100644 oauth2_provider/migrations/0006_alter_application_client_secret.py diff --git a/AUTHORS b/AUTHORS index a1591b6da..c6e66453d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -64,6 +64,7 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Peter Karman Vinay Karanam Eduardo Oliveira Andrea Greco diff --git a/CHANGELOG.md b/CHANGELOG.md index b66e0822d..da07b6cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [unreleased] + +## [2.0.0] unreleased + +### Changed +* #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) + client_secret values. This is a **breaking change** that will migrate all your existing + cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm + and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the + auto-generated or manually-entered `client_secret` before hitting Save. + + ## [1.7.0] 2022-01-23 ### Added diff --git a/docs/settings.rst b/docs/settings.rst index 01baaaf4b..0ba12df11 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -97,19 +97,19 @@ of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). @@ -121,7 +121,7 @@ Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). @@ -154,7 +154,7 @@ If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are @@ -178,7 +178,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR -~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. @@ -265,7 +265,7 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. OIDC_RSA_PRIVATE_KEYS_INACTIVE -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``[]`` An array of *inactive* RSA private keys. These keys are not used to sign tokens, @@ -276,7 +276,7 @@ This is useful for providing a smooth transition during key rotation. should be retained in this inactive list. OIDC_JWKS_MAX_AGE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``3600`` The max-age value for the Cache-Control header on jwks_uri. @@ -354,9 +354,9 @@ load when clearing large batches of expired tokens. Settings imported from Django project --------------------------- +------------------------------------- USE_TZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~ Used to determine whether or not to make token expire dates timezone aware. diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py new file mode 100644 index 000000000..88e148274 --- /dev/null +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -0,0 +1,31 @@ +from django.db import migrations +from django.contrib.auth.hashers import identify_hasher, make_password +import logging +import oauth2_provider.generators +import oauth2_provider.models + + +def forwards_func(apps, schema_editor): + """ + Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. + """ + Application = apps.get_model('oauth2_provider', 'application') + applications = Application.objects.all() + for application in applications: + application.save(update_fields=['client_secret']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20211222_2352'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), + ), + migrations.RunPython(forwards_func), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 2c9747ce8..1ded7a4e2 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,6 +6,7 @@ from django.apps import apps from django.conf import settings +from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse @@ -24,6 +25,20 @@ logger = logging.getLogger(__name__) +class ClientSecretField(models.CharField): + def pre_save(self, model_instance, add): + secret = getattr(model_instance, self.attname) + try: + hasher = identify_hasher(secret) + logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") + except ValueError: + logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.") + hashed_secret = make_password(secret) + setattr(model_instance, self.attname, hashed_secret) + return hashed_secret + return super().pre_save(model_instance, add) + + class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -90,8 +105,12 @@ class AbstractApplication(models.Model): ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) - client_secret = models.CharField( - max_length=255, blank=True, default=generate_client_secret, db_index=True + client_secret = ClientSecretField( + max_length=255, + blank=True, + default=generate_client_secret, + db_index=True, + help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4d9480be1..00c5e7de0 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -12,6 +12,7 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q @@ -123,7 +124,7 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False else: @@ -148,7 +149,7 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False else: diff --git a/tests/conftest.py b/tests/conftest.py index a3274aa33..520b6cbac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class OAuthSettingsWrapper: """ @@ -101,12 +103,14 @@ def application(): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) @pytest.fixture def hybrid_application(application): application.authorization_grant_type = application.GRANT_OPENID_HYBRID + application.client_secret = CLEARTEXT_SECRET application.save() return application @@ -141,7 +145,7 @@ def oidc_tokens(oauth2_settings, application, test_user, client): "code": code, "redirect_uri": "http://example.org", "client_id": application.client_id, - "client_secret": application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index c9bef0f5c..91fd06bd1 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -34,6 +34,7 @@ URI_OOB = "urn:ietf:wg:oauth:2.0:oob" URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view @@ -60,6 +61,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -677,7 +679,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -699,7 +701,7 @@ def test_refresh(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -744,7 +746,7 @@ def test_refresh_with_grace_period(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -795,7 +797,7 @@ def test_refresh_invalidates_old_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -827,7 +829,7 @@ def test_refresh_no_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -855,7 +857,7 @@ def test_refresh_bad_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -881,7 +883,7 @@ def test_refresh_fail_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -911,7 +913,7 @@ def test_refresh_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -948,7 +950,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -977,7 +979,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -989,7 +991,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1014,7 +1016,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1049,7 +1051,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -1070,7 +1072,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1445,7 +1447,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1480,7 +1482,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1520,7 +1522,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1565,7 +1567,7 @@ def test_oob_as_html(self): "code": authorization_code, "redirect_uri": URI_OOB, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1605,7 +1607,7 @@ def test_oob_as_json(self): "code": authorization_code, "redirect_uri": URI_OOB_AUTO, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1681,7 +1683,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1715,7 +1717,7 @@ def test_id_token(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", } @@ -1761,7 +1763,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1819,7 +1821,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8159d55db..38265c3d9 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,5 @@ import json from unittest.mock import patch -from urllib.parse import quote_plus import pytest from django.contrib.auth import get_user_model @@ -24,6 +23,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -44,6 +45,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -55,35 +57,28 @@ def tearDown(self): class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): """ - Request an access token using Client Credential Flow + Request an access token using Client Credential Flow with hashed secrets """ + self.assertNotEqual(self.application.client_secret, CLEARTEXT_SECRET) + token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") + # secret mismatch should return a 401 + auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -93,7 +88,7 @@ def test_client_credential_does_not_issue_refresh_token(self): def test_client_credential_user_is_none_on_access_token(self): token_request_data = {"grant_type": "client_credentials"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -122,7 +117,7 @@ def test_extended_request(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -174,12 +169,11 @@ def test_client_resource_password_based(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} - auth_headers = get_basic_auth_header( - quote_plus(self.application.client_id), quote_plus(self.application.client_secret) - ) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) diff --git a/tests/test_commands.py b/tests/test_commands.py index ff5deba4e..13b0eeb3d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,7 @@ from io import StringIO from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase @@ -83,7 +84,7 @@ def test_application_created_with_client_secret(self): ) app = Application.objects.get() - self.assertEqual(app.client_secret, "SECRET") + self.assertTrue(check_password("SECRET", app.client_secret)) def test_application_created_with_client_id(self): call_command( diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 4f9753979..3f4048698 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -30,6 +30,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -62,6 +64,7 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) self.application.save() @@ -829,7 +832,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -850,7 +853,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -862,7 +865,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="hy_test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -887,7 +890,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -922,7 +925,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -943,7 +946,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1056,7 +1059,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1091,7 +1094,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1126,7 +1129,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1163,7 +1166,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1200,7 +1203,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1239,7 +1242,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1351,7 +1354,7 @@ def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_app "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) @@ -1422,7 +1425,7 @@ def test_claims_passed_to_code_generation( "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 95374cda5..b19c521d5 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -17,6 +17,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) @@ -35,6 +37,7 @@ def setUp(self): user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) self.resource_server_token = AccessToken.objects.create( @@ -281,7 +284,7 @@ def test_view_post_notexisting_token(self): def test_view_post_valid_client_creds_basic_auth(self): """Test HTTP basic auth working""" - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -301,9 +304,7 @@ def test_view_post_valid_client_creds_basic_auth(self): def test_view_post_invalid_client_creds_basic_auth(self): """Must fail for invalid client credentials""" - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret + "_so_wrong" - ) + auth_headers = get_basic_auth_header(self.application.client_id, f"{CLEARTEXT_SECRET}_so_wrong") response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -316,7 +317,7 @@ def test_view_post_valid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, }, ) self.assertEqual(response.status_code, 200) @@ -340,7 +341,7 @@ def test_view_post_invalid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret + "_so_wrong", + "client_secret": f"{CLEARTEXT_SECRET}_so_wrong", }, ) self.assertEqual(response.status_code, 403) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7997d3bca..fd06a1eda 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -15,6 +15,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets +from .utils import get_basic_auth_header try: @@ -28,6 +29,8 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @contextlib.contextmanager def always_invalid_token(): @@ -51,7 +54,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, @@ -69,7 +72,7 @@ def test_authenticate_request_body(self): self.request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.request)) - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) def test_extract_basic_auth(self): @@ -86,26 +89,22 @@ def test_extract_basic_auth(self): def test_authenticate_basic_auth(self): self.request.encoding = "utf-8" - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_default_encoding(self): self.request.encoding = None - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_id(self): self.request.encoding = "utf-8" - # wrong_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic d3JvbmdfaWQ6Y2xpZW50X3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("wrong_id", CLEARTEXT_SECRET) self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_secret(self): self.request.encoding = "utf-8" - # client_id:wrong_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOndyb25nX3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("client_id", "wrong_secret") self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_b64_auth_string(self): @@ -116,7 +115,6 @@ def test_authenticate_basic_auth_not_b64_auth_string(self): def test_authenticate_basic_auth_invalid_b64_string(self): self.request.encoding = "utf-8" - # client_id:wrong_secret self.request.headers = {"HTTP_AUTHORIZATION": "Basic ZHVtbXk=:ZHVtbXk=\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) @@ -140,7 +138,7 @@ def test_client_authentication_required(self): self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.headers = {} self.request.client_id = "client_id" - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.client_secret = "" self.assertFalse(self.validator.client_authentication_required(self.request)) @@ -327,7 +325,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, diff --git a/tests/test_password.py b/tests/test_password.py index 953b076e2..ab0f49228 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -14,6 +14,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -33,6 +35,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -51,7 +54,7 @@ def test_get_token(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -70,7 +73,7 @@ def test_bad_credentials(self): "username": "test_user", "password": "NOT_MY_PASS", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -83,7 +86,7 @@ def test_password_resource_access_allowed(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index a310e223a..39601ed3b 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -18,6 +18,8 @@ Grant = get_grant_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ScopeResourceView(ScopedProtectedResourceView): @@ -67,6 +69,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -123,7 +126,7 @@ def test_scopes_save_in_access_token(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -159,7 +162,7 @@ def test_scopes_protection_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -201,7 +204,7 @@ def test_scopes_protection_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -243,7 +246,7 @@ def test_multi_scope_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -285,7 +288,7 @@ def test_multi_scope_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -326,7 +329,7 @@ def get_access_token(self, scopes): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 1ed1c9119..b4f5af7dd 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -13,6 +13,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class BaseTest(TestCase): def setUp(self): @@ -26,6 +28,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -46,7 +49,7 @@ def test_revoke_access_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, } url = reverse("oauth2_provider:revoke-token") @@ -93,7 +96,7 @@ def test_revoke_access_token_with_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "access_token", } @@ -115,7 +118,7 @@ def test_revoke_access_token_with_invalid_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "bad_hint", } @@ -139,7 +142,7 @@ def test_revoke_refresh_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": rtok.token, } @@ -164,7 +167,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self): for token in (tok.token, rtok.token): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": token, } @@ -194,7 +197,7 @@ def test_revoke_token_with_wrong_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "refresh_token", } From f46439e69844feb12fc8e5a6dcb99dd569abec3f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 27 Jan 2022 12:23:16 -0500 Subject: [PATCH 02/20] OIDC: Add "scopes_supported" to openid-configuration. (#1106) * OIDC: Add "scopes_supported" to openid-configuration. --- CHANGELOG.md | 4 ++++ oauth2_provider/views/oidc.py | 7 ++++++- tests/test_oidc_views.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da07b6cab..6a18b1bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] unreleased +### Added +* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). + This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + ### Changed * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index e66b30a86..bb47d4f43 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -18,7 +18,8 @@ class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): """ - View used to show oidc provider configuration information + View used to show oidc provider configuration information per + `OpenID Provider Metadata `_ """ def get(self, request, *args, **kwargs): @@ -49,6 +50,9 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() oidc_claims = list(set(validator.get_discovery_claims(request))) + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + scopes = scopes_class() + scopes_supported = [scope for scope in scopes.get_available_scopes()] data = { "issuer": issuer_url, @@ -56,6 +60,7 @@ def get(self, request, *args, **kwargs): "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "jwks_uri": jwks_uri, + "scopes_supported": scopes_supported, "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": signing_algorithms, diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index fa514ac92..5eb9c2d77 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -17,6 +17,7 @@ def test_get_connect_discovery_info(self): "token_endpoint": "http://localhost/o/token/", "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", @@ -44,6 +45,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "token_endpoint": "http://testserver/o/token/", "userinfo_endpoint": "http://testserver/o/userinfo/", "jwks_uri": "http://testserver/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", From 492a867499b50f348c28db4ef3e429e8f46dc412 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 1 Feb 2022 09:17:19 -0500 Subject: [PATCH 03/20] OIDC: Standard scopes to determine which claims are returned (#1108) * Add configurable attribute to restrict returned claims based on scopes. --- CHANGELOG.md | 8 ++- docs/oidc.rst | 94 +++++++++++++++++++--------- oauth2_provider/oauth2_validators.py | 37 ++++++++++- tests/conftest.py | 40 ++++++++++++ tests/presets.py | 2 + tests/test_oidc_views.py | 56 +++++++++++++++++ 6 files changed, 203 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a18b1bd2..23035d0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] unreleased ### Added -* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). +* #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). ### Changed @@ -28,7 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the auto-generated or manually-entered `client_secret` before hitting Save. +* #1108 OIDC: (**Breaking**) Add default configurable OIDC standard scopes that determine which claims are returned. + If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses) + and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`. +* #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`. +### Fixed +* #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. ## [1.7.0] 2022-01-23 diff --git a/docs/oidc.rst b/docs/oidc.rst index 143bec5e5..8dfea2e16 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -102,7 +102,7 @@ so there is no need to add a setting for the public key. Rotating the RSA private key -~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE`` setting. For example::: @@ -143,7 +143,7 @@ scopes in your ``settings.py``:: # ... any other settings you want } -.. info:: +.. note:: If you want to enable ``RS256`` at a later date, you can do so - just add the private key as described above. @@ -250,54 +250,88 @@ our custom validator. It takes one of two forms: The first form gets passed a request object, and should return a dictionary mapping a claim name to claim data:: class CustomOAuth2Validator(OAuth2Validator): + # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, + # otherwise the OIDC standard scopes are used. + def get_additional_claims(self, request): - claims = {} - claims["email"] = request.user.get_user_email() - claims["username"] = request.user.get_full_name() + return { + "given_name": request.user.first_name, + "family_name": request.user.last_name, + "name": ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": request.user.username, + "email": request.user.email, + } - return claims The second form gets no request object, and should return a dictionary mapping a claim name to a callable, accepting a request and producing the claim data:: class CustomOAuth2Validator(OAuth2Validator): - def get_additional_claims(self): - def get_user_email(request): - return request.user.get_user_email() + # Extend the standard scopes to add a new "permissions" scope + # which returns a "permissions" claim: + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"permissions": "permissions"}) + + def get_additional_claims(self): + return { + "given_name": lambda request: request.user.first_name, + "family_name": lambda request: request.user.last_name, + "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": lambda request: request.user.username, + "email": lambda request: request.user.email, + "permissions": lambda request: list(request.user.get_group_permissions()), + } - claims = {} - claims["email"] = get_user_email - claims["username"] = lambda r: r.user.get_full_name() - - return claims Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. -In some cases, it might be desirable to not list all claims in discovery info. To customize -which claims are advertised, you can override the ``get_discovery_claims`` method to return -a list of claim names to advertise. If your ``get_additional_claims`` uses the first form -and you still want to advertise claims, you can also override ``get_discovery_claims``. +Supported claims discovery +-------------------------- -In order to help lcients discover claims early, they can be advertised in the discovery +In order to help clients discover claims early, they can be advertised in the discovery info, under the ``claims_supported`` key. In order for the discovery info view to automatically add all claims your validator returns, you need to use the second form (producing callables), because the discovery info views are requested with an unauthenticated request, so directly producing claim data would fail. If you use the first form, producing claim data directly, your claims will not be added to discovery info. +In some cases, it might be desirable to not list all claims in discovery info. To customize +which claims are advertised, you can override the ``get_discovery_claims`` method to return +a list of claim names to advertise. If your ``get_additional_claims`` uses the first form +and you still want to advertise claims, you can also override ``get_discovery_claims``. + +Using OIDC scopes to determine which claims are returned +-------------------------------------------------------- + +The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's +`5.4 Requesting Claims using Scope Values`_ feature. +For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted. + +To change the list of claims and which scopes result in their being returned, +override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope. +The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted:: + class CustomOAuth2Validator(OAuth2Validator): + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"foo": "bar"}) + +Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes. + +You have to make sure you've added addtional claims via ``get_additional_claims`` +and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work. + .. note:: This ``request`` object is not a ``django.http.Request`` object, but an ``oauthlib.common.Request`` object. This has a number of attributes that you can use to decide what claims to put in to the ID token: - * ``request.scopes`` - a list of the scopes requested by the client when - making an authorization request. - * ``request.claims`` - a dictionary of the requested claims, using the - `OIDC claims requesting system`_. These must be requested by the client - when making an authorization request. - * ``request.user`` - the django user object. + * ``request.scopes`` - the list of granted scopes. + * ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_. + These must be requested by the client when making an authorization request. + * ``request.user`` - the `Django User`_ object. -.. _OIDC claims requesting system: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +.. _5.4 Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +.. _5.5 Requesting Claims using the "claims" Request Parameter: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +.. _Django User: https://docs.djangoproject.com/en/stable/ref/contrib/auth/#user-model What claims you decide to put in to the token is up to you to determine based upon what the scopes and / or claims means to your provider. @@ -307,11 +341,11 @@ Adding information to the ``UserInfo`` service ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``UserInfo`` service is supplied as part of the OIDC service, and is used -to retrieve more information about the user than was supplied in the ID token -when the user logged in to the OIDC client application. It is optional to use -the service. The service is accessed by making a request to the +to retrieve information about the user given their Access Token. +It is optional to use the service. The service is accessed by making a request to the ``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token -retrieved at login as a ``Bearer`` token. +retrieved at login as a ``Bearer`` token or as a form-encoded ``access_token`` body parameter +for a POST request. Again, to modify the content delivered, we need to add a function to our custom validator. The default implementation adds the claims from the ID diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 00c5e7de0..b33c80f39 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -65,6 +65,34 @@ class OAuth2Validator(RequestValidator): + # Return the given claim only if the given scope is present. + # Extended as needed for non-standard OIDC claims/scopes. + # Override by setting to None to ignore scopes. + # see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + # For example, for the "nickname" claim, you need the "profile" scope. + oidc_claim_scope = { + "sub": "openid", + "name": "profile", + "family_name": "profile", + "given_name": "profile", + "middle_name": "profile", + "nickname": "profile", + "preferred_username": "profile", + "profile": "profile", + "picture": "profile", + "website": "profile", + "gender": "profile", + "birthdate": "profile", + "zoneinfo": "profile", + "locale": "profile", + "updated_at": "profile", + "email": "email", + "email_verified": "email", + "address": "address", + "phone_number": "phone", + "phone_number_verified": "phone", + } + def _extract_basic_auth(self, request): """ Return authentication string if request contains basic auth credentials, @@ -397,7 +425,7 @@ def validate_bearer_token(self, token, scopes, request): if access_token and access_token.is_valid(scopes): request.client = access_token.application request.user = access_token.user - request.scopes = scopes + request.scopes = list(access_token.scopes) # this is needed by django rest framework request.access_token = access_token @@ -759,8 +787,11 @@ def get_oidc_claims(self, token, token_handler, request): data = self.get_claim_dict(request) claims = {} + # TODO if request.claims then return only the claims requested, but limited by granted scopes. + for k, v in data.items(): - claims[k] = v(request) if callable(v) else v + if not self.oidc_claim_scope or self.oidc_claim_scope.get(k) in request.scopes: + claims[k] = v(request) if callable(v) else v return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -911,7 +942,7 @@ def get_userinfo_claims(self, request): current user's claims. """ - return self.get_oidc_claims(None, None, request) + return self.get_oidc_claims(request.access_token, None, request) def get_additional_claims(self, request): return {} diff --git a/tests/conftest.py b/tests/conftest.py index 520b6cbac..14db54aa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,3 +158,43 @@ def oidc_tokens(oauth2_settings, application, test_user, client): id_token=token_data["id_token"], oauth2_settings=oauth2_settings, ) + + +@pytest.fixture +def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): + oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE) + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": application.client_id, + "state": "random_state_string", + "scope": "openid email", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] + client.logout() + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": application.client_id, + "client_secret": CLEARTEXT_SECRET, + "scope": "openid email", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + return SimpleNamespace( + user=test_user, + application=application, + access_token=token_data["access_token"], + id_token=token_data["id_token"], + oauth2_settings=oauth2_settings, + ) diff --git a/tests/presets.py b/tests/presets.py index 438da1e03..fa2d7a34c 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -22,6 +22,8 @@ } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] +OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"}) OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] REST_FRAMEWORK_SCOPES = { diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 5eb9c2d77..7b379d1b3 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -160,6 +160,8 @@ def claim_user_email(request): @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): + oidc_claim_scope = None + def get_additional_claims(self): return { "username": claim_user_email, @@ -183,9 +185,38 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims_email_scope_callable( + oidc_email_scope_tokens, client, oauth2_settings +): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self): + return { + "username": claim_user_email, + "email": claim_user_email, + } + + oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_email_scope_tokens.user.pk) + + assert "username" not in data + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL + + @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): + oidc_claim_scope = None + def get_additional_claims(self, request): return { "username": EXAMPLE_EMAIL, @@ -207,3 +238,28 @@ def get_additional_claims(self, request): assert "email" in data assert data["email"] == EXAMPLE_EMAIL + + +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self, request): + return { + "username": EXAMPLE_EMAIL, + "email": EXAMPLE_EMAIL, + } + + oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_email_scope_tokens.user.pk) + + assert "username" not in data + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL From 769c8e7ba6ef33658d2b4f32065352af6e468ecf Mon Sep 17 00:00:00 2001 From: Brian Helba Date: Wed, 16 Feb 2022 10:36:02 -0500 Subject: [PATCH 04/20] Prevent the tests/migrations directory from getting packaged (#1118) Simply excluding "tests" is not enough to prevent subpackages from also being excluded. If a wildcard is provided to "find_packages(exclude=", it will only match a single hierarchy level. Without this, a directory "site-packages/tests/migrations" gets created upon package install. --- AUTHORS | 1 + setup.cfg | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index c6e66453d..69905c31f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Ash Christopher Asif Saif Uddin Bart Merenda Bas van Oostveen +Brian Helba Dave Burkholder David Fischer David Smith diff --git a/setup.cfg b/setup.cfg index 4a8de4569..7fc5a9243 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,4 +38,6 @@ install_requires = jwcrypto >= 0.8.0 [options.packages.find] -exclude = tests +exclude = + tests + tests.* From c48b7516fe6aebf89c5964e4855ecf129274c3c3 Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 23 Feb 2022 15:52:28 -0500 Subject: [PATCH 05/20] Topic/1112 (#1113) --- AUTHORS | 1 + docs/tutorial/tutorial_03.rst | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/AUTHORS b/AUTHORS index 69905c31f..2ba7f3325 100644 --- a/AUTHORS +++ b/AUTHORS @@ -70,3 +70,4 @@ Vinay Karanam Eduardo Oliveira Andrea Greco Dominik George +David Hill diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 52868c01f..30c8317e6 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -78,3 +78,35 @@ Now supposing your access token value is `123456` you can try to access your aut :: curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret + +Working with Rest_framework generic class based views +----------------------------------------------------- + +If you have completed the `Django REST framework tutorial +`_, +you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes. + +It would be nice to reuse those views **and** support token handling. Instead of reworking +those classes to be ProtectedResourceView based, the solution is much simpler than that. + +Assume you have already modified the settings as was already shown. +The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly. + +.. code-block:: python + + from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope + + class SnippetList(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + + class SnippetDetail(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + +Note that this example overrides the Django default permission class setting. There are several other +ways this can be solved. Overriding the class function *get_permission_classes* is another way +to solve the problem. + +A detailed dive into the `Dango REST framework permissions is here. `_ + From 5531cb92b43126565216610e320881f34639a9a1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 18 Mar 2022 14:45:07 -0400 Subject: [PATCH 06/20] [pre-commit.ci] pre-commit autoupdate (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c78568ea..3b883f488 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.1.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From e761ebc22fbac1aa1837c3f851fe3bac38421331 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Fri, 18 Mar 2022 20:35:26 +0100 Subject: [PATCH 07/20] Fix broken import in doc (#1121) * Fix broken import in doc * Add Carl Schwan to AUTHORS Co-authored-by: Alan Crosswell --- AUTHORS | 1 + docs/oidc.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 2ba7f3325..7f3f21276 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,6 +22,7 @@ Asif Saif Uddin Bart Merenda Bas van Oostveen Brian Helba +Carl Schwan Dave Burkholder David Fischer David Smith diff --git a/docs/oidc.rst b/docs/oidc.rst index 8dfea2e16..f4fdfd09c 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -80,7 +80,7 @@ Now we need to add this key to our settings and allow the ``openid`` scope to be used. Assuming we have set an environment variable called ``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: - import os.environ + import os OAUTH2_PROVIDER = { "OIDC_ENABLED": True, From fc291ce040f2eec6722cca9882eac88ec49b2250 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 19 Mar 2022 14:30:02 -0400 Subject: [PATCH 08/20] Security BCP: Remove OOB (#1124) * Remove OOB * Indicate that this is a security fix. --- CHANGELOG.md | 6 ++ .../oauth2_provider/authorized-oob.html | 23 ----- oauth2_provider/views/base.py | 36 +------- tests/test_authorization_code.py | 90 ------------------- 4 files changed, 8 insertions(+), 147 deletions(-) delete mode 100644 oauth2_provider/templates/oauth2_provider/authorized-oob.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 23035d0b2..c3b10068b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. +### Removed +* #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced + by [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) "OAuth 2.0 for Native Apps" BCP. Google has + [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with + a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. + ## [1.7.0] 2022-01-23 ### Added diff --git a/oauth2_provider/templates/oauth2_provider/authorized-oob.html b/oauth2_provider/templates/oauth2_provider/authorized-oob.html deleted file mode 100644 index 78399da7c..000000000 --- a/oauth2_provider/templates/oauth2_provider/authorized-oob.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "oauth2_provider/base.html" %} - -{% load i18n %} - -{% block title %} -Success code={{code}} -{% endblock %} - -{% block content %} -
- {% if not error %} -

{% trans "Success" %}

- -

{% trans "Please return to your application and enter this code:" %}

- -

{{ code }}

- - {% else %} -

Error: {{ error.error }}

-

{{ error.description }}

- {% endif %} -
-{% endblock %} diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e46a49d10..211da45ed 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,11 +1,8 @@ import json import logging -import urllib.parse from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, JsonResponse -from django.shortcuts import render -from django.urls import reverse +from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -207,42 +204,13 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True, ) - return self.redirect(uri, application, token) + return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) return self.render_to_response(self.get_context_data(**kwargs)) - def redirect(self, redirect_to, application, token=None): - - if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): - return super().redirect(redirect_to, application) - - parsed_redirect = urllib.parse.urlparse(redirect_to) - code = urllib.parse.parse_qs(parsed_redirect.query)["code"][0] - - if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): - - response = { - "access_token": code, - "token_uri": redirect_to, - "client_id": application.client_id, - "client_secret": application.client_secret, - "revoke_uri": reverse("oauth2_provider:revoke-token"), - } - - return JsonResponse(response) - - else: - return render( - request=self.request, - template_name="oauth2_provider/authorized-oob.html", - context={ - "code": code, - }, - ) - @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 91fd06bd1..25447b9dd 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -2,7 +2,6 @@ import datetime import hashlib import json -import re from urllib.parse import parse_qs, urlparse import pytest @@ -32,8 +31,6 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() -URI_OOB = "urn:ietf:wg:oauth:2.0:oob" -URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" @@ -56,7 +53,6 @@ def setUp(self): name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" - " " + URI_OOB + " " + URI_OOB_AUTO ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, @@ -1532,92 +1528,6 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - def test_oob_as_html(self): - """ - Test out-of-band authentication. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], r"^text/html") - - content = response.content.decode("utf-8") - - # "A lot of applications, for legacy reasons, use this and regex - # to extract the token, risking summoning zalgo in the process." - # -- https://github.com/jazzband/django-oauth-toolkit/issues/235 - - matches = re.search(r".*([^<>]*)", content) - self.assertIsNotNone(matches, msg="OOB response contains code inside tag") - self.assertEqual(len(matches.groups()), 1, msg="OOB response contains multiple tags") - authorization_code = matches.groups()[0] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB, - "client_id": self.application.client_id, - "client_secret": CLEARTEXT_SECRET, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_oob_as_json(self): - """ - Test out-of-band authentication, with a JSON response. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB_AUTO, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], "^application/json") - - parsed_response = json.loads(response.content.decode("utf-8")) - - self.assertIn("access_token", parsed_response) - authorization_code = parsed_response["access_token"] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB_AUTO, - "client_id": self.application.client_id, - "client_secret": CLEARTEXT_SECRET, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): From 2212144242f650207f71525eb088413e81587208 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 19 Mar 2022 18:25:34 -0400 Subject: [PATCH 09/20] Revert 1070 (Celery tasks.py) (#1126) * Revert #1070: tasks.py raises an import exception with Celery and conflicts with Huey. --- CHANGELOG.md | 7 +++++++ docs/management_commands.rst | 5 ----- oauth2_provider/tasks.py | 8 -------- 3 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 oauth2_provider/tasks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b10068b..e6b089f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. +## [1.7.1] 2022-03-19 + +### Removed +* #1126 Reverts #1070 which incorrectly added Celery auto-discovery tasks.py (as described in #1123) and because it conflicts + with Huey's auto-discovery which also uses tasks.py as described in #1114. If you are using Celery or Huey, you'll need + to separately implement these tasks. + ## [1.7.0] 2022-01-23 ### Added diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 727ff9e98..147a0bbe4 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -21,8 +21,3 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. - -The ``cleartokens`` action can also be scheduled as a `Celery periodic task`_ -by using the ``clear_tokens`` task (automatically registered when using Celery). - -.. _Celery periodic task: https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html diff --git a/oauth2_provider/tasks.py b/oauth2_provider/tasks.py deleted file mode 100644 index d86c33720..000000000 --- a/oauth2_provider/tasks.py +++ /dev/null @@ -1,8 +0,0 @@ -from celery import shared_task - - -@shared_task -def clear_tokens(): - from ...models import clear_expired # noqa - - clear_expired() From 56c8c66b11d01991897858082e1b9ff38b260ea3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 27 Mar 2022 08:42:05 -0400 Subject: [PATCH 10/20] Pin Jinja2 version per https://github.com/sphinx-doc/sphinx/issues/10291 (#1134) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 03241132f..7232ecef7 100644 --- a/tox.ini +++ b/tox.ini @@ -64,6 +64,7 @@ commands = docs: make html livedocs: make livehtml deps = + Jinja2<3.1 sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 From a62195b20edf03c755a476416b2e79d65899505b Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 27 Mar 2022 09:01:52 -0400 Subject: [PATCH 11/20] Missed updating master branch version to 1.7.1 (#1133) --- oauth2_provider/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 805f886e8..9024b6f63 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.7.0" +__version__ = "1.7.1" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From c8eee2cea6c937951dd23d9dee2f2dbfaecabd01 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 29 Mar 2022 00:35:48 +1100 Subject: [PATCH 12/20] Update `createapplication` command (#1132) * Add --algorithm argument and fix --skip-authorization help text for createapplication command * Add unit test for update to createapplication command * Add to AUTHORS * Update changelog for createapplication command changes * Add documentation for 'createapplication' command to 'management_commands.rst' Co-authored-by: Alan Crosswell --- AUTHORS | 1 + CHANGELOG.md | 2 + docs/management_commands.rst | 37 +++++++++++++++++++ .../management/commands/createapplication.py | 7 +++- tests/test_commands.py | 17 +++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 7f3f21276..962cc7d00 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Bart Merenda Bas van Oostveen Brian Helba Carl Schwan +Daniel 'Vector' Kerr Dave Burkholder David Fischer David Smith diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b089f5f..da0fede00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses) and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`. * #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`. +* #1132: Added `--algorithm` argument to `createapplication` management command ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. +* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command ### Removed * #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 147a0bbe4..956ce9ef9 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -4,6 +4,8 @@ Management commands Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means (eg: cron) .. _cleartokens: +.. _createapplication: + cleartokens ~~~~~~~~~~~ @@ -21,3 +23,38 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. + + + +createapplication +~~~~~~~~~~~~~~~~~ + +The ``createapplication`` management command provides a shortcut to create a new application in a programmatic way. + +.. code-block:: sh + + usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] + [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] + [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + [--skip-checks] + client_type authorization_grant_type + + Shortcut to create a new application in a programmatic way + + positional arguments: + client_type The client type, can be confidential or public + authorization_grant_type + The type of authorization grant to be used + + optional arguments: + -h, --help show this help message and exit + --client-id CLIENT_ID + The ID of the new application + --user USER The user the application belongs to + --redirect-uris REDIRECT_URIS + The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + --client-secret CLIENT_SECRET + The secret for this application + --name NAME The name this application + --skip-authorization The ID of the new application + ... diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 92c4ae46b..f8575a8b0 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -49,7 +49,12 @@ def add_arguments(self, parser): parser.add_argument( "--skip-authorization", action="store_true", - help="The ID of the new application", + help="If set, completely bypass the authorization form, even on the first use of the application", + ) + parser.add_argument( + "--algorithm", + type=str, + help="The OIDC token signing algorithm for this application (e.g., 'RS256' or 'HS256')", ) def handle(self, *args, **options): diff --git a/tests/test_commands.py b/tests/test_commands.py index 13b0eeb3d..f9a9f5ade 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,5 +1,6 @@ from io import StringIO +import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import check_password from django.core.management import call_command @@ -8,6 +9,8 @@ from oauth2_provider.models import get_application_model +from . import presets + Application = get_application_model() @@ -112,6 +115,20 @@ def test_application_created_with_user(self): self.assertEqual(app.user, user) + @pytest.mark.usefixtures("oauth2_settings") + @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) + def test_application_created_with_algorithm(self): + call_command( + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--algorithm=RS256", + ) + app = Application.objects.get() + + self.assertEqual(app.algorithm, "RS256") + def test_validation_failed_message(self): output = StringIO() call_command( From e8ffc9ca31975d1e281d199609356057a612f109 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 28 Mar 2022 13:06:16 -0400 Subject: [PATCH 13/20] Add tutorial for Celery task setup. (#1128) * Add tutorial for Celery task setup. --- CHANGELOG.md | 2 + docs/management_commands.rst | 3 +- docs/tutorial/admin+celery.png | Bin 0 -> 67088 bytes docs/tutorial/celery+add.png | Bin 0 -> 72877 bytes docs/tutorial/tutorial.rst | 2 + docs/tutorial/tutorial_05.rst | 169 +++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 docs/tutorial/admin+celery.png create mode 100644 docs/tutorial/celery+add.png create mode 100644 docs/tutorial/tutorial_05.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index da0fede00..148d5e50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). +* #1128 Documentation: [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_05.html) + on using Celery to automate clearing expired tokens. ### Changed * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 956ce9ef9..3029f1345 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -1,7 +1,8 @@ Management commands =================== -Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means (eg: cron) +Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means such as cron +or :doc:`Celery `. .. _cleartokens: .. _createapplication: diff --git a/docs/tutorial/admin+celery.png b/docs/tutorial/admin+celery.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e25ea1977357a1900d9250bccf8c6ba583d00c GIT binary patch literal 67088 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 Mq42y+?A5#f1tHCoj{pDw literal 0 HcmV?d00001 diff --git a/docs/tutorial/celery+add.png b/docs/tutorial/celery+add.png new file mode 100644 index 0000000000000000000000000000000000000000..eca8e02e2bda4995606deaf3516b681c6d963164 GIT binary patch literal 72877 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 literal 0 HcmV?d00001 diff --git a/docs/tutorial/tutorial.rst b/docs/tutorial/tutorial.rst index 0de799a6e..5a0662507 100644 --- a/docs/tutorial/tutorial.rst +++ b/docs/tutorial/tutorial.rst @@ -8,3 +8,5 @@ Tutorials tutorial_02 tutorial_03 tutorial_04 + tutorial_05 + diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst new file mode 100644 index 000000000..784d489a8 --- /dev/null +++ b/docs/tutorial/tutorial_05.rst @@ -0,0 +1,169 @@ +Part 5 - Using Celery to Automate Maintenance Chores +==================================================== + +Scenario +-------- +In :doc:`Part 1 ` you created your own :term:`Authorization Server` and it's running along just fine. +However, the database is getting cluttered with expired tokens. You can periodically run +the :doc:`cleartokens management command <../management_commands>`, but why not automate this with +`Celery `_? + +Set up RabbitMQ +--------------- +Celery components communicate via a message queue. We'll use `RabbitMQ `_. + +Install RabbitMQ on MacOS +~~~~~~~~~~~~~~~~~~~~~~~~~~ +If you are using MacOS it's likely you are already using `Homebrew `_. If not, now's +the time to install this fantastic package manager. + +:: + + brew install rabbitmq + brew service start rabbitmq + +Install RabbitMQ with Docker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This will start up a docker image that just works: +:: + + docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.9-management + + + +Install RabbitMQ on Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +See the `RabbitMQ Installing on Windows `_ instructions. + + +Add Celery +---------- +Make sure you virtualenv is active and install `celery` and +`django-celery-beat `_. + +:: + + pip install celery django-celery-beat + +Update your list of installed apps to include both your :term:`Authorization Server` app -- we'll call it ``tutorial``, +and ``django_celery_beat`` which extends your Django project to store your periodic task schedule +in the database and adds a Django Admin interface for configuring them. + +.. code-block:: python + + INSTALLED_APPS = { + # ... + "tutorial", + "django_celery_beat", + } + + +Now add a new file to your app to add Celery: ``tutorial/celery.py``: + +.. code-block:: python + + import os + + from celery import Celery + + # Set the default Django settings module for the 'celery' program. + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings') + app = Celery('tutorial', broker="pyamqp://guest@localhost//") + app.config_from_object('django.conf:settings', namespace='CELERY') + + # Load task modules from all registered Django apps. + app.autodiscover_tasks() + +This will autodiscover any ``tasks.py`` files in the list of installed apps. +We'll add ours now in ``tutorial/tasks.py``: + +.. code-block:: python + + from celery import shared_task + + @shared_task + def clear_tokens(): + from oauth2_provider.models import clear_expired + + clear_expired() + +Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: + +.. code-block:: python + + from .celery import app as celery_app + + __all__ = ('celery_app',) + + +Run Celery Beat and the Worker +------------------------------ + +RabbitMQ should already be running; it's the "glue" between Beat and the Worker. + +It's best to run each of these in its own terminal window so you can see the log messages. + +Start Celery Beat +~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler + +Start Celery Worker +~~~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial worker -l INFO + +Configure the ``clear_tokens`` task +----------------------------------- + +Go into `Django Admin `_ and you'll see a new section for periodic tasks: + +.. image:: admin+celery.png + :width: 500 + :alt: Django Admin interface screenshot + +Now let's define a fairly short (10 second) interval. Go to: http://127.0.0.1:8000/admin/django_celery_beat/intervalschedule/ +and select Add Interval, set number of intervals to 10 and interval period to seconds and Save. + +Then go to http://127.0.0.1:8000/admin/django_celery_beat/periodictask/ to add a new periodic task by +selecting `Add Periodic Task `_ and +select ``tutorial.tasks.clear_tokens``, choose the ``every 10 seconds`` interval schedule, and "Save." + +.. image:: celery+add.png + :width: 500 + :alt: Django Admin interface screenshot + + +Now your Celery Beat and Celery Workers should start running the task every 10 seconds. + +The Beat console will look like this: + +:: + + [2022-03-19 22:06:35,605: INFO/MainProcess] Scheduler: Sending due task clear stale tokens (tutorial.tasks.clear_tokens) + +And the Workers console like this: + +:: + + [2022-03-19 22:06:35,614: INFO/MainProcess] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] received + [2022-03-19 22:06:35,616: INFO/ForkPoolWorker-8] refresh_expire_at is None. No refresh tokens deleted. + [2022-03-19 22:06:35,629: INFO/ForkPoolWorker-8] 0 Expired access tokens deleted + [2022-03-19 22:06:35,631: INFO/ForkPoolWorker-8] 0 Expired grant tokens deleted + [2022-03-19 22:06:35,632: INFO/ForkPoolWorker-8] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] succeeded in 0.016124433999999965s: None + + +References +---------- + +The preceding is based on these references: + +https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html + +https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers + +https://django-celery-beat.readthedocs.io/en/latest/index.html From c79eae268949ad2a418315a62324033103d0776d Mon Sep 17 00:00:00 2001 From: Darrel O'Pry Date: Tue, 29 Mar 2022 12:54:57 -0400 Subject: [PATCH 14/20] chore: .gitignore local development files (#1137) * chore: .gitignore local development files - venv, my company's development team keeps venvs in project folders. - coverage.xml is generated by tox * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3643335d4..4d15af97f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ _build # Sqlite database files *.sqlite + +/venv/ +/coverage.xml From e647d51bee0685d21ab692b205dc0fa65b4c4d8a Mon Sep 17 00:00:00 2001 From: Darrel O'Pry Date: Tue, 29 Mar 2022 13:12:33 -0400 Subject: [PATCH 15/20] feat: Update PKCE_REQUIRED to true by default (#1129) * feat: default PKCE_REQUIRED to True BREAKING CHANGE: set to False to maintain legacy behavior Co-authored-by: Alan Crosswell Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 5 +++++ docs/settings.rst | 16 ++++++++++++++-- oauth2_provider/settings.py | 2 +- tests/presets.py | 1 + tests/settings.py | 1 + tests/test_authorization_code.py | 17 +++++------------ tests/test_hybrid.py | 2 +- tests/test_scopes.py | 7 +++++++ tests/test_settings.py | 5 +++++ 10 files changed, 41 insertions(+), 16 deletions(-) diff --git a/AUTHORS b/AUTHORS index 962cc7d00..8ec6667e2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -73,3 +73,4 @@ Eduardo Oliveira Andrea Greco Dominik George David Hill +Darrel O'Pry diff --git a/CHANGELOG.md b/CHANGELOG.md index 148d5e50e..baae70de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on using Celery to automate clearing expired tokens. ### Changed +* #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without + PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) + in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). + If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False ` in your settings.py + * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm diff --git a/docs/settings.rst b/docs/settings.rst index 0ba12df11..2ac31ccda 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -253,9 +253,21 @@ will be used. PKCE_REQUIRED ~~~~~~~~~~~~~ -Default: ``False`` +Default: ``True`` + +Can be either a bool or a callable that takes a client id and returns a bool. + +Whether or not `Proof Key for Code Exchange `_ is required. + +According to `OAuth 2.0 Security Best Current Practice `_ related to the +`Authorization Code Grant `_ + +- Public clients MUST use PKCE `RFC7636 `_ +- For confidential clients, the use of PKCE `RFC7636 `_ is RECOMMENDED. + + + -Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool. OIDC_RSA_PRIVATE_KEY diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 3b7dea3f8..00a4e631c 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -97,7 +97,7 @@ "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, # Whether or not PKCE is required - "PKCE_REQUIRED": False, + "PKCE_REQUIRED": True, # Whether to re-create OAuthlibCore on every request. # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, diff --git a/tests/presets.py b/tests/presets.py index fa2d7a34c..6411687a4 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -19,6 +19,7 @@ "openid": "OpenID connect", }, "DEFAULT_SCOPES": ["read", "write"], + "PKCE_REQUIRED": False, } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] diff --git a/tests/settings.py b/tests/settings.py index d2fbe6a56..27dcfe9a3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -159,3 +159,4 @@ CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 1 CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0 +PKCE_REQUIRED = False diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 25447b9dd..8bface719 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -48,6 +48,7 @@ def setUp(self): self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.PKCE_REQUIRED = False self.application = Application.objects.create( name="Test Application", @@ -73,6 +74,7 @@ class TestRegressionIssue315(BaseTest): """ def test_request_is_not_overwritten(self): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") response = self.client.get( reverse("oauth2_provider:authorize"), @@ -94,6 +96,7 @@ def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() @@ -132,6 +135,7 @@ def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") query_data = { @@ -644,7 +648,6 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ - self.oauth2_settings.PKCE_REQUIRED = True authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", @@ -1115,7 +1118,6 @@ def test_public_pkce_S256_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1143,7 +1145,6 @@ def test_public_pkce_plain_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1171,7 +1172,6 @@ def test_public_pkce_S256(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1200,7 +1200,6 @@ def test_public_pkce_plain(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1228,7 +1227,6 @@ def test_public_pkce_invalid_algorithm(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1250,13 +1248,13 @@ def test_public_pkce_missing_code_challenge(self): Request an access token using client_type: public and PKCE enabled but with the code_challenge missing """ + self.oauth2_settings.PKCE_REQUIRED = True self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1282,7 +1280,6 @@ def test_public_pkce_missing_code_challenge_method(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1308,7 +1305,6 @@ def test_public_pkce_S256_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1332,7 +1328,6 @@ def test_public_pkce_plain_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1356,7 +1351,6 @@ def test_public_pkce_S256_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1379,7 +1373,6 @@ def test_public_pkce_plain_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 3f4048698..2e85b05b1 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -52,7 +52,7 @@ def setUp(self): self.factory = RequestFactory() self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - + self.oauth2_settings.PKCE_REQUIRED = False self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 39601ed3b..548cc060c 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -83,6 +83,7 @@ def test_scopes_saved_in_grant(self): """ Test scopes are properly saved in grant """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -105,6 +106,7 @@ def test_scopes_save_in_access_token(self): """ Test scopes are properly saved in access token """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -141,6 +143,7 @@ def test_scopes_protection_valid(self): """ Test access to a scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -183,6 +186,7 @@ def test_scopes_protection_fail(self): """ Test access to a scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -225,6 +229,7 @@ def test_multi_scope_fail(self): """ Test access to a multi-scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -267,6 +272,7 @@ def test_multi_scope_valid(self): """ Test access to a multi-scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -308,6 +314,7 @@ def test_multi_scope_valid(self): class TestReadWriteScope(BaseTest): def get_access_token(self, scopes): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code diff --git a/tests/test_settings.py b/tests/test_settings.py index 52bdafe03..f9f540339 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -167,3 +167,8 @@ def test_generating_iss_endpoint_type_error(oauth2_settings): with pytest.raises(TypeError) as exc: oauth2_settings.oidc_issuer(None) assert str(exc.value) == "request must be a django or oauthlib request: got None" + + +def test_pkce_required_is_default(): + settings = OAuth2ProviderSettings() + assert settings.PKCE_REQUIRED is True From 4a9039eb2af2df3409a02f492a68b77a922884a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 23 Apr 2022 17:50:16 -0400 Subject: [PATCH 16/20] [pre-commit.ci] pre-commit autoupdate (#1139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0) - [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b883f488..647f79c66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: check-ast - id: trailing-whitespace From e506fceffaf9a89aca77732eb2e9de86c950bfe9 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Sun, 24 Apr 2022 13:42:45 +0200 Subject: [PATCH 17/20] sphinx-lint (#1142) * Testing sphinx-lint. * Add sphinx-lint to pre-commit-config, tox, and github workflow. * Add self to AUTHORS. --- .pre-commit-config.yaml | 4 +++ AUTHORS | 1 + docs/glossary.rst | 2 +- docs/management_commands.rst | 16 +++++----- docs/oidc.rst | 47 +++++++++++++++-------------- docs/rest-framework/permissions.rst | 2 +- docs/templates.rst | 2 +- docs/tutorial/tutorial_05.rst | 4 +-- docs/views/mixins.rst | 2 +- tox.ini | 9 +++++- 10 files changed, 51 insertions(+), 38 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 647f79c66..386d28c9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,7 @@ repos: hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v0.3 + hooks: + - id: sphinx-lint diff --git a/AUTHORS b/AUTHORS index 8ec6667e2..a5f652ea0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -44,6 +44,7 @@ Jim Graham Jonas Nygaard Pedersen Jonathan Steffan Jozef Knaperek +Julien Palard Jun Zhou Kristian Rune Larsen Michael Howitz diff --git a/docs/glossary.rst b/docs/glossary.rst index c1536f801..7819129b1 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -39,4 +39,4 @@ Glossary Refresh Token A token the authorization server may issue to clients and can be swapped for a brand new access token, without - repeating the authorization process. It has no expire time. \ No newline at end of file + repeating the authorization process. It has no expire time. diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 3029f1345..085b130ec 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -35,27 +35,27 @@ The ``createapplication`` management command provides a shortcut to create a new .. code-block:: sh usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] - [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] - [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] - [--skip-checks] - client_type authorization_grant_type + [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] + [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + [--skip-checks] + client_type authorization_grant_type Shortcut to create a new application in a programmatic way positional arguments: client_type The client type, can be confidential or public authorization_grant_type - The type of authorization grant to be used + The type of authorization grant to be used optional arguments: -h, --help show this help message and exit --client-id CLIENT_ID - The ID of the new application + The ID of the new application --user USER The user the application belongs to --redirect-uris REDIRECT_URIS - The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + The redirect URIs, this must be a space separated string e.g 'URI1 URI2' --client-secret CLIENT_SECRET - The secret for this application + The secret for this application --name NAME The name this application --skip-authorization The ID of the new application ... diff --git a/docs/oidc.rst b/docs/oidc.rst index f4fdfd09c..4b427ba86 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -249,38 +249,39 @@ our custom validator. It takes one of two forms: The first form gets passed a request object, and should return a dictionary mapping a claim name to claim data:: + class CustomOAuth2Validator(OAuth2Validator): - # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, - # otherwise the OIDC standard scopes are used. + # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, + # otherwise the OIDC standard scopes are used. def get_additional_claims(self, request): - return { - "given_name": request.user.first_name, - "family_name": request.user.last_name, - "name": ' '.join([request.user.first_name, request.user.last_name]), - "preferred_username": request.user.username, - "email": request.user.email, - } + return { + "given_name": request.user.first_name, + "family_name": request.user.last_name, + "name": ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": request.user.username, + "email": request.user.email, + } The second form gets no request object, and should return a dictionary mapping a claim name to a callable, accepting a request and producing the claim data:: class CustomOAuth2Validator(OAuth2Validator): - # Extend the standard scopes to add a new "permissions" scope - # which returns a "permissions" claim: - oidc_claim_scope = OAuth2Validator.oidc_claim_scope - oidc_claim_scope.update({"permissions": "permissions"}) - - def get_additional_claims(self): - return { - "given_name": lambda request: request.user.first_name, - "family_name": lambda request: request.user.last_name, - "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), - "preferred_username": lambda request: request.user.username, - "email": lambda request: request.user.email, - "permissions": lambda request: list(request.user.get_group_permissions()), - } + # Extend the standard scopes to add a new "permissions" scope + # which returns a "permissions" claim: + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"permissions": "permissions"}) + + def get_additional_claims(self): + return { + "given_name": lambda request: request.user.first_name, + "family_name": lambda request: request.user.last_name, + "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": lambda request: request.user.username, + "email": lambda request: request.user.email, + "permissions": lambda request: list(request.user.get_group_permissions()), + } Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index 1058aed3f..ee398d9fc 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -114,4 +114,4 @@ The following is a minimal OAS declaration that shows the same required alternat to try it in the `swagger editor `_. .. literalinclude:: openapi.yaml - :language: YAML \ No newline at end of file + :language: YAML diff --git a/docs/templates.rst b/docs/templates.rst index 4f6320bf7..8ebcd4127 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -242,4 +242,4 @@ This template gets passed the following template context variable: .. important:: To override successfully this template you should provide a form that posts to the same URL, example: - ``

`` \ No newline at end of file + ```` diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index 784d489a8..1be656b88 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -83,9 +83,9 @@ We'll add ours now in ``tutorial/tasks.py``: @shared_task def clear_tokens(): - from oauth2_provider.models import clear_expired + from oauth2_provider.models import clear_expired - clear_expired() + clear_expired() Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: diff --git a/docs/views/mixins.rst b/docs/views/mixins.rst index be3541a88..a8da12414 100644 --- a/docs/views/mixins.rst +++ b/docs/views/mixins.rst @@ -2,4 +2,4 @@ Mixins for Class Based Views ============================ .. automodule:: oauth2_provider.views.mixins - :members: \ No newline at end of file + :members: diff --git a/tox.ini b/tox.ini index 7232ecef7..117a5e901 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = flake8, migrations, docs, + sphinxlint, py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, @@ -11,7 +12,7 @@ envlist = [gh-actions] python = 3.7: py37 - 3.8: py38, docs, flake8, migrations + 3.8: py38, docs, flake8, migrations, sphinxlint 3.9: py39 3.10: py310 @@ -56,6 +57,12 @@ passenv = ignore_errors = true ignore_outcome = true +[testenv:sphinxlint] +deps = sphinx-lint +skip_install = True +commands = + sphinx-lint docs/ + [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs From ec34fe935c3d2f0710e3d000b6f531efef4080ca Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 24 Apr 2022 13:01:43 -0400 Subject: [PATCH 18/20] Corrections to glossary terms and documentation links. (#1136) --- docs/resource_server.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/resource_server.rst b/docs/resource_server.rst index e19e542a8..4e623b118 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,6 +1,6 @@ Separate Resource Server ======================== -Django OAuth Toolkit allows to separate the :term:`Authentication Server` and the :term:`Resource Server.` +Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. Based on the `RFC 7662 `_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. @@ -8,7 +8,7 @@ As well the Django OAuth Toolkit allows to verify access tokens by the use of an Setup the Authentication Server ------------------------------- -Setup the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Create a OAuth2 access token for the :term:`Resource Server` and add the ``introspection``-Scope to the settings. @@ -21,7 +21,7 @@ Create a OAuth2 access token for the :term:`Resource Server` and add the ... }, -The :term:`Authentication Server` will listen for introspection requests. +The :term:`Authorization Server` will listen for introspection requests. The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. Example Request:: @@ -49,10 +49,10 @@ Example Response:: Setup the Resource Server ------------------------- -Setup the :term:`Resource Server` like the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` **or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. -The :term:`Resource Server` will try to verify its requests on the :term:`Authentication Server`. +The :term:`Resource Server` will try to verify its requests on the :term:`Authorization Server`. .. code-block:: python @@ -66,7 +66,7 @@ The :term:`Resource Server` will try to verify its requests on the :term:`Authen ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the -:term:`Authentication Server`. +:term:`Authorization Server`. As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. For these, use: ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead From e8d0ee6868021463a7d582495c2e7213eb59f67f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 24 Apr 2022 14:01:46 -0400 Subject: [PATCH 19/20] Add help wanted to the README (#1144) * Add help wanted to the README * Apply suggestions from code review Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- README.rst | 52 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 8a9f333db..3acf459d8 100644 --- a/README.rst +++ b/README.rst @@ -39,11 +39,6 @@ Note: If you have issues installing Django 4.0.0, it is because we only support Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. `Explanation `_. -Contributing ------------- - -We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines `_ and submit a PR. Reporting security issues ------------------------- @@ -99,3 +94,50 @@ License ------- django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. + +Help Wanted +----------- + +We need help maintaining and enhancing django-oauth-toolkit (DOT). + +Join the team +~~~~~~~~~~~~~ + +Please consider joining `Jazzband `__ (If not +already a member) and the `DOT project +team `__. + +How you can help +~~~~~~~~~~~~~~~~ + +See our +`contributing `__ +info and the open +`issues `__ and +`PRs `__, +especially those labeled +`help-wanted `__. + +Submit PRs and Perform Reviews +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PR submissions and reviews are always appreciated! Since we require an +independent review of any PR before it can be merged, having your second +set of eyes looking at PRs is extremely valuable. + +Please don’t merge PRs +~~~~~~~~~~~~~~~~~~~~~~ + +Please be aware that we don’t want *every* Jazzband member to merge PRs +but just a handful of project team members so that we can maintain a +modicum of control over what goes into a release of this security oriented code base. Only `project +leads `__ are able to +publish releases to Pypi and it becomes difficult when creating a new +release for the leads to deal with “unexpected” merged PRs. + +Become a Project Lead +~~~~~~~~~~~~~~~~~~~~~ + +If you are interested in stepping up to be a Project Lead, please join +the +`discussion `__. From 025cd1b7d901150cdb671194970273081de34bc7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 24 Apr 2022 14:26:59 -0400 Subject: [PATCH 20/20] Release 2.0.0 (#1145) --- CHANGELOG.md | 9 +++++---- oauth2_provider/__init__.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baae70de8..7819fe616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] -## [2.0.0] unreleased +## [2.0.0] 2022-04-24 + +This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: ### Added * #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). @@ -28,8 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). - If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False ` in your settings.py - + If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False` in your settings.py * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm @@ -43,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. -* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command +* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command. ### Removed * #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 9024b6f63..49a4433da 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.7.1" +__version__ = "2.0.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig"