diff --git a/Jenkinsfile b/Jenkinsfile index 5b3146d..005322f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,26 +9,109 @@ pipeline { sh 'toxtest/bin/pip install tox==3.28.0 setuptools pathlib2' } } - stage('Test'){ + stage('Test 3.11'){ parallel { stage('Unit Test Django 3.2'){ steps { - sh 'toxtest/bin/tox -e py3.12-django{3.2}' + sh 'toxtest/bin/tox -e py311-django{32}' } } stage('Unit Test Django 4.0'){ steps { - sh 'toxtest/bin/tox -e py3.12-django{4.0}' + sh 'toxtest/bin/tox -e py311-django{40}' } } stage('Unit Test Django 4.1'){ steps { - sh 'toxtest/bin/tox -e py3.12-django{4.1}' + sh 'toxtest/bin/tox -e py311-django{41}' } } stage('Unit Test Django 4.2'){ steps { - sh 'toxtest/bin/tox -e py3.12-django{4.2}' + sh 'toxtest/bin/tox -e py311-django{42}' + } + } + stage('Unit Test Django 5.0'){ + steps { + sh 'toxtest/bin/tox -e py311-django{50}' + } + } + stage('Unit Test Django 5.1'){ + steps { + sh 'toxtest/bin/tox -e py311-django{51}' + } + } + stage('Unit Test Django 5.2'){ + steps { + sh 'toxtest/bin/tox -e py311-django{52}' + } + } + } + } + stage('Test 3.12'){ + parallel { + stage('Unit Test Django 3.2'){ + steps { + sh 'toxtest/bin/tox -e py312-django{32}' + } + } + stage('Unit Test Django 4.0'){ + steps { + sh 'toxtest/bin/tox -e py312-django{40}' + } + } + stage('Unit Test Django 4.1'){ + steps { + sh 'toxtest/bin/tox -e py312-django{41}' + } + } + stage('Unit Test Django 4.2'){ + steps { + sh 'toxtest/bin/tox -e py312-django{42}' + } + } + stage('Unit Test Django 5.0'){ + steps { + sh 'toxtest/bin/tox -e py312-django{50}' + } + } + stage('Unit Test Django 5.1'){ + steps { + sh 'toxtest/bin/tox -e py312-django{51}' + } + } + stage('Unit Test Django 5.2'){ + steps { + sh 'toxtest/bin/tox -e py312-django{52}' + } + } + } + } + stage('Test 3.13'){ + parallel { + stage('Unit Test Django 4.1'){ + steps { + sh 'toxtest/bin/tox -e py313-django{41}' + } + } + stage('Unit Test Django 4.2'){ + steps { + sh 'toxtest/bin/tox -e py313-django{42}' + } + } + stage('Unit Test Django 5.0'){ + steps { + sh 'toxtest/bin/tox -e py313-django{50}' + } + } + stage('Unit Test Django 5.1'){ + steps { + sh 'toxtest/bin/tox -e py313-django{51}' + } + } + stage('Unit Test Django 5.2'){ + steps { + sh 'toxtest/bin/tox -e py313-django{52}' } } } diff --git a/provider/constants.py b/provider/constants.py index 87ed309..23f29ab 100644 --- a/provider/constants.py +++ b/provider/constants.py @@ -41,4 +41,5 @@ ENFORCE_CLIENT_SECURE = getattr(settings, 'OAUTH_ENFORCE_CLIENT_SECURE', True) SESSION_KEY = getattr(settings, 'OAUTH_SESSION_KEY', 'oauth') - +TOKEN_PREFIX_LENGTH = getattr(settings, 'OAUTH_TOKEN_PREFIX_LENGTH', 4) +assert 0 < TOKEN_PREFIX_LENGTH <= 10, "OAUTH_TOKEN_PREFIX_LENGTH must be 10 or less" diff --git a/provider/forms.py b/provider/forms.py index f7c68d8..6df2869 100644 --- a/provider/forms.py +++ b/provider/forms.py @@ -42,7 +42,7 @@ class OAuthForm(forms.Form): """ def __init__(self, *args, **kwargs): self.client = kwargs.pop('client', None) - super(OAuthForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _clean_fields(self): """ @@ -50,7 +50,7 @@ def _clean_fields(self): instead of validating each field. """ try: - super(OAuthForm, self)._clean_fields() + super()._clean_fields() except OAuthValidationError as e: self._errors.update(e.args[0]) @@ -59,6 +59,6 @@ def _clean_form(self): Overriding the default cleaning behaviour for a shallow error dict. """ try: - super(OAuthForm, self)._clean_form() + super()._clean_form() except OAuthValidationError as e: self._errors.update(e.args[0]) diff --git a/provider/oauth2/admin.py b/provider/oauth2/admin.py index 00b1c58..b0fde95 100644 --- a/provider/oauth2/admin.py +++ b/provider/oauth2/admin.py @@ -1,9 +1,18 @@ from django.contrib import admin +from django.contrib import messages +from django.utils.safestring import mark_safe + from provider.oauth2 import models +from provider.oauth2.forms import ClientSecretAdminCreateForm class AccessTokenAdmin(admin.ModelAdmin): - list_display = ('user', 'client', 'token', 'expires',) + list_display = ('user', 'client', 'token_prefix', 'expires',) + raw_id_fields = ('user',) + + +class RefreshTokenAdmin(admin.ModelAdmin): + list_display = ('__str__', 'access_token', 'user', 'client', 'expired') raw_id_fields = ('user',) @@ -13,11 +22,30 @@ class GrantAdmin(admin.ModelAdmin): class ClientAdmin(admin.ModelAdmin): - list_display = ('url', 'user', 'redirect_uri', 'client_id', + list_display = ('name', 'user', 'redirect_uri', 'client_id', 'client_type', 'auto_authorize') raw_id_fields = ('user',) +class ClientSecretAdmin(admin.ModelAdmin): + list_display = ('client_name', 'client_id', 'secret_prefix', 'description', 'expiration_date') + + def get_form(self, request, obj=None, change=None, **kwargs): + kwargs["form"] = ClientSecretAdminCreateForm + return super().get_form(request, obj=obj, change=change, **kwargs) + + def save_form(self, request, form, change): + if form.plain_client_secret: + messages.info(request, mark_safe(f"New client secret created for client {form.instance.client.client_id}: {form.plain_client_secret}
This will not be shown again.")) + return super().save_form(request, form, change) + + def client_id(self, obj): + return obj.client.client_id + + def client_name(self, obj): + return obj.client.name + + class AuthorizedClientAdmin(admin.ModelAdmin): list_display = ('user', 'client', 'authorized_at') raw_id_fields = ('user',) @@ -31,7 +59,8 @@ class AwsAccountAdmin(admin.ModelAdmin): admin.site.register(models.AccessToken, AccessTokenAdmin) admin.site.register(models.Grant, GrantAdmin) admin.site.register(models.Client, ClientAdmin) +admin.site.register(models.ClientSecret, ClientSecretAdmin) admin.site.register(models.AuthorizedClient, AuthorizedClientAdmin) admin.site.register(models.AwsAccount, AwsAccountAdmin) -admin.site.register(models.RefreshToken) +admin.site.register(models.RefreshToken, RefreshTokenAdmin) admin.site.register(models.Scope) diff --git a/provider/oauth2/apps.py b/provider/oauth2/apps.py index 73b1ae7..43b31fa 100644 --- a/provider/oauth2/apps.py +++ b/provider/oauth2/apps.py @@ -4,6 +4,7 @@ class Oauth2(AppConfig): name = 'provider.oauth2' label = 'oauth2' verbose_name = "Provider Oauth2" + default_auto_field = "django.db.models.AutoField" def ready(self): import provider.oauth2.signals diff --git a/provider/oauth2/fixtures/test_oauth2.json b/provider/oauth2/fixtures/test_oauth2.json index 54abb86..fab5cd9 100644 --- a/provider/oauth2/fixtures/test_oauth2.json +++ b/provider/oauth2/fixtures/test_oauth2.json @@ -80,7 +80,7 @@ "is_superuser": true, "last_login": "2012-01-23 05:52:32", "last_name": "", - "password": "sha1$da29e$498b9faab2d002183bc1d874689634b0e15ad6d7", + "password": "pbkdf2_sha256$600000$X9VBl26TIACb4rC7RYDXVa$/MgXp49qusu4+nPJ6BAMf0ti8iR3dLt4DnAchmA5FUo=", "user_permissions": [], "username": "test-user-1" }, @@ -98,7 +98,7 @@ "is_superuser": false, "last_login": "2012-01-23 05:53:31", "last_name": "", - "password": "sha1$0cf1b$d66589690edd96b410170fcae5cc2bdfb68821e7", + "password": "pbkdf2_sha256$600000$X9VBl26TIACb4rC7RYDXVa$/MgXp49qusu4+nPJ6BAMf0ti8iR3dLt4DnAchmA5FUo=", "user_permissions": [], "username": "test-user-2" }, @@ -116,7 +116,7 @@ "is_superuser": false, "last_login": "2012-01-23 05:53:31", "last_name": "", - "password": "sha1$0cf1b$d66589690edd96b410170fcae5cc2bdfb68821e7", + "password": "pbkdf2_sha256$600000$X9VBl26TIACb4rC7RYDXVa$/MgXp49qusu4+nPJ6BAMf0ti8iR3dLt4DnAchmA5FUo=", "user_permissions": [], "username": "test-user-aws" }, diff --git a/provider/oauth2/forms.py b/provider/oauth2/forms.py index 5a82a98..e07aeff 100644 --- a/provider/oauth2/forms.py +++ b/provider/oauth2/forms.py @@ -11,8 +11,8 @@ from django.utils import timezone from provider.constants import RESPONSE_TYPE_CHOICES, PKCE, PUBLIC from provider.forms import OAuthForm, OAuthValidationError -from provider.utils import now, ArnHelper -from provider.oauth2.models import Client, Grant, RefreshToken, Scope +from provider.utils import now, ArnHelper, make_client_secret +from provider.oauth2.models import Client, Grant, RefreshToken, Scope, ClientSecret log = logging.getLogger('provider.oauth2') @@ -42,13 +42,26 @@ class ClientAuthForm(forms.Form): client_secret = forms.CharField() def clean(self): + data = self.cleaned_data + client = None try: - client = Client.objects.get(client_id=data.get('client_id'), - client_secret=data.get('client_secret')) + client = Client.objects.get( + client_id=data.get('client_id'), + client_secret=data.get('client_secret'), + ) except Client.DoesNotExist: - raise forms.ValidationError(_("Client could not be validated with " - "key pair.")) + try: + client_secret = ClientSecret.objects.get_by_secret( + data.get('client_id'), + data.get('client_secret'), + ) + client = client_secret.client + except ClientSecret.DoesNotExist: + pass + + if not client: + raise forms.ValidationError(_("Client could not be validated with key pair.")) data['client'] = client return data @@ -247,8 +260,8 @@ def clean_refresh_token(self): raise OAuthValidationError({'error': 'invalid_request'}) try: - token = RefreshToken.objects.get(token=token, - expired=False, client=self.client) + token = RefreshToken.objects.get_token(token=token, + expired=False, client=self.client) except RefreshToken.DoesNotExist: raise OAuthValidationError({'error': 'invalid_grant'}) @@ -462,3 +475,19 @@ def clean(self): data['client'] = client data['grant'] = grant return data + + +class ClientSecretAdminCreateForm(forms.ModelForm): + class Meta: + model = ClientSecret + fields = ['description', 'client', 'expiration_date'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.plain_client_secret = None + if not self.instance.secret_prefix or not self.instance.secret_hash: + new_secret, prefix, secret_hash = make_client_secret() + self.plain_client_secret = new_secret + self.instance.secret_prefix = prefix + self.instance.secret_hash = secret_hash diff --git a/provider/oauth2/management/__init__.py b/provider/oauth2/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/provider/oauth2/management/commands/__init__.py b/provider/oauth2/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/provider/oauth2/management/commands/oauth2_migrate_hashed_tokens.py b/provider/oauth2/management/commands/oauth2_migrate_hashed_tokens.py new file mode 100644 index 0000000..faf04b9 --- /dev/null +++ b/provider/oauth2/management/commands/oauth2_migrate_hashed_tokens.py @@ -0,0 +1,37 @@ +from django.core.management import BaseCommand +from django.contrib.auth.hashers import make_password + +from provider.oauth2.models import AccessToken, RefreshToken +from provider.constants import TOKEN_PREFIX_LENGTH + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--save', action='store_true', default=False, + help="Save changes (defaults to showing what would be migrated otherwise)") + + def handle(self, *args, **options): + if not options.get('save'): + self.stderr.write("Token migration changes will be shown, but not saved. Run with --save to actually migrate.") + at_qs = AccessToken.objects.filter(token_prefix__isnull=True) + self.stdout.write(f"Found {at_qs.count()} AccessTokens to migrate.") + for access_token in at_qs: + prefix = access_token.token[:TOKEN_PREFIX_LENGTH] + hashed = make_password(access_token.token) + self.stdout.write(f" Converting AccessToken {prefix}: {hashed}\n") + access_token.token_prefix = prefix + access_token.token = hashed + if options.get('save'): + access_token.save() + + + rt_qs = RefreshToken.objects.filter(token_prefix__isnull=True, expired=False) + self.stdout.write(f"Found {rt_qs.count()} RefreshTokens to migrate.") + for refresh_token in rt_qs: + prefix = refresh_token.token[:TOKEN_PREFIX_LENGTH] + hashed = make_password(refresh_token.token) + self.stdout.write(f" Converting AccessToken {prefix}: {hashed}\n") + refresh_token.token_prefix = prefix + refresh_token.token = hashed + if options.get('save'): + refresh_token.save() diff --git a/provider/oauth2/migrations/0006_clientsecrets.py b/provider/oauth2/migrations/0006_clientsecrets.py new file mode 100644 index 0000000..2e71c04 --- /dev/null +++ b/provider/oauth2/migrations/0006_clientsecrets.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2 on 2025-08-17 03:13 + +from django.db import migrations, models +import django.db.models.deletion +import provider.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2', '0005_pkce'), + ] + + operations = [ + migrations.CreateModel( + name='ClientSecret', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(default=provider.utils.client_secret_description, max_length=255)), + ('secret_prefix', models.CharField(db_index=True, max_length=6)), + ('secret_hash', models.CharField(max_length=255)), + ('expiration_date', models.DateField(blank=True, null=True)), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oauth2.client')), + ], + options={ + 'db_table': 'oauth2_clientsecret', + }, + ), + ] diff --git a/provider/oauth2/migrations/0007_secret_tokens.py b/provider/oauth2/migrations/0007_secret_tokens.py new file mode 100644 index 0000000..ebb4497 --- /dev/null +++ b/provider/oauth2/migrations/0007_secret_tokens.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2 on 2025-08-20 21:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2', '0006_clientsecrets'), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='token_prefix', + field=models.CharField(db_index=True, max_length=10, null=True, blank=True), + ), + migrations.AddField( + model_name='refreshtoken', + name='token_prefix', + field=models.CharField(db_index=True, max_length=10, null=True, blank=True), + ), + ] diff --git a/provider/oauth2/models.py b/provider/oauth2/models.py index 7d4ffc1..d45ded0 100644 --- a/provider/oauth2/models.py +++ b/provider/oauth2/models.py @@ -3,20 +3,23 @@ implement these models with fields and and methods to be compatible with the views in :attr:`provider.views`. """ - from base64 import urlsafe_b64encode from hashlib import sha256 from django.db import models +from django.db.models import Q from django.conf import settings from django.contrib.auth import get_user_model -from provider import constants -from provider.constants import CLIENT_TYPES -from provider.utils import now, short_token, long_token, get_code_expiry -from provider.utils import get_token_expiry - +from django.contrib.auth.hashers import check_password from django.utils import timezone +from provider import constants +from provider.constants import CLIENT_TYPES, TOKEN_PREFIX_LENGTH +from provider.utils import ( + now, short_token, long_token, get_code_expiry, client_secret_description, + get_token_expiry, make_client_secret, +) + class Client(models.Model): """ @@ -63,6 +66,55 @@ class Meta: db_table = 'oauth2_client' +class ClientSecretManager(models.Manager): + def new_random_secret(self, client, expiration_date=None): + new_secret, prefix, secret_hash = make_client_secret() + client_secret = self.create( + client=client, + secret_prefix=prefix, + secret_hash=secret_hash, + expiration_date=expiration_date, + ) + return client_secret, new_secret + + + def get_by_secret(self, client, secret): + if not secret or not client: + return self.none().get() + + if isinstance(client, Client): + client_pk=client.pk + else: + client_pk=client + + prefix = secret[:6] + selector = Q(client__client_id=client_pk, secret_prefix=prefix) + not_expired = Q(expiration_date__isnull=True) | Q(expiration_date__gte=now().date()) + for record in self.filter(selector & not_expired): + if check_password(secret, record.secret_hash): + return record + + # Get from an empty queryset to raise a DoesNotExist exception + return self.none().get() + + +class ClientSecret(models.Model): + description = models.CharField(max_length=255, default=client_secret_description) + client = models.ForeignKey('Client', models.CASCADE) + secret_prefix = models.CharField(max_length=6, db_index=True) + secret_hash = models.CharField(max_length=255) + expiration_date = models.DateField(null=True, blank=True) + + objects = ClientSecretManager() + + class Meta: + app_label = 'oauth2' + db_table = 'oauth2_clientsecret' + + def __str__(self): + return f"{self.client.client_id}: {self.secret_prefix}..." + + class Scope(models.Model): name = models.CharField(max_length=50, primary_key=True) description = models.CharField(max_length=256, default='', blank=True) @@ -160,24 +212,12 @@ def verify_code_challenge(self, code_verifier): class AccessTokenManager(models.Manager): def get_token(self, token): - return self.get(token=token, expires__gt=now()) + filters = dict(expires__gt=now()) + for candidate in self.filter(token_prefix=token[:TOKEN_PREFIX_LENGTH], **filters): + if check_password(token, candidate.token): + return self.get(pk=candidate.pk) - def get_scoped_token(self, user, client, scope): - obj = self.get(user=user, client=client, expires__gt=now()) - obj_scopes = {s.name for s in obj.scope.all()} - req_scopes = {s.name for s in scope} - if set(req_scopes).issubset(obj_scopes): - return obj - raise AccessToken.DoesNotExist - - def create(self, scope=None, *args, **kwargs): - obj = super(AccessTokenManager, self).create(*args, **kwargs) - obj.save() - if not scope: - scope = list() - for s in scope: - obj.scope.add(s) - return obj + return self.none().get() class AccessToken(models.Model): @@ -201,6 +241,7 @@ class AccessToken(models.Model): expiry """ user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING) + token_prefix = models.CharField(max_length=10, null=True, blank=True, db_index=True) token = models.CharField(max_length=255, default=long_token, db_index=True) client = models.ForeignKey('Client', models.DO_NOTHING) expires = models.DateTimeField() @@ -209,7 +250,7 @@ class AccessToken(models.Model): objects = AccessTokenManager() def __str__(self): - return self.token + return f"{self.token_prefix}:{self.token[:25]}" def save(self, *args, **kwargs): if not self.expires: @@ -255,6 +296,12 @@ def create(self, scope=None, *args, **kwargs): obj.scope.add(s) return obj + def get_token(self, token, **filters): + for candidate in self.filter(token_prefix=token[:TOKEN_PREFIX_LENGTH], **filters): + if check_password(token, candidate.token): + return self.get(pk=candidate.pk) + return self.none().get() + class RefreshToken(models.Model): """ @@ -270,6 +317,7 @@ class RefreshToken(models.Model): * :attr:`expired` - ``boolean`` """ user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING) + token_prefix = models.CharField(max_length=10, null=True, blank=True, db_index=True) token = models.CharField(max_length=255, default=long_token) access_token = models.OneToOneField('AccessToken', models.DO_NOTHING, related_name='refresh_token') @@ -279,7 +327,7 @@ class RefreshToken(models.Model): objects = RefreshTokenManager() def __str__(self): - return self.token + return f"{self.token_prefix}:{self.token[:25]}" class Meta: app_label = 'oauth2' diff --git a/provider/oauth2/tests/test_views.py b/provider/oauth2/tests/test_views.py index 9d968ae..f2b76ae 100644 --- a/provider/oauth2/tests/test_views.py +++ b/provider/oauth2/tests/test_views.py @@ -8,14 +8,16 @@ from django.conf import settings from django.shortcuts import reverse from django.utils.html import escape +from django.utils import timezone from django.test import TestCase from django.contrib.auth.models import User + from provider import constants, scope from provider.compat import skipIfCustomUser from provider.templatetags.scope import scopes from provider.utils import now as date_now from provider.oauth2.forms import ClientForm -from provider.oauth2.models import Client, Grant, AccessToken, RefreshToken, AuthorizedClient, AwsAccount +from provider.oauth2.models import Client, Grant, AccessToken, RefreshToken, AuthorizedClient, AwsAccount, ClientSecret from provider.oauth2.backends import BasicClientBackend, RequestParamsClientBackend from provider.oauth2.backends import AccessTokenBackend @@ -755,6 +757,69 @@ def test_basic_client_backend(self): self.assertEqual(BasicClientBackend().authenticate(request).id, 2, "Didn't return the right client.") + def test_basic_client_backend_empty_secret(self): + request = type('Request', (object,), {'META': {}})() + client = self.get_client() + client.client_secret = "" + client.save() + + user_pass = "{0}:{1}".format( + self.get_client().client_id, + "" + ) + user_pass64 = base64.b64encode(user_pass.encode('utf8')).decode('utf8') + request.META['HTTP_AUTHORIZATION'] = "Basic {}".format(user_pass64) + + self.assertIsNone(BasicClientBackend().authenticate(request), + "Client should not have loaded.") + + def test_basic_client_backend_secure_secret_no_expire(self): + client = self.get_client() + client_secret, secret_string = ClientSecret.objects.new_random_secret(client) + + request = type('Request', (object,), {'META': {}})() + user_pass = "{0}:{1}".format( + self.get_client().client_id, + secret_string, + ) + user_pass64 = base64.b64encode(user_pass.encode('utf8')).decode('utf8') + request.META['HTTP_AUTHORIZATION'] = "Basic {}".format(user_pass64) + + self.assertEqual(BasicClientBackend().authenticate(request).id, + 2, "Didn't return the right client.") + + def test_basic_client_backend_secure_secret_expire_future(self): + next_month = timezone.now() + datetime.timedelta(days=30) + client = self.get_client() + client_secret, secret_string = ClientSecret.objects.new_random_secret(client, expiration_date=next_month.date()) + + request = type('Request', (object,), {'META': {}})() + user_pass = "{0}:{1}".format( + self.get_client().client_id, + secret_string, + ) + user_pass64 = base64.b64encode(user_pass.encode('utf8')).decode('utf8') + request.META['HTTP_AUTHORIZATION'] = "Basic {}".format(user_pass64) + + self.assertEqual(BasicClientBackend().authenticate(request).id, + 2, "Didn't return the right client.") + + def test_basic_client_backend_secure_secret_expire_past(self): + last_month = timezone.now() - datetime.timedelta(days=30) + client = self.get_client() + client_secret, secret_string = ClientSecret.objects.new_random_secret(client, expiration_date=last_month.date()) + + request = type('Request', (object,), {'META': {}})() + user_pass = "{0}:{1}".format( + self.get_client().client_id, + secret_string, + ) + user_pass64 = base64.b64encode(user_pass.encode('utf8')).decode('utf8') + request.META['HTTP_AUTHORIZATION'] = "Basic {}".format(user_pass64) + + self.assertIsNone(BasicClientBackend().authenticate(request), + "Client should not have loaded.") + def test_request_params_client_backend(self): request = type('Request', (object,), {'REQUEST': {}})() @@ -892,10 +957,8 @@ def test_clear_expired(self): # make sure the grant is gone self.assertFalse(Grant.objects.filter(code=code).exists()) # and verify that the AccessToken and RefreshToken exist - self.assertTrue(AccessToken.objects.filter(token=access_token) - .exists()) - self.assertTrue(RefreshToken.objects.filter(token=refresh_token) - .exists()) + self.assertIsInstance(AccessToken.objects.get_token(token=access_token), AccessToken) + self.assertIsInstance(RefreshToken.objects.get_token(token=refresh_token), RefreshToken) # refresh the token response = self.client.post(self.access_token_url(), { diff --git a/provider/oauth2/views.py b/provider/oauth2/views.py index ed5e028..4fb2ae2 100644 --- a/provider/oauth2/views.py +++ b/provider/oauth2/views.py @@ -3,15 +3,15 @@ from datetime import timedelta from urllib.parse import urlparse, ParseResult +from django.contrib.auth.hashers import make_password from django.http import HttpResponse from django.http import HttpResponseRedirect, QueryDict from django.shortcuts import reverse from django.views.generic import TemplateView, View -from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext as _ from provider import constants -from provider.utils import now, ArnHelper +from provider.utils import now, TokenContainer, long_token from provider.oauth2 import forms from provider.oauth2 import models from provider.oauth2 import backends @@ -423,32 +423,32 @@ def get_aws_grant(self, request, data, _client): data['awsaccount'] = account return data - def get_access_token(self, request, user, scope, client): - try: - # Attempt to fetch an existing access token. - at = models.AccessToken.objects.get_scoped_token(user, client, scope) - except models.AccessToken.DoesNotExist: - # None found... make a new one! - at = self.create_access_token(request, user, scope, client) - if client.client_type != constants.PUBLIC: - self.create_refresh_token(request, user, scope, at, client) - return at - def create_access_token(self, request, user, scope, client): + access_secret = long_token() + access_token = make_password(access_secret) at = models.AccessToken.objects.create( user=user, client=client, + token=access_token, + token_prefix=access_secret[:constants.TOKEN_PREFIX_LENGTH], ) for s in scope: at.scope.add(s) - return at + return TokenContainer(access_secret, at) def create_refresh_token(self, request, user, scope, access_token, client): - return models.RefreshToken.objects.create( + refresh_secret = long_token() + refresh_token = make_password(refresh_secret) + + rt = models.RefreshToken.objects.create( user=user, - access_token=access_token, + access_token=access_token.access_token, client=client, + token=refresh_token, + token_prefix=refresh_secret[:constants.TOKEN_PREFIX_LENGTH], ) + access_token.add_refresh_token(refresh_secret, rt) + return rt def invalidate_grant(self, grant): if constants.DELETE_EXPIRED: @@ -485,21 +485,17 @@ def access_token_response(self, access_token): Returns a successful response after creating the access token as defined in :rfc:`5.1`. """ - response_data = { - 'access_token': access_token.token, + 'access_token': access_token.access_token_secret, 'token_type': constants.TOKEN_TYPE, - 'expires_in': access_token.get_expire_delta(), - 'scope': access_token.get_scope_string(), + 'expires_in': access_token.access_token.get_expire_delta(), + 'scope': access_token.access_token.get_scope_string(), } # Not all access_tokens are given a refresh_token # (for example, public clients doing password auth) - try: - rt = access_token.refresh_token - response_data['refresh_token'] = rt.token - except ObjectDoesNotExist: - pass + if access_token.refresh_token_secret: + response_data['refresh_token'] = access_token.refresh_token_secret return HttpResponse( json.dumps(response_data), content_type='application/json' @@ -543,8 +539,8 @@ def refresh_token(self, request, data, client): at = self.create_access_token(request, rt.user, token_scope, client) - rt = self.create_refresh_token(request, at.user, - at.scope.all(), at, client) + rt = self.create_refresh_token(request, at.access_token.user, + at.access_token.scope.all(), at, client) return self.access_token_response(at) @@ -570,8 +566,8 @@ def aws_identity(self, request, data, client): scope = list(account.scope.all()) at = self.create_access_token(request, account.get_or_create_user(), scope, account.client) - at.expires = now() + timedelta(seconds=account.max_token_lifetime) - at.save() + at.access_token.expires = now() + timedelta(seconds=account.max_token_lifetime) + at.access_token.save() return self.access_token_response(at) def get_handler(self, grant_type): diff --git a/provider/utils.py b/provider/utils.py index 3e5011b..e95f122 100644 --- a/provider/utils.py +++ b/provider/utils.py @@ -1,7 +1,9 @@ import hashlib import shortuuid from django.conf import settings -from provider.constants import EXPIRE_DELTA, EXPIRE_DELTA_PUBLIC, EXPIRE_CODE_DELTA +from django.contrib.auth.hashers import make_password + +from provider.constants import EXPIRE_DELTA, EXPIRE_DELTA_PUBLIC, EXPIRE_CODE_DELTA, TOKEN_PREFIX_LENGTH from django.utils import timezone @@ -50,6 +52,16 @@ def get_code_expiry(): return now() + EXPIRE_CODE_DELTA +def client_secret_description(): + return f"Secret created {now().date()}" + + +def make_client_secret(): + new_secret = long_token() + secret_hash = make_password(new_secret) + return new_secret, new_secret[:6], secret_hash + + class BadArn(Exception): pass @@ -83,3 +95,15 @@ def __eq__(self, other): return True return False + + +class TokenContainer: + def __init__(self, at_secret, at): + self.access_token = at + self.access_token_secret = at_secret + self.refresh_token = None + self.refresh_token_secret = None + + def add_refresh_token(self, rt_secret, rt): + self.refresh_token = rt + self.refresh_token_secret = rt_secret diff --git a/tests/settings.py b/tests/settings.py index fa85b1f..37dab69 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -94,8 +94,7 @@ PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.SHA1PasswordHasher', # Used by unit tests + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', # Used by unit tests ] USE_TZ = True diff --git a/tox.ini b/tox.ini index 973659f..06db7af 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] toxworkdir={env:TOX_WORK_DIR:.tox} downloadcache = {toxworkdir}/cache/ -envlist = py{3.10,3.12}-django{3.2,4.0,4.1,4.2,5.0,5.1} +envlist = py{311,312,313}-django{32,40,41,42,50,51} [testenv] setenv = @@ -9,38 +9,10 @@ setenv = commands = {toxinidir}/test.sh deps = - -[testenv:py3.12-django3.1] -basepython = python3.12 -deps = Django>=3.1,<3.2 - {[testenv]deps} - -[testenv:py3.12-django3.2] -basepython = python3.12 -deps = Django>=3.2,<4.0 - {[testenv]deps} - -[testenv:py3.12-django4.0] -basepython = python3.12 -deps = Django>=4.0,<4.1 - {[testenv]deps} - -[testenv:py3.12-django4.1] -basepython = python3.12 -deps = Django>=4.1,<4.2 - {[testenv]deps} - -[testenv:py3.12-django4.2] -basepython = python3.12 -deps = Django>=4.2,<5.0 - {[testenv]deps} - -[testenv:py3.12-django5.0] -basepython = python3.12 -deps = Django~=5.0 - {[testenv]deps} - -[testenv:py3.12-django5.1] -basepython = python3.12 -deps = Django~=5.1 - {[testenv]deps} + django32: Django>=3.2,<4.0 + django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 + django42: Django>=4.2,<5.0 + django50: Django~=5.0 + django51: Django~=5.1 + django52: Django~=5.2