From 206ef80919208d4997fe331e5d983b53eaf8d456 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Sun, 11 Feb 2024 15:40:36 -0500 Subject: [PATCH 01/46] Add oauth2 provider application --- .../lib/dynamic_config/dynamic_settings.py | 32 ++ ansible_base/oauth2_provider/__init__.py | 0 ansible_base/oauth2_provider/admin.py | 3 + ansible_base/oauth2_provider/apps.py | 7 + .../oauth2_provider/authentication.py | 20 ++ .../migrations/0001_initial.py | 276 ++++++++++++++++++ .../oauth2_provider/migrations/__init__.py | 0 .../oauth2_provider/models/__init__.py | 60 ++++ .../oauth2_provider/models/access_token.py | 73 +++++ .../oauth2_provider/models/application.py | 76 +++++ .../oauth2_provider/models/id_token.py | 10 + .../oauth2_provider/models/refresh_token.py | 11 + .../oauth2_provider/serializers/__init__.py | 2 + .../serializers/application.py | 75 +++++ .../oauth2_provider/serializers/token.py | 111 +++++++ ansible_base/oauth2_provider/urls.py | 41 +++ ansible_base/oauth2_provider/utils.py | 19 ++ .../oauth2_provider/views/__init__.py | 3 + .../oauth2_provider/views/application.py | 12 + .../views/authorization_root.py | 22 ++ ansible_base/oauth2_provider/views/token.py | 39 +++ pyproject.toml | 4 +- requirements/requirements_all.txt | 10 + requirements/requirements_oauth2_provider.in | 1 + test_app/settings.py | 1 + 25 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 ansible_base/oauth2_provider/__init__.py create mode 100644 ansible_base/oauth2_provider/admin.py create mode 100644 ansible_base/oauth2_provider/apps.py create mode 100644 ansible_base/oauth2_provider/authentication.py create mode 100644 ansible_base/oauth2_provider/migrations/0001_initial.py create mode 100644 ansible_base/oauth2_provider/migrations/__init__.py create mode 100644 ansible_base/oauth2_provider/models/__init__.py create mode 100644 ansible_base/oauth2_provider/models/access_token.py create mode 100644 ansible_base/oauth2_provider/models/application.py create mode 100644 ansible_base/oauth2_provider/models/id_token.py create mode 100644 ansible_base/oauth2_provider/models/refresh_token.py create mode 100644 ansible_base/oauth2_provider/serializers/__init__.py create mode 100644 ansible_base/oauth2_provider/serializers/application.py create mode 100644 ansible_base/oauth2_provider/serializers/token.py create mode 100644 ansible_base/oauth2_provider/urls.py create mode 100644 ansible_base/oauth2_provider/utils.py create mode 100644 ansible_base/oauth2_provider/views/__init__.py create mode 100644 ansible_base/oauth2_provider/views/application.py create mode 100644 ansible_base/oauth2_provider/views/authorization_root.py create mode 100644 ansible_base/oauth2_provider/views/token.py create mode 100644 requirements/requirements_oauth2_provider.in diff --git a/ansible_base/lib/dynamic_config/dynamic_settings.py b/ansible_base/lib/dynamic_config/dynamic_settings.py index fa7d893a2..a709244c2 100644 --- a/ansible_base/lib/dynamic_config/dynamic_settings.py +++ b/ansible_base/lib/dynamic_config/dynamic_settings.py @@ -168,3 +168,35 @@ ORG_ADMINS_CAN_SEE_ALL_USERS except NameError: ORG_ADMINS_CAN_SEE_ALL_USERS = True + + +if 'ansible_base.oauth2_provider' in INSTALLED_APPS: # noqa: F821 + if 'oauth2_provider' not in INSTALLED_APPS: # noqa: F821 + INSTALLED_APPS.append('oauth2_provider') # noqa: F821 + + try: + OAUTH2_PROVIDER # noqa: F821 + except NameError: + OAUTH2_PROVIDER = {} + + if 'ACCESS_TOKEN_EXPIRE_SECONDS' not in OAUTH2_PROVIDER: + OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] = 31536000000 + if 'AUTHORIZATION_CODE_EXPIRE_SECONDS' not in OAUTH2_PROVIDER: + OAUTH2_PROVIDER['AUTHORIZATION_CODE_EXPIRE_SECONDS'] = 600 + if 'REFRESH_TOKEN_EXPIRE_SECONDS' not in OAUTH2_PROVIDER: + OAUTH2_PROVIDER['REFRESH_TOKEN_EXPIRE_SECONDS'] = 2628000 + + OAUTH2_PROVIDER['APPLICATION_MODEL'] = 'dab_oauth2_provider.OAuth2Application' + OAUTH2_PROVIDER['ACCESS_TOKEN_MODEL'] = 'dab_oauth2_provider.OAuth2AccessToken' + + oauth2_authentication_class = 'ansible_base.oauth2_provider.authentication.LoggedOAuth2Authentication' + if 'DEFAULT_AUTHENTICATION_CLASSES' not in REST_FRAMEWORK: # noqa: F821 + REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [] # noqa: F821 + if oauth2_authentication_class not in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']: # noqa: F821 + REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'].insert(0, oauth2_authentication_class) # noqa: F821 + + # These have to be defined for the migration to function + OAUTH2_PROVIDER_APPLICATION_MODEL = 'dab_oauth2_provider.OAuth2Application' + OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'dab_oauth2_provider.OAuth2AccessToken' + OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "dab_oauth2_provider.OAuth2RefreshToken" + OAUTH2_PROVIDER_ID_TOKEN_MODEL = "dab_oauth2_provider.OAuth2IDToken" diff --git a/ansible_base/oauth2_provider/__init__.py b/ansible_base/oauth2_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/oauth2_provider/admin.py b/ansible_base/oauth2_provider/admin.py new file mode 100644 index 000000000..4fd549025 --- /dev/null +++ b/ansible_base/oauth2_provider/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin # noqa: F401 + +# Register your models here. diff --git a/ansible_base/oauth2_provider/apps.py b/ansible_base/oauth2_provider/apps.py new file mode 100644 index 000000000..9b549e4c3 --- /dev/null +++ b/ansible_base/oauth2_provider/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class Oauth2ProviderConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ansible_base.oauth2_provider' + label = 'dab_oauth2_provider' diff --git a/ansible_base/oauth2_provider/authentication.py b/ansible_base/oauth2_provider/authentication.py new file mode 100644 index 000000000..a1879177e --- /dev/null +++ b/ansible_base/oauth2_provider/authentication.py @@ -0,0 +1,20 @@ +import logging + +from django.utils.encoding import smart_str +from oauth2_provider.contrib.rest_framework import OAuth2Authentication + +logger = logging.getLogger('ansible_base.oauth2_provider.authentication') + + +class LoggedOAuth2Authentication(OAuth2Authentication): + def authenticate(self, request): + ret = super().authenticate(request) + if ret: + user, token = ret + username = user.username if user else '' + logger.info( + smart_str(u"User {} performed a {} to {} through the API using OAuth 2 token {}.".format(username, request.method, request.path, token.pk)) + ) + # TODO: check oauth_scopes when we have RBAC in Gateway + setattr(user, 'oauth_scopes', [x for x in token.scope.split() if x]) + return ret diff --git a/ansible_base/oauth2_provider/migrations/0001_initial.py b/ansible_base/oauth2_provider/migrations/0001_initial.py new file mode 100644 index 000000000..7ead77097 --- /dev/null +++ b/ansible_base/oauth2_provider/migrations/0001_initial.py @@ -0,0 +1,276 @@ +# Generated by Django 4.2.8 on 2024-02-11 20:16 + +import re +import uuid + +import django.core.validators +import django.db.models.deletion +import oauth2_provider.generators +from django.conf import settings +from django.db import migrations, models + +import ansible_base.oauth2_provider.models.application + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.ANSIBLE_BASE_ORGANIZATION_MODEL), + ] + + run_before = [ + ('oauth2_provider', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='OAuth2Application', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('name', models.CharField(help_text='The name of this resource', max_length=512)), + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('description', models.TextField(blank=True, default='')), + ('logo_data', models.TextField(default='', editable=False, validators=[django.core.validators.RegexValidator(re.compile('.*'))])), + ('client_secret', ansible_base.oauth2_provider.models.application.OAuth2ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Used for more stringent verification of access to an application when creating a token.', max_length=1024)), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], help_text='Set to Public or Confidential depending on how secure the client device is.', max_length=32)), + ('skip_authorization', models.BooleanField(default=False, help_text='Set True to skip authorization step for completely trusted applications.')), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('password', 'Resource owner password-based')], help_text='The Grant type the user must use for acquire tokens for this application.', max_length=32)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.ANSIBLE_BASE_ORGANIZATION_MODEL)), + ], + options={ + 'verbose_name': 'application', + 'ordering': ('organization', 'name'), + 'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL', + 'unique_together': {('name', 'organization')}, + }, + ), + migrations.CreateModel( + name='OAuth2IDToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'id token', + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + migrations.CreateModel( + name='OAuth2RefreshToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'access token', + 'ordering': ('id',), + 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', + }, + ), + migrations.CreateModel( + name='OAuth2AccessToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('description', models.TextField(blank=True, default='')), + ('last_used', models.DateTimeField(default=None, editable=False, null=True)), + ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'access token', + 'ordering': ('id',), + 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', + }, + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='expires', + field=models.DateTimeField(default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='token', + field=models.CharField(default='', max_length=255, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='oauth2application', + name='algorithm', + field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + migrations.AddField( + model_name='oauth2application', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2application', + name='post_logout_redirect_uris', + field=models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated'), + ), + migrations.AddField( + model_name='oauth2application', + name='redirect_uris', + field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), + ), + migrations.AddField( + model_name='oauth2application', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='oauth2application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='oauth2idtoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='oauth2idtoken', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2idtoken', + name='expires', + field=models.DateTimeField(default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2idtoken', + name='jti', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID'), + ), + migrations.AddField( + model_name='oauth2idtoken', + name='scope', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='oauth2idtoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='oauth2idtoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='access_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='application', + field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='revoked', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='token', + field=models.CharField(default='', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='oauth2refreshtoken', + name='user', + field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2application', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2application', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterUniqueTogether( + name='oauth2refreshtoken', + unique_together={('token', 'revoked')}, + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + ] diff --git a/ansible_base/oauth2_provider/migrations/__init__.py b/ansible_base/oauth2_provider/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/oauth2_provider/models/__init__.py b/ansible_base/oauth2_provider/models/__init__.py new file mode 100644 index 000000000..9864347a0 --- /dev/null +++ b/ansible_base/oauth2_provider/models/__init__.py @@ -0,0 +1,60 @@ +from .access_token import OAuth2AccessToken +from .application import OAuth2Application +from .id_token import OAuth2IDToken +from .refresh_token import OAuth2RefreshToken + +__all__ = ( + 'OAuth2AccessToken', + 'OAuth2Application', + 'OAuth2IDToken', + 'OAuth2RefreshToken', +) + +# +# There were a lot of problems making the initial migrations for this class +# See https://github.com/jazzband/django-oauth-toolkit/issues/634 which helped +# +# Here were my steps: +# 1. Make sure 'ansible_base.oauth2_provider' is in test_app INSTALLED_APPS (this already should be) +# 2. Comment out all OAUTH2_ settings in ansible_base/lib/dynamic_config/dynamic_settings.py which reference dab_oauth2_provider.* +# 3. Change all model classes to: +# remove oauth2_models.Abstract* as superclasses (including the meta ones) +# comment out the "import oauth2_provider.models as oauth2_models" imports +# 4. ./manage.py makemigrations dab_oauth2_provider +# 5. Edit the created 0001 migration and delete the dpendency on ('test_app', '000X') +# 6. ./manage.py migrate dab_oauth2_provider +# 7. Look at the generated migration, if this has a direct reference to your applications organization model in OAuth2Application model we need to update it +# for example, if it looks like: +# ('organization', ... to='.organization')), +# We want to change this to reference the setting: +# ('organization', ... to=settings.ANSIBLE_BASE_ORGANIZATION_MODEL)), +# We should also add this in the migration dependencies: +# migrations.swappable_dependency(settings.ANSIBLE_BASE_ORGANIZATION_MODEL), +# 8. Uncomment all OAUTH2_PROVIDER_* settings +# 9. Revert step 3 +# 10. gateway-manage makemigrations && gateway-manage migrate ansible_base +# When you do this django does not realize that you are creating an initial migration and tell you its impossible to migrate so fields +# It will ask you to either: 1. Enter a default 2. Quit +# Tell it to use the default if it has one populated at the prompt. Other wise use django.utils.timezone.now for timestamps and '' for other items +# This wont matter for us because there will be no data in the tables between these two migrations +# 11. You can now combine the migration into one. +# Add the `import uuid` to the top of the initial migration file +# Copy all of the operations from the second file to the first +# Find the AddFields commands for oauth2refreshtoken.access_token and oauth2accesstoken.source_refresh_token and move them to the end of the operations +# If desired, convert the remaining AddFilds into actual fields on the table creation. For example: +# migrations.AddField( +# model_name='oauth2accesstoken', +# name='created', +# field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), +# preserve_default=False, +# ), +# Would become the following field on the oauth2accesstoken table: +# ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)), +# Next put the table creation in the following order: OAuth2Application, OAuth2IDToken, OAuth2RefreshToken, OAuth2AccessToken +# Finally, be sure to add this to the migration file: +# run_before = [ +# ('oauth2_provider', '0001_initial'), +# ] +# 12. Delete the new migration +# 13. zero out the initial migration with: ./manage.py migrate dab_oauth2_provider zero +# 14. Make the actual migration with: ./manage.py migrate dab_oauth2_provider diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py new file mode 100644 index 000000000..ed0516985 --- /dev/null +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -0,0 +1,73 @@ +import oauth2_provider.models as oauth2_models +from django.conf import settings +from django.db import connection, models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from oauthlib import oauth2 + +from ansible_base.lib.abstract_models.common import CommonModel +from ansible_base.lib.utils.settings import get_setting +from ansible_base.oauth2_provider.utils import is_external_account + + +class OAuth2AccessToken(oauth2_models.AbstractAccessToken, CommonModel): + reverse_name_override = 'token' + # There is a special condition where, as the user is logging in we want to update the last_used field. + # However, this happens before the user is set for the request. + # If this is the only field attempting to be saved, don't update the modified on/by fields + not_user_modified_fields = ['last_used'] + + class Meta(oauth2_models.AbstractAccessToken.Meta): + verbose_name = _('access token') + ordering = ('id',) + swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + help_text=_('The user representing the token owner'), + ) + description = models.TextField( + default='', + blank=True, + ) + last_used = models.DateTimeField( + null=True, + default=None, + editable=False, + ) + scope = models.CharField( + blank=True, + default='write', + max_length=32, + choices=[('read', 'read'), ('write', 'write')], + help_text=_("Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."), + ) + + def is_valid(self, scopes=None): + valid = super(OAuth2AccessToken, self).is_valid(scopes) + if valid: + self.last_used = now() + + def _update_last_used(): + if OAuth2AccessToken.objects.filter(pk=self.pk).exists(): + self.save(update_fields=['last_used']) + + connection.on_commit(_update_last_used) + return valid + + def validate_external_users(self): + if self.user and get_setting('ALLOW_OAUTH2_FOR_EXTERNAL_USERS') is False: + external_account = is_external_account(self.user) + if external_account: + raise oauth2.AccessDeniedError( + _('OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})').format(external_account) + ) + + def save(self, *args, **kwargs): + if not self.pk: + self.validate_external_users() + super().save(*args, **kwargs) diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py new file mode 100644 index 000000000..2eb18d875 --- /dev/null +++ b/ansible_base/oauth2_provider/models/application.py @@ -0,0 +1,76 @@ +import re + +import oauth2_provider.models as oauth2_models +from django.conf import settings +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from oauth2_provider.generators import generate_client_id, generate_client_secret + +from ansible_base.lib.abstract_models.common import NamedCommonModel +from ansible_base.lib.utils.encryption import ansible_encryption + +DATA_URI_RE = re.compile(r'.*') # FIXME + + +class OAuth2ClientSecretField(models.CharField): + def get_db_prep_value(self, value, connection, prepared=False): + return super().get_db_prep_value(ansible_encryption.encrypt_string(value), connection, prepared) + + def from_db_value(self, value, expression, connection): + if value and value.startswith('$encrypted$'): + return ansible_encryption.decrypt_string(value) + return value + + +class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): + reverse_name_override = 'application' + + class Meta(oauth2_models.AbstractAccessToken.Meta): + verbose_name = _('application') + unique_together = (("name", "organization"),) + ordering = ('organization', 'name') + swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL" + + CLIENT_TYPES = ( + ("confidential", _("Confidential")), + ("public", _("Public")), + ) + + GRANT_TYPES = ( + ("authorization-code", _("Authorization code")), + ("password", _("Resource owner password-based")), + ) + + # Here we are going to overwrite this from the parent class so that we can change the default + client_id = models.CharField(db_index=True, default=generate_client_id, max_length=100, unique=True) + description = models.TextField( + default='', + blank=True, + ) + logo_data = models.TextField( + default='', + editable=False, + validators=[RegexValidator(DATA_URI_RE)], + ) + organization = models.ForeignKey( + getattr(settings, 'ANSIBLE_BASE_ORGANIZATION_MODEL'), + related_name='applications', + help_text=_('Organization containing this application.'), + on_delete=models.CASCADE, + null=True, + ) + client_secret = OAuth2ClientSecretField( + max_length=1024, + blank=True, + default=generate_client_secret, + db_index=True, + help_text=_('Used for more stringent verification of access to an application when creating a token.'), + ) + client_type = models.CharField( + max_length=32, choices=CLIENT_TYPES, help_text=_('Set to Public or Confidential depending on how secure the client device is.') + ) + skip_authorization = models.BooleanField(default=False, help_text=_('Set True to skip authorization step for completely trusted applications.')) + authorization_grant_type = models.CharField( + max_length=32, choices=GRANT_TYPES, help_text=_('The Grant type the user must use for acquire tokens for this application.') + ) diff --git a/ansible_base/oauth2_provider/models/id_token.py b/ansible_base/oauth2_provider/models/id_token.py new file mode 100644 index 000000000..80a3c7704 --- /dev/null +++ b/ansible_base/oauth2_provider/models/id_token.py @@ -0,0 +1,10 @@ +import oauth2_provider.models as oauth2_models +from django.utils.translation import gettext_lazy as _ + +from ansible_base.lib.abstract_models.common import CommonModel + + +class OAuth2IDToken(oauth2_models.AbstractIDToken, CommonModel): + class Meta(oauth2_models.AbstractIDToken.Meta): + verbose_name = _('id token') + swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" diff --git a/ansible_base/oauth2_provider/models/refresh_token.py b/ansible_base/oauth2_provider/models/refresh_token.py new file mode 100644 index 000000000..64c37202b --- /dev/null +++ b/ansible_base/oauth2_provider/models/refresh_token.py @@ -0,0 +1,11 @@ +import oauth2_provider.models as oauth2_models +from django.utils.translation import gettext_lazy as _ + +from ansible_base.lib.abstract_models.common import CommonModel + + +class OAuth2RefreshToken(oauth2_models.AbstractRefreshToken, CommonModel): + class Meta(oauth2_models.AbstractRefreshToken.Meta): + verbose_name = _('access token') + ordering = ('id',) + swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" diff --git a/ansible_base/oauth2_provider/serializers/__init__.py b/ansible_base/oauth2_provider/serializers/__init__.py new file mode 100644 index 000000000..cac44fafb --- /dev/null +++ b/ansible_base/oauth2_provider/serializers/__init__.py @@ -0,0 +1,2 @@ +from .application import OAuth2ApplicationSerializer # noqa: F401 +from .token import OAuth2TokenSerializer # noqa: F401 diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py new file mode 100644 index 000000000..57bc059a1 --- /dev/null +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -0,0 +1,75 @@ +from django.utils.translation import gettext_lazy as _ + +from ansible_base.lib.serializers.common import NamedCommonModelSerializer +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING +from ansible_base.oauth2_provider.models import OAuth2Application + + +def has_model_field_prefetched(obj, thing): + # from awx.main.utils import has_model_field_prefetched + pass + + +class OAuth2ApplicationSerializer(NamedCommonModelSerializer): + reverse_url_name = 'application-detail' + + class Meta: + model = OAuth2Application + fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in OAuth2Application._meta.concrete_fields] + read_only_fields = ('client_id', 'client_secret') + read_only_on_update_fields = ('user', 'authorization_grant_type') + extra_kwargs = { + 'user': {'allow_null': True, 'required': False}, + 'organization': {'allow_null': False}, + 'authorization_grant_type': {'allow_null': False, 'label': _('Authorization Grant Type')}, + 'client_secret': {'label': _('Client Secret')}, + 'client_type': {'label': _('Client Type')}, + 'redirect_uris': {'label': _('Redirect URIs')}, + 'skip_authorization': {'label': _('Skip Authorization')}, + } + + def to_representation(self, obj): + ret = super(OAuth2ApplicationSerializer, self).to_representation(obj) + request = self.context.get('request', None) + if not request or (request.method != 'POST' and obj.client_type == 'confidential'): + ret['client_secret'] = ENCRYPTED_STRING + if obj.client_type == 'public': + ret.pop('client_secret', None) + return ret + + def get_related(self, obj): + res = super(OAuth2ApplicationSerializer, self).get_related(obj) + res.update( + dict( + tokens=self.reverse('api:o_auth2_application_token_list', kwargs={'pk': obj.pk}), + activity_stream=self.reverse('api:o_auth2_application_activity_stream_list', kwargs={'pk': obj.pk}), + ) + ) + if obj.organization_id: + res.update( + dict( + organization=self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id}), + ) + ) + return res + + def get_modified(self, obj): + if obj is None: + return None + return obj.updated + + def _summary_field_tokens(self, obj): + token_list = [{'id': x.pk, 'token': ENCRYPTED_STRING, 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] + if has_model_field_prefetched(obj, 'oauth2accesstoken_set'): + token_count = len(obj.oauth2accesstoken_set.all()) + else: + if len(token_list) < 10: + token_count = len(token_list) + else: + token_count = obj.oauth2accesstoken_set.count() + return {'count': token_count, 'results': token_list} + + def get_summary_fields(self, obj): + ret = super(OAuth2ApplicationSerializer, self).get_summary_fields(obj) + ret['tokens'] = self._summary_field_tokens(obj) + return ret diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py new file mode 100644 index 000000000..034e152c6 --- /dev/null +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -0,0 +1,111 @@ +import logging +from datetime import timedelta + +from django.core.exceptions import ObjectDoesNotExist +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from oauthlib.common import generate_token +from oauthlib.oauth2 import AccessDeniedError +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.serializers import SerializerMethodField + +from ansible_base.lib.serializers.common import CommonModelSerializer +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING +from ansible_base.lib.utils.settings import get_setting +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken + +logger = logging.getLogger("ansible_base.serializers.oauth2_provider") + + +class BaseOAuth2TokenSerializer(CommonModelSerializer): + reverse_url_name = 'token-detail' + refresh_token = SerializerMethodField() + token = SerializerMethodField() + ALLOWED_SCOPES = ['read', 'write'] + + class Meta: + model = OAuth2AccessToken + fields = CommonModelSerializer.Meta.fields + [x.name for x in OAuth2AccessToken._meta.concrete_fields] + ['refresh_token'] + # The source_refresh_token and id_token are the concrete field but we change them to just token and refresh_token + # We wrap these in a try for when we need to make the initial models + try: + fields.remove('source_refresh_token') + except ValueError: + pass + try: + fields.remove('id_token') + except ValueError: + pass + read_only_fields = ('user', 'token', 'expires', 'refresh_token') + extra_kwargs = {'scope': {'allow_null': False, 'required': False}, 'user': {'allow_null': False, 'required': True}} + + def get_token(self, obj): + request = self.context.get('request', None) + try: + if request.method == 'POST': + return obj.token + else: + return ENCRYPTED_STRING + except ObjectDoesNotExist: + return '' + + def get_refresh_token(self, obj): + request = self.context.get('request', None) + try: + if not obj.refresh_token: + return None + elif request.method == 'POST': + return getattr(obj.refresh_token, 'token', '') + else: + return ENCRYPTED_STRING + except ObjectDoesNotExist: + return None + + def get_related(self, obj): + ret = super(BaseOAuth2TokenSerializer, self).get_related(obj) + if obj.user: + ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) + if obj.application: + ret['application'] = self.reverse('api:o_auth2_application_detail', kwargs={'pk': obj.application.pk}) + ret['activity_stream'] = self.reverse('api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk}) + return ret + + def _is_valid_scope(self, value): + if not value or (not isinstance(value, str)): + return False + words = value.split() + for word in words: + if words.count(word) > 1: + return False # do not allow duplicates + if word not in self.ALLOWED_SCOPES: + return False + return True + + def validate_scope(self, value): + if not self._is_valid_scope(value): + raise ValidationError(_('Must be a simple space-separated string with allowed scopes {}.').format(self.ALLOWED_SCOPES)) + return value + + def create(self, validated_data): + validated_data['user'] = self.context['request'].user + try: + return super(BaseOAuth2TokenSerializer, self).create(validated_data) + except AccessDeniedError as e: + raise PermissionDenied(str(e)) + + +class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): + def create(self, validated_data): + current_user = self.context['request'].user + validated_data['token'] = generate_token() + expires_delta = get_setting('OAUTH2_PROVIDER', {}).get('ACCESS_TOKEN_EXPIRE_SECONDS', 0) + if expires_delta == 0: + logger.warning("OAUTH2_PROVIDER.ACCESS_TOKEN_EXPIRE_SECONDS was set to 0, creating token that has already expired") + validated_data['expires'] = now() + timedelta(seconds=expires_delta) + obj = super(OAuth2TokenSerializer, self).create(validated_data) + if obj.application and obj.application.user: + obj.user = obj.application.user + obj.save() + if obj.application: + OAuth2RefreshToken.objects.create(user=current_user, token=generate_token(), application=obj.application, access_token=obj) + return obj diff --git a/ansible_base/oauth2_provider/urls.py b/ansible_base/oauth2_provider/urls.py new file mode 100644 index 000000000..1abe61e0f --- /dev/null +++ b/ansible_base/oauth2_provider/urls.py @@ -0,0 +1,41 @@ +from django.urls import include, path, re_path +from oauth2_provider import views as oauth_views + +from ansible_base.lib.routers import AssociationResourceRouter +from ansible_base.oauth2_provider import views as oauth2_providers_views + +router = AssociationResourceRouter() + +router.register( + r'applications', + oauth2_providers_views.OAuth2ApplicationViewSet, +) + +router.register(r'tokens', oauth2_providers_views.OAuth2TokenViewSet) + +api_version_urls = [ + path('', include(router.urls)), +] + +# re_path( +# r'^applications/(?P[0-9]+)/tokens/$', +# oauth2_providers_views.ApplicationOAuth2TokenList.as_view(), +# name='o_auth2_application_token_list' +# ), +# re_path( +# r'^applications/(?P[0-9]+)/activity_stream/$', +# oauth2_providers_views.OAuth2ApplicationActivityStreamList.as_view(), +# name='o_auth2_application_activity_stream_list' +# ), +# re_path( +# r'^tokens/(?P[0-9]+)/activity_stream/$', +# oauth2_providers_views.OAuth2TokenActivityStreamList.as_view(), +# name='o_auth2_token_activity_stream_list' +# ), + +root_urls = [ + re_path(r'^o/$', oauth2_providers_views.ApiOAuthAuthorizationRootView.as_view(), name='oauth_authorization_root_view'), + re_path(r"^o/authorize/$", oauth_views.AuthorizationView.as_view(), name="authorize"), + re_path(r"^o/token/$", oauth2_providers_views.TokenView.as_view(), name="token"), + re_path(r"^o/revoke_token/$", oauth_views.RevokeTokenView.as_view(), name="revoke-token"), +] diff --git a/ansible_base/oauth2_provider/utils.py b/ansible_base/oauth2_provider/utils.py new file mode 100644 index 000000000..4c1784b03 --- /dev/null +++ b/ansible_base/oauth2_provider/utils.py @@ -0,0 +1,19 @@ +from ansible_base.authentication.models import AuthenticatorUser + + +def is_external_account(user) -> bool: + # True if the user is associated with any external login source + # False if the user is associated only with the local + # None if there is no user + + if not user: + return None + + authenticator_users = AuthenticatorUser.objects.filter(user_id=user.id) + for auth_user in authenticator_users: + provider = auth_user.provider + if provider.type != 'ansible_base.authenticator_plugins.local': + return True + + # This user was not associated with any providers that were not the local provider + return False diff --git a/ansible_base/oauth2_provider/views/__init__.py b/ansible_base/oauth2_provider/views/__init__.py new file mode 100644 index 000000000..65b261cac --- /dev/null +++ b/ansible_base/oauth2_provider/views/__init__.py @@ -0,0 +1,3 @@ +from .application import OAuth2ApplicationViewSet # noqa: F401 +from .authorization_root import ApiOAuthAuthorizationRootView # noqa: F401 +from .token import OAuth2TokenViewSet, TokenView # noqa: F401 diff --git a/ansible_base/oauth2_provider/views/application.py b/ansible_base/oauth2_provider/views/application.py new file mode 100644 index 000000000..534110224 --- /dev/null +++ b/ansible_base/oauth2_provider/views/application.py @@ -0,0 +1,12 @@ +from rest_framework import permissions +from rest_framework.viewsets import ModelViewSet + +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView +from ansible_base.oauth2_provider.models import OAuth2Application +from ansible_base.oauth2_provider.serializers import OAuth2ApplicationSerializer + + +class OAuth2ApplicationViewSet(ModelViewSet, AnsibleBaseDjangoAppApiView): + queryset = OAuth2Application.objects.all() + serializer_class = OAuth2ApplicationSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/ansible_base/oauth2_provider/views/authorization_root.py b/ansible_base/oauth2_provider/views/authorization_root.py new file mode 100644 index 000000000..125dc3ba1 --- /dev/null +++ b/ansible_base/oauth2_provider/views/authorization_root.py @@ -0,0 +1,22 @@ +from collections import OrderedDict + +from django.utils.translation import gettext_lazy as _ +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.reverse import _reverse + +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView + + +class ApiOAuthAuthorizationRootView(AnsibleBaseDjangoAppApiView): + permission_classes = (permissions.AllowAny,) + name = _("API OAuth 2 Authorization Root") + versioning_class = None + swagger_topic = 'Authentication' + + def get(self, request, format=None): + data = OrderedDict() + data['authorize'] = _reverse('authorize') + data['revoke_token'] = _reverse('revoke-token') + data['token'] = _reverse('token') + return Response(data) diff --git a/ansible_base/oauth2_provider/views/token.py b/ansible_base/oauth2_provider/views/token.py new file mode 100644 index 000000000..730d04c65 --- /dev/null +++ b/ansible_base/oauth2_provider/views/token.py @@ -0,0 +1,39 @@ +from datetime import timedelta + +from django.utils.timezone import now +from oauth2_provider import views as oauth_views +from oauthlib import oauth2 +from rest_framework import permissions +from rest_framework.viewsets import ModelViewSet + +from ansible_base.lib.utils.settings import get_setting +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken +from ansible_base.oauth2_provider.serializers import OAuth2TokenSerializer + + +class TokenView(oauth_views.TokenView, AnsibleBaseDjangoAppApiView): + def create_token_response(self, request): + # Django OAuth2 Toolkit has a bug whereby refresh tokens are *never* + # properly expired (ugh): + # + # https://github.com/jazzband/django-oauth-toolkit/issues/746 + # + # This code detects and auto-expires them on refresh grant + # requests. + if request.POST.get('grant_type') == 'refresh_token' and 'refresh_token' in request.POST: + refresh_token = OAuth2RefreshToken.objects.filter(token=request.POST['refresh_token']).first() + if refresh_token: + expire_seconds = get_setting('OAUTH2_PROVIDER', {}).get('REFRESH_TOKEN_EXPIRE_SECONDS', 0) + if refresh_token.created + timedelta(seconds=expire_seconds) < now(): + return request.build_absolute_uri(), {}, 'The refresh token has expired.', '403' + try: + return super(TokenView, self).create_token_response(request) + except oauth2.AccessDeniedError as e: + return request.build_absolute_uri(), {}, str(e), '403' + + +class OAuth2TokenViewSet(ModelViewSet, AnsibleBaseDjangoAppApiView): + queryset = OAuth2AccessToken.objects.all() + serializer_class = OAuth2TokenSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/pyproject.toml b/pyproject.toml index 0a5d0f8bf..1200e0d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ optional-dependencies.all = { file = [ "requirements/requirements_jwt_consumer.in", "requirements/requirements_testing.in", "requirements/requirements_redis_client.in", + "requirements/requirements_oauth2_provider.in", ] } optional-dependencies.activitystream = { file = [ "requirements/requirements_activitystream.in" ] } optional-dependencies.authentication = { file = [ "requirements/requirements_authentication.in" ] } @@ -53,6 +54,7 @@ optional-dependencies.channel_auth = { file = [ "requirements/requirements_chann optional-dependencies.jwt_consumer = { file = [ "requirements/requirements_jwt_consumer.in" ] } optional-dependencies.testing = { file = [ "requirements/requirements_testing.in" ] } optional-dependencies.redis_client = { file = [ "requirements/requirements_redis_client.in" ] } +optional-dependencies.oauth2_provider = { file = [ "requirements/requirements_oauth2_provider.in" ] } [build-system] requires = ["setuptools>=64", "setuptools_scm>=8"] @@ -72,7 +74,7 @@ force-exclude = ''' [tool.isort] profile = "black" line_length = 160 -extend_skip = [ "ansible_base/authentication/migrations", "ansible_base/activitystream/migrations", "test_app/migrations" ] +extend_skip = [ "ansible_base/authentication/migrations", "ansible_base/activitystream/migrations", "test_app/migrations", "ansible_base/oauth2_provider/migrations" ] [tool.flake8] diff --git a/requirements/requirements_all.txt b/requirements/requirements_all.txt index 819d4e689..27bcbacf5 100644 --- a/requirements/requirements_all.txt +++ b/requirements/requirements_all.txt @@ -18,6 +18,7 @@ cryptography==42.0.5 # via # -r requirements/requirements.in # -r requirements/requirements_testing.in + # jwcrypto # social-auth-core defusedxml==0.8.0rc2 # via @@ -29,6 +30,7 @@ django==4.2.11 # channels # django-auth-ldap # django-crum + # django-oauth-toolkit # django-redis # djangorestframework # drf-spectacular @@ -37,6 +39,8 @@ django-auth-ldap==4.7.0 # via -r requirements/requirements_authentication.in django-crum==0.7.9 # via -r requirements/requirements.in +django-oauth-toolkit==2.3.0 + # via -r requirements/requirements_oauth2_provider.in django-redis==5.4.0 # via -r requirements/requirements_redis_client.in django-split-settings==1.3.0 @@ -61,6 +65,8 @@ jsonschema==4.21.1 # via drf-spectacular jsonschema-specifications==2023.12.1 # via jsonschema +jwcrypto==1.5.6 + # via django-oauth-toolkit lxml==5.1.0 # via # python3-saml @@ -69,6 +75,7 @@ netaddr==1.2.1 # via pyrad oauthlib==3.2.2 # via + # django-oauth-toolkit # requests-oauthlib # social-auth-core packaging==24.0 @@ -116,6 +123,7 @@ referencing==0.34.0 requests==2.31.0 # via # -r requirements/requirements_jwt_consumer.in + # django-oauth-toolkit # requests-oauthlib # social-auth-core requests-oauthlib==2.0.0 @@ -139,6 +147,8 @@ tabulate==0.9.0 # via -r requirements/requirements_authentication.in tacacs-plus==2.6 # via -r requirements/requirements_authentication.in +typing-extensions==4.11.0 + # via jwcrypto uritemplate==4.1.1 # via drf-spectacular urllib3==2.2.1 diff --git a/requirements/requirements_oauth2_provider.in b/requirements/requirements_oauth2_provider.in new file mode 100644 index 000000000..ac2de91e4 --- /dev/null +++ b/requirements/requirements_oauth2_provider.in @@ -0,0 +1 @@ +django-oauth-toolkit>=1.7.1 # This is pinned this way so that DAB is compatible with AWX \ No newline at end of file diff --git a/test_app/settings.py b/test_app/settings.py index a38b58baf..59c1fd7f5 100644 --- a/test_app/settings.py +++ b/test_app/settings.py @@ -57,6 +57,7 @@ 'ansible_base.resource_registry', 'ansible_base.rest_pagination', 'ansible_base.rbac', + 'ansible_base.oauth2_provider', 'test_app', 'django_extensions', 'debug_toolbar', From 6e33c5a52e85b32ae802cc37fcacfdae48fd52a2 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 28 Feb 2024 14:34:21 -0500 Subject: [PATCH 02/46] Start unit tests for oauth2 provider --- .../migrations/0001_initial.py | 210 +++-------------- ansible_base/oauth2_provider/urls.py | 10 +- .../oauth2_provider/views/application.py | 2 +- .../oauth2_provider/views/token_root.py | 29 +++ test_app/tests/oauth2_provider/__init__.py | 0 .../tests/oauth2_provider/models/__init__.py | 0 .../models/test_access_token.py | 0 .../models/test_application.py | 222 ++++++++++++++++++ .../oauth2_provider/serializers/__init__.py | 0 .../serializers/test_application.py | 0 .../oauth2_provider/serializers/test_token.py | 0 .../oauth2_provider/test_authentication.py | 0 test_app/tests/oauth2_provider/test_utils.py | 34 +++ .../tests/oauth2_provider/views/__init__.py | 0 .../views/test_authoriation_root.py | 9 + .../tests/oauth2_provider/views/test_token.py | 100 ++++++++ 16 files changed, 432 insertions(+), 184 deletions(-) create mode 100644 ansible_base/oauth2_provider/views/token_root.py create mode 100644 test_app/tests/oauth2_provider/__init__.py create mode 100644 test_app/tests/oauth2_provider/models/__init__.py create mode 100644 test_app/tests/oauth2_provider/models/test_access_token.py create mode 100644 test_app/tests/oauth2_provider/models/test_application.py create mode 100644 test_app/tests/oauth2_provider/serializers/__init__.py create mode 100644 test_app/tests/oauth2_provider/serializers/test_application.py create mode 100644 test_app/tests/oauth2_provider/serializers/test_token.py create mode 100644 test_app/tests/oauth2_provider/test_authentication.py create mode 100644 test_app/tests/oauth2_provider/test_utils.py create mode 100644 test_app/tests/oauth2_provider/views/__init__.py create mode 100644 test_app/tests/oauth2_provider/views/test_authoriation_root.py create mode 100644 test_app/tests/oauth2_provider/views/test_token.py diff --git a/ansible_base/oauth2_provider/migrations/0001_initial.py b/ansible_base/oauth2_provider/migrations/0001_initial.py index 7ead77097..79acfe36a 100644 --- a/ansible_base/oauth2_provider/migrations/0001_initial.py +++ b/ansible_base/oauth2_provider/migrations/0001_initial.py @@ -30,9 +30,9 @@ class Migration(migrations.Migration): name='OAuth2Application', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('name', models.CharField(help_text='The name of this resource', max_length=512)), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('name', models.CharField(blank=True, help_text='The name of this resource', max_length=255)), ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('description', models.TextField(blank=True, default='')), ('logo_data', models.TextField(default='', editable=False, validators=[django.core.validators.RegexValidator(re.compile('.*'))])), @@ -43,6 +43,11 @@ class Migration(migrations.Migration): ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.ANSIBLE_BASE_ORGANIZATION_MODEL)), + ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), + ('post_logout_redirect_uris', models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated')), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'application', @@ -55,10 +60,16 @@ class Migration(migrations.Migration): name='OAuth2IDToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('expires', models.DateTimeField(default=None)), + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('scope', models.TextField(blank=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'id token', @@ -69,29 +80,41 @@ class Migration(migrations.Migration): name='OAuth2RefreshToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('revoked', models.DateTimeField(null=True)), + ('token', models.CharField(default='', max_length=255)), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'access token', 'ordering': ('id',), 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', + 'unique_together': {('token', 'revoked')}, }, ), migrations.CreateModel( name='OAuth2AccessToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('modified_on', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), ('description', models.TextField(blank=True, default='')), ('last_used', models.DateTimeField(default=None, editable=False, null=True)), ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('expires', models.DateTimeField(default=None)), + ('token', models.CharField(default='', max_length=255, unique=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), + ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ], options={ 'verbose_name': 'access token', @@ -99,178 +122,9 @@ class Migration(migrations.Migration): 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', }, ), - migrations.AddField( - model_name='oauth2accesstoken', - name='application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='expires', - field=models.DateTimeField(default=''), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='token', - field=models.CharField(default='', max_length=255, unique=True), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='oauth2application', - name='algorithm', - field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), - ), - migrations.AddField( - model_name='oauth2application', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2application', - name='post_logout_redirect_uris', - field=models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated'), - ), - migrations.AddField( - model_name='oauth2application', - name='redirect_uris', - field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), - ), - migrations.AddField( - model_name='oauth2application', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='oauth2application', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='oauth2idtoken', - name='application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ), - migrations.AddField( - model_name='oauth2idtoken', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2idtoken', - name='expires', - field=models.DateTimeField(default=''), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2idtoken', - name='jti', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID'), - ), - migrations.AddField( - model_name='oauth2idtoken', - name='scope', - field=models.TextField(blank=True), - ), - migrations.AddField( - model_name='oauth2idtoken', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='oauth2idtoken', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), - ), migrations.AddField( model_name='oauth2refreshtoken', name='access_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='application', - field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='revoked', - field=models.DateTimeField(null=True), - ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='token', - field=models.CharField(default='', max_length=255), - preserve_default=False, - ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='oauth2refreshtoken', - name='user', - field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - migrations.AlterField( - model_name='oauth2accesstoken', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='oauth2application', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='oauth2application', - name='name', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AlterField( - model_name='oauth2idtoken', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='oauth2refreshtoken', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterUniqueTogether( - name='oauth2refreshtoken', - unique_together={('token', 'revoked')}, - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='source_refresh_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), - ), ] diff --git a/ansible_base/oauth2_provider/urls.py b/ansible_base/oauth2_provider/urls.py index 1abe61e0f..809502b0f 100644 --- a/ansible_base/oauth2_provider/urls.py +++ b/ansible_base/oauth2_provider/urls.py @@ -3,15 +3,15 @@ from ansible_base.lib.routers import AssociationResourceRouter from ansible_base.oauth2_provider import views as oauth2_providers_views +from ansible_base.oauth2_provider.apps import Oauth2ProviderConfig + +app_name = Oauth2ProviderConfig.label router = AssociationResourceRouter() -router.register( - r'applications', - oauth2_providers_views.OAuth2ApplicationViewSet, -) +router.register(r'applications', oauth2_providers_views.OAuth2ApplicationViewSet, basename='application') -router.register(r'tokens', oauth2_providers_views.OAuth2TokenViewSet) +router.register(r'tokens', oauth2_providers_views.OAuth2TokenViewSet, basename='token') api_version_urls = [ path('', include(router.urls)), diff --git a/ansible_base/oauth2_provider/views/application.py b/ansible_base/oauth2_provider/views/application.py index 534110224..25c5919ef 100644 --- a/ansible_base/oauth2_provider/views/application.py +++ b/ansible_base/oauth2_provider/views/application.py @@ -6,7 +6,7 @@ from ansible_base.oauth2_provider.serializers import OAuth2ApplicationSerializer -class OAuth2ApplicationViewSet(ModelViewSet, AnsibleBaseDjangoAppApiView): +class OAuth2ApplicationViewSet(AnsibleBaseDjangoAppApiView, ModelViewSet): queryset = OAuth2Application.objects.all() serializer_class = OAuth2ApplicationSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/ansible_base/oauth2_provider/views/token_root.py b/ansible_base/oauth2_provider/views/token_root.py new file mode 100644 index 000000000..b40b70ed1 --- /dev/null +++ b/ansible_base/oauth2_provider/views/token_root.py @@ -0,0 +1,29 @@ +from datetime import timedelta + +from django.conf import settings +from django.utils.timezone import now +from oauth2_provider.views import TokenView +from oauthlib.oauth2 import AccessDeniedError + +from ansible_base.oauth2_provider.models import OAuth2RefreshToken + + +class TokenView(TokenView): + def create_token_response(self, request): + # Django OAuth2 Toolkit has a bug whereby refresh tokens are *never* + # properly expired (ugh): + # + # https://github.com/jazzband/django-oauth-toolkit/issues/746 + # + # This code detects and auto-expires them on refresh grant + # requests. + if request.POST.get('grant_type') == 'refresh_token' and 'refresh_token' in request.POST: + refresh_token = OAuth2RefreshToken.objects.filter(token=request.POST['refresh_token']).first() + if refresh_token: + expire_seconds = settings.OAUTH2_PROVIDER.get('REFRESH_TOKEN_EXPIRE_SECONDS', 0) + if refresh_token.created + timedelta(seconds=expire_seconds) < now(): + return request.build_absolute_uri(), {}, 'The refresh token has expired.', '403' + try: + return super(TokenView, self).create_token_response(request) + except AccessDeniedError as e: + return request.build_absolute_uri(), {}, str(e), '403' diff --git a/test_app/tests/oauth2_provider/__init__.py b/test_app/tests/oauth2_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/models/__init__.py b/test_app/tests/oauth2_provider/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/models/test_access_token.py b/test_app/tests/oauth2_provider/models/test_access_token.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/models/test_application.py b/test_app/tests/oauth2_provider/models/test_application.py new file mode 100644 index 000000000..86853f03a --- /dev/null +++ b/test_app/tests/oauth2_provider/models/test_application.py @@ -0,0 +1,222 @@ +# @pytest.mark.django_db +# def test_oauth2_application_create(admin, organization, post): +# response = post( +# reverse('api:o_auth2_application_list'), +# { +# 'name': 'test app', +# 'organization': organization.pk, +# 'client_type': 'confidential', +# 'authorization_grant_type': 'password', +# }, +# admin, +# expect=201, +# ) +# assert 'modified' in response.data +# assert 'updated' not in response.data +# created_app = Application.objects.get(client_id=response.data['client_id']) +# assert created_app.name == 'test app' +# assert created_app.skip_authorization is False +# assert created_app.redirect_uris == '' +# assert created_app.client_type == 'confidential' +# assert created_app.authorization_grant_type == 'password' +# assert created_app.organization == organization +# +# +# @pytest.mark.django_db +# def test_oauth2_validator(admin, oauth_application, post): +# post( +# reverse('api:o_auth2_application_list'), +# { +# 'name': 'Write App Token', +# 'application': oauth_application.pk, +# 'scope': 'Write', +# }, +# admin, +# expect=400, +# ) +# +# +# @pytest.mark.django_db +# def test_oauth_application_update(oauth_application, organization, patch, admin, alice): +# patch( +# reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), +# { +# 'name': 'Test app with immutable grant type and user', +# 'organization': organization.pk, +# 'redirect_uris': 'http://localhost/api/', +# 'authorization_grant_type': 'password', +# 'skip_authorization': True, +# }, +# admin, +# expect=200, +# ) +# updated_app = Application.objects.get(client_id=oauth_application.client_id) +# assert updated_app.name == 'Test app with immutable grant type and user' +# assert updated_app.redirect_uris == 'http://localhost/api/' +# assert updated_app.skip_authorization is True +# assert updated_app.authorization_grant_type == 'password' +# assert updated_app.organization == organization +# +# +# @pytest.mark.django_db +# def test_oauth_application_encryption(admin, organization, post): +# response = post( +# reverse('api:o_auth2_application_list'), +# { +# 'name': 'test app', +# 'organization': organization.pk, +# 'client_type': 'confidential', +# 'authorization_grant_type': 'password', +# }, +# admin, +# expect=201, +# ) +# pk = response.data.get('id') +# secret = response.data.get('client_secret') +# with connection.cursor() as cursor: +# encrypted = cursor.execute('SELECT client_secret FROM main_oauth2application WHERE id={}'.format(pk)).fetchone()[0] +# assert encrypted.startswith('$encrypted$') +# assert decrypt_value(get_encryption_key('value', pk=None), encrypted) == secret +# +# @pytest.mark.django_db +# def test_oauth_token_create(oauth_application, get, post, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# assert 'modified' in response.data and response.data['modified'] is not None +# assert 'updated' not in response.data +# token = AccessToken.objects.get(token=response.data['token']) +# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) +# assert token.application == oauth_application +# assert refresh_token.application == oauth_application +# assert token.user == admin +# assert refresh_token.user == admin +# assert refresh_token.access_token == token +# assert token.scope == 'read' +# response = get(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), admin, expect=200) +# assert response.data['count'] == 1 +# response = get(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=200) +# assert response.data['summary_fields']['tokens']['count'] == 1 +# assert response.data['summary_fields']['tokens']['results'][0] == {'id': token.pk, 'scope': token.scope, 'token': '************'} +# +# response = post(reverse('api:o_auth2_token_list'), {'scope': 'read', 'application': oauth_application.pk}, admin, expect=201) +# assert response.data['refresh_token'] +# response = post( +# reverse('api:user_authorized_token_list', kwargs={'pk': admin.pk}), {'scope': 'read', 'application': oauth_application.pk}, admin, expect=201 +# ) +# assert response.data['refresh_token'] +# response = post(reverse('api:application_o_auth2_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# assert response.data['refresh_token'] +# +# +# @pytest.mark.django_db +# def test_oauth_token_update(oauth_application, post, patch, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# token = AccessToken.objects.get(token=response.data['token']) +# patch(reverse('api:o_auth2_token_detail', kwargs={'pk': token.pk}), {'scope': 'write'}, admin, expect=200) +# token = AccessToken.objects.get(token=token.token) +# assert token.scope == 'write' +# +# +# @pytest.mark.django_db +# def test_oauth_token_delete(oauth_application, post, delete, get, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# token = AccessToken.objects.get(token=response.data['token']) +# delete(reverse('api:o_auth2_token_detail', kwargs={'pk': token.pk}), admin, expect=204) +# assert AccessToken.objects.count() == 0 +# assert RefreshToken.objects.count() == 1 +# response = get(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), admin, expect=200) +# assert response.data['count'] == 0 +# response = get(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=200) +# assert response.data['summary_fields']['tokens']['count'] == 0 +# +# +# @pytest.mark.django_db +# def test_oauth_application_delete(oauth_application, post, delete, admin): +# post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# delete(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=204) +# assert Application.objects.filter(client_id=oauth_application.client_id).count() == 0 +# assert RefreshToken.objects.filter(application=oauth_application).count() == 0 +# assert AccessToken.objects.filter(application=oauth_application).count() == 0 +# +# @pytest.mark.django_db +# def test_refresh_accesstoken(oauth_application, post, get, delete, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# assert AccessToken.objects.count() == 1 +# assert RefreshToken.objects.count() == 1 +# token = AccessToken.objects.get(token=response.data['token']) +# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) +# +# refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/' +# response = post( +# refresh_url, +# data='grant_type=refresh_token&refresh_token=' + refresh_token.token, +# content_type='application/x-www-form-urlencoded', +# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), +# ) +# assert RefreshToken.objects.filter(token=refresh_token).exists() +# original_refresh_token = RefreshToken.objects.get(token=refresh_token) +# assert token not in AccessToken.objects.all() +# assert AccessToken.objects.count() == 1 +# # the same RefreshToken remains but is marked revoked +# assert RefreshToken.objects.count() == 2 +# new_token = json.loads(response._container[0])['access_token'] +# new_refresh_token = json.loads(response._container[0])['refresh_token'] +# assert AccessToken.objects.filter(token=new_token).count() == 1 +# # checks that RefreshTokens are rotated (new RefreshToken issued) +# assert RefreshToken.objects.filter(token=new_refresh_token).count() == 1 +# assert original_refresh_token.revoked # is not None +# +# +# @pytest.mark.django_db +# def test_refresh_token_expiration_is_respected(oauth_application, post, get, delete, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# assert AccessToken.objects.count() == 1 +# assert RefreshToken.objects.count() == 1 +# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) +# refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/' +# short_lived = {'ACCESS_TOKEN_EXPIRE_SECONDS': 1, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 1, 'REFRESH_TOKEN_EXPIRE_SECONDS': 1} +# time.sleep(1) +# with override_settings(OAUTH2_PROVIDER=short_lived): +# response = post( +# refresh_url, +# data='grant_type=refresh_token&refresh_token=' + refresh_token.token, +# content_type='application/x-www-form-urlencoded', +# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), +# ) +# assert response.status_code == 403 +# assert b'The refresh token has expired.' in response.content +# assert RefreshToken.objects.filter(token=refresh_token).exists() +# assert AccessToken.objects.count() == 1 +# assert RefreshToken.objects.count() == 1 +# +# +# @pytest.mark.django_db +# def test_revoke_access_then_refreshtoken(oauth_application, post, get, delete, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# token = AccessToken.objects.get(token=response.data['token']) +# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) +# assert AccessToken.objects.count() == 1 +# assert RefreshToken.objects.count() == 1 +# +# token.revoke() +# assert AccessToken.objects.count() == 0 +# assert RefreshToken.objects.count() == 1 +# assert not refresh_token.revoked +# +# refresh_token.revoke() +# assert AccessToken.objects.count() == 0 +# assert RefreshToken.objects.count() == 1 +# +# +# @pytest.mark.django_db +# def test_revoke_refreshtoken(oauth_application, post, get, delete, admin): +# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) +# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) +# assert AccessToken.objects.count() == 1 +# assert RefreshToken.objects.count() == 1 +# +# refresh_token.revoke() +# assert AccessToken.objects.count() == 0 +# # the same RefreshToken is recycled +# new_refresh_token = RefreshToken.objects.all().first() +# assert refresh_token == new_refresh_token +# assert new_refresh_token.revoked diff --git a/test_app/tests/oauth2_provider/serializers/__init__.py b/test_app/tests/oauth2_provider/serializers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/serializers/test_application.py b/test_app/tests/oauth2_provider/serializers/test_application.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/serializers/test_token.py b/test_app/tests/oauth2_provider/serializers/test_token.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/test_authentication.py b/test_app/tests/oauth2_provider/test_authentication.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/test_utils.py b/test_app/tests/oauth2_provider/test_utils.py new file mode 100644 index 000000000..14d0bf98b --- /dev/null +++ b/test_app/tests/oauth2_provider/test_utils.py @@ -0,0 +1,34 @@ +import pytest + +from ansible_base.authentication.models import AuthenticatorUser +from ansible_base.oauth2_provider.utils import is_external_account + + +def test_oauth2_provider_is_external_account_none(): + assert is_external_account(None) is None + + +@pytest.mark.parametrize("link_local, link_ldap, expected", [(False, False, False), (True, False, False), (False, True, True), (True, True, True)]) +def test_oauth2_provider_is_external_account_with_user(user, local_authenticator, ldap_authenticator, link_local, link_ldap, expected): + if link_local: + # Link the user to the local authenticator + local_au = AuthenticatorUser(provider=local_authenticator, user=user) + local_au.save() + if link_ldap: + # Link the user to the ldap authenticator + ldap_au = AuthenticatorUser(provider=ldap_authenticator, user=user) + ldap_au.save() + + assert is_external_account(user) is expected + + +def test_oauth2_provider_is_external_account_import_error(user, local_authenticator): + au = AuthenticatorUser(provider=local_authenticator, user=user) + au.save() + from django.db import connection + + with connection.cursor() as cursor: + cursor.execute( + f'UPDATE dab_authentication_authenticator SET type="test_app.tests.fixtures.authenticator_plugins.broken" WHERE id={local_authenticator.id}' + ) + assert is_external_account(user) is True diff --git a/test_app/tests/oauth2_provider/views/__init__.py b/test_app/tests/oauth2_provider/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_app/tests/oauth2_provider/views/test_authoriation_root.py b/test_app/tests/oauth2_provider/views/test_authoriation_root.py new file mode 100644 index 000000000..468373790 --- /dev/null +++ b/test_app/tests/oauth2_provider/views/test_authoriation_root.py @@ -0,0 +1,9 @@ +from django.urls import reverse + + +def test_oauth2_provider_authorization_root_view(admin_api_client): + url = reverse("oauth_authorization_root_view") + response = admin_api_client.get(url) + + assert response.status_code == 200 + assert 'authorize' in response.data diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py new file mode 100644 index 000000000..00e4d198f --- /dev/null +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -0,0 +1,100 @@ +import pytest +from django.urls import reverse + +# @pytest.mark.django_db +# def test_personal_access_token_creation(oauth_application, post, alice): +# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' +# resp = post( +# url, +# data='grant_type=password&username=alice&password=alice&scope=read', +# content_type='application/x-www-form-urlencoded', +# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), +# ) +# resp_json = smart_str(resp._container[0]) +# assert 'access_token' in resp_json +# assert 'scope' in resp_json +# assert 'refresh_token' in resp_json +# +# +# @pytest.mark.django_db +# @pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)]) +# def test_token_creation_disabled_for_external_accounts(oauth_application, post, alice, allow_oauth, status): +# UserEnterpriseAuth(user=alice, provider='radius').save() +# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' +# +# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=allow_oauth): +# resp = post( +# url, +# data='grant_type=password&username=alice&password=alice&scope=read', +# content_type='application/x-www-form-urlencoded', +# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), +# status=status, +# ) +# if allow_oauth: +# assert AccessToken.objects.count() == 1 +# else: +# assert 'OAuth2 Tokens cannot be created by users associated with an external authentication provider' in smart_str(resp.content) # noqa +# assert AccessToken.objects.count() == 0 +# +# @pytest.mark.django_db +# def test_existing_token_enabled_for_external_accounts(oauth_application, get, post, admin): +# UserEnterpriseAuth(user=admin, provider='radius').save() +# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' +# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True): +# resp = post( +# url, +# data='grant_type=password&username=admin&password=admin&scope=read', +# content_type='application/x-www-form-urlencoded', +# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), +# status=201, +# ) +# token = json.loads(resp.content)['access_token'] +# assert AccessToken.objects.count() == 1 +# +# with immediate_on_commit(): +# resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) +# assert json.loads(resp.content)['results'][0]['username'] == 'admin' +# +# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False): +# with immediate_on_commit(): +# resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) +# assert json.loads(resp.content)['results'][0]['username'] == 'admin' +# +# @pytest.mark.django_db +# def test_pat_creation_no_default_scope(oauth_application, post, admin): +# # tests that the default scope is overriden +# url = reverse('api:o_auth2_token_list') +# response = post( +# url, +# { +# 'description': 'test token', +# 'scope': 'read', +# 'application': oauth_application.pk, +# }, +# admin, +# ) +# assert response.data['scope'] == 'read' +# +# @pytest.mark.django_db +# def test_pat_creation_no_scope(oauth_application, post, admin): +# url = reverse('api:o_auth2_token_list') +# response = post( +# url, +# { +# 'description': 'test token', +# 'application': oauth_application.pk, +# }, +# admin, +# ) +# assert response.data['scope'] == 'write' +# + + +@pytest.mark.django_db +def test_oauth2_provider_list_user_tokens(unauthenticated_api_client, admin_user, random_user): + for user in (admin_user, random_user): + unauthenticated_api_client.login(username=user.username, password=user.password) + url = reverse('api:o_auth2_token_list', kwargs={'pk': user.pk}) + response = unauthenticated_api_client.post(url, data={'scope': 'read'}) + assert response.status_code == 201 + assert response.json()['count'] == 1 From d3605ab56dcd1f508080f61f0a2b6343a1998992 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Thu, 18 Apr 2024 20:43:48 +0200 Subject: [PATCH 03/46] Regen migrations for common changes Signed-off-by: Rick Elrod --- .../migrations/0001_initial.py | 166 ++++++++++-------- 1 file changed, 92 insertions(+), 74 deletions(-) diff --git a/ansible_base/oauth2_provider/migrations/0001_initial.py b/ansible_base/oauth2_provider/migrations/0001_initial.py index 79acfe36a..2c48544fb 100644 --- a/ansible_base/oauth2_provider/migrations/0001_initial.py +++ b/ansible_base/oauth2_provider/migrations/0001_initial.py @@ -1,15 +1,13 @@ -# Generated by Django 4.2.8 on 2024-02-11 20:16 - -import re -import uuid +# Generated by Django 4.2.11 on 2024-04-18 18:43 +import ansible_base.oauth2_provider.models.application +from django.conf import settings import django.core.validators +from django.db import migrations, models import django.db.models.deletion import oauth2_provider.generators -from django.conf import settings -from django.db import migrations, models - -import ansible_base.oauth2_provider.models.application +import re +import uuid class Migration(migrations.Migration): @@ -18,21 +16,41 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - migrations.swappable_dependency(settings.ANSIBLE_BASE_ORGANIZATION_MODEL), - ] - - run_before = [ - ('oauth2_provider', '0001_initial'), + ('test_app', '0009_city_state'), ] operations = [ + migrations.CreateModel( + name='OAuth2AccessToken', + fields=[ + ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('description', models.TextField(blank=True, default='')), + ('last_used', models.DateTimeField(default=None, editable=False, null=True)), + ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), + ], + options={ + 'verbose_name': 'access token', + 'ordering': ('id',), + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', + }, + ), migrations.CreateModel( name='OAuth2Application', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), - ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('name', models.CharField(blank=True, help_text='The name of this resource', max_length=255)), + ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('post_logout_redirect_uris', models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated')), + ('name', models.CharField(blank=True, max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('description', models.TextField(blank=True, default='')), ('logo_data', models.TextField(default='', editable=False, validators=[django.core.validators.RegexValidator(re.compile('.*'))])), @@ -40,91 +58,91 @@ class Migration(migrations.Migration): ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], help_text='Set to Public or Confidential depending on how secure the client device is.', max_length=32)), ('skip_authorization', models.BooleanField(default=False, help_text='Set True to skip authorization step for completely trusted applications.')), ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('password', 'Resource owner password-based')], help_text='The Grant type the user must use for acquire tokens for this application.', max_length=32)), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.ANSIBLE_BASE_ORGANIZATION_MODEL)), - ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), - ('post_logout_redirect_uris', models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated')), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('updated', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='test_app.organization')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'application', 'ordering': ('organization', 'name'), + 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL', 'unique_together': {('name', 'organization')}, }, ), - migrations.CreateModel( - name='OAuth2IDToken', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), - ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('expires', models.DateTimeField(default=None)), - ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), - ('scope', models.TextField(blank=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'id token', - 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', - }, - ), migrations.CreateModel( name='OAuth2RefreshToken', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('application', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('revoked', models.DateTimeField(null=True)), - ('token', models.CharField(default='', max_length=255)), + ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ('revoked', models.DateTimeField(null=True)), + ('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'access token', 'ordering': ('id',), + 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', 'unique_together': {('token', 'revoked')}, }, ), migrations.CreateModel( - name='OAuth2AccessToken', + name='OAuth2IDToken', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), - ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), - ('description', models.TextField(blank=True, default='')), - ('last_used', models.DateTimeField(default=None, editable=False, null=True)), - ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('user', models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('expires', models.DateTimeField(default=None)), - ('token', models.CharField(default='', max_length=255, unique=True)), + ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), - ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), - ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name': 'access token', - 'ordering': ('id',), - 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', + 'verbose_name': 'id token', + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', }, ), migrations.AddField( - model_name='oauth2refreshtoken', - name='access_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + model_name='oauth2accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='created_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='modified_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + migrations.AddField( + model_name='oauth2accesstoken', + name='user', + field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), ), ] From 60b4cd13043a6d64b9d9c709978abcdd8dce95ce Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 14:03:26 +0200 Subject: [PATCH 04/46] Tighten up is_external_account() and fix tests Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/utils.py | 28 +++++++++----------- test_app/tests/oauth2_provider/test_utils.py | 17 ++++-------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/ansible_base/oauth2_provider/utils.py b/ansible_base/oauth2_provider/utils.py index 4c1784b03..a8ba95006 100644 --- a/ansible_base/oauth2_provider/utils.py +++ b/ansible_base/oauth2_provider/utils.py @@ -1,19 +1,17 @@ -from ansible_base.authentication.models import AuthenticatorUser +from django.contrib.auth import get_user_model +User = get_user_model() -def is_external_account(user) -> bool: - # True if the user is associated with any external login source - # False if the user is associated only with the local - # None if there is no user - if not user: - return None +def is_external_account(user: User) -> bool: + """ + Predicate which tests whether the user is associated with any external + login source. - authenticator_users = AuthenticatorUser.objects.filter(user_id=user.id) - for auth_user in authenticator_users: - provider = auth_user.provider - if provider.type != 'ansible_base.authenticator_plugins.local': - return True - - # This user was not associated with any providers that were not the local provider - return False + :param user: The user to test + :return: True if the user is associated with any external login source + False if the user is associated only with the local + """ + authenticator_users = user.authenticator_users.all() + local = 'ansible_base.authentication.authenticator_plugins.local' + return any(auth_user.provider.type != local for auth_user in authenticator_users) diff --git a/test_app/tests/oauth2_provider/test_utils.py b/test_app/tests/oauth2_provider/test_utils.py index 14d0bf98b..a023367ed 100644 --- a/test_app/tests/oauth2_provider/test_utils.py +++ b/test_app/tests/oauth2_provider/test_utils.py @@ -1,13 +1,9 @@ import pytest -from ansible_base.authentication.models import AuthenticatorUser +from ansible_base.authentication.models import Authenticator, AuthenticatorUser from ansible_base.oauth2_provider.utils import is_external_account -def test_oauth2_provider_is_external_account_none(): - assert is_external_account(None) is None - - @pytest.mark.parametrize("link_local, link_ldap, expected", [(False, False, False), (True, False, False), (False, True, True), (True, True, True)]) def test_oauth2_provider_is_external_account_with_user(user, local_authenticator, ldap_authenticator, link_local, link_ldap, expected): if link_local: @@ -25,10 +21,7 @@ def test_oauth2_provider_is_external_account_with_user(user, local_authenticator def test_oauth2_provider_is_external_account_import_error(user, local_authenticator): au = AuthenticatorUser(provider=local_authenticator, user=user) au.save() - from django.db import connection - - with connection.cursor() as cursor: - cursor.execute( - f'UPDATE dab_authentication_authenticator SET type="test_app.tests.fixtures.authenticator_plugins.broken" WHERE id={local_authenticator.id}' - ) - assert is_external_account(user) is True + local_authenticator.type = "test_app.tests.fixtures.authenticator_plugins.broken" + # Avoid save() which would raise an ImportError + Authenticator.objects.bulk_update([local_authenticator], ['type']) + assert is_external_account(user) From 90c821b4293e756790ffab80c9834acbfc607ba4 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 14:56:04 +0200 Subject: [PATCH 05/46] fix import alias Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/urls.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ansible_base/oauth2_provider/urls.py b/ansible_base/oauth2_provider/urls.py index 809502b0f..baa815ed9 100644 --- a/ansible_base/oauth2_provider/urls.py +++ b/ansible_base/oauth2_provider/urls.py @@ -2,16 +2,16 @@ from oauth2_provider import views as oauth_views from ansible_base.lib.routers import AssociationResourceRouter -from ansible_base.oauth2_provider import views as oauth2_providers_views +from ansible_base.oauth2_provider import views as oauth2_provider_views from ansible_base.oauth2_provider.apps import Oauth2ProviderConfig app_name = Oauth2ProviderConfig.label router = AssociationResourceRouter() -router.register(r'applications', oauth2_providers_views.OAuth2ApplicationViewSet, basename='application') +router.register(r'applications', oauth2_provider_views.OAuth2ApplicationViewSet, basename='application') -router.register(r'tokens', oauth2_providers_views.OAuth2TokenViewSet, basename='token') +router.register(r'tokens', oauth2_provider_views.OAuth2TokenViewSet, basename='token') api_version_urls = [ path('', include(router.urls)), @@ -19,23 +19,23 @@ # re_path( # r'^applications/(?P[0-9]+)/tokens/$', -# oauth2_providers_views.ApplicationOAuth2TokenList.as_view(), +# oauth2_provider_views.ApplicationOAuth2TokenList.as_view(), # name='o_auth2_application_token_list' # ), # re_path( # r'^applications/(?P[0-9]+)/activity_stream/$', -# oauth2_providers_views.OAuth2ApplicationActivityStreamList.as_view(), +# oauth2_provider_views.OAuth2ApplicationActivityStreamList.as_view(), # name='o_auth2_application_activity_stream_list' # ), # re_path( # r'^tokens/(?P[0-9]+)/activity_stream/$', -# oauth2_providers_views.OAuth2TokenActivityStreamList.as_view(), +# oauth2_provider_views.OAuth2TokenActivityStreamList.as_view(), # name='o_auth2_token_activity_stream_list' # ), root_urls = [ - re_path(r'^o/$', oauth2_providers_views.ApiOAuthAuthorizationRootView.as_view(), name='oauth_authorization_root_view'), + re_path(r'^o/$', oauth2_provider_views.ApiOAuthAuthorizationRootView.as_view(), name='oauth_authorization_root_view'), re_path(r"^o/authorize/$", oauth_views.AuthorizationView.as_view(), name="authorize"), - re_path(r"^o/token/$", oauth2_providers_views.TokenView.as_view(), name="token"), + re_path(r"^o/token/$", oauth2_provider_views.TokenView.as_view(), name="token"), re_path(r"^o/revoke_token/$", oauth_views.RevokeTokenView.as_view(), name="revoke-token"), ] From e8f8329ad758ad6dcd3c3e55e0e3d9daf5e4dcd7 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 17:37:09 +0200 Subject: [PATCH 06/46] I regret everything. Revert "Regen migrations for common changes" This reverts commit 53b7fb640a84d3c346226d3a5188be48ea467c21. --- .../migrations/0001_initial.py | 166 ++++++++---------- 1 file changed, 74 insertions(+), 92 deletions(-) diff --git a/ansible_base/oauth2_provider/migrations/0001_initial.py b/ansible_base/oauth2_provider/migrations/0001_initial.py index 2c48544fb..79acfe36a 100644 --- a/ansible_base/oauth2_provider/migrations/0001_initial.py +++ b/ansible_base/oauth2_provider/migrations/0001_initial.py @@ -1,13 +1,15 @@ -# Generated by Django 4.2.11 on 2024-04-18 18:43 +# Generated by Django 4.2.8 on 2024-02-11 20:16 + +import re +import uuid -import ansible_base.oauth2_provider.models.application -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import oauth2_provider.generators -import re -import uuid +from django.conf import settings +from django.db import migrations, models + +import ansible_base.oauth2_provider.models.application class Migration(migrations.Migration): @@ -16,41 +18,21 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('test_app', '0009_city_state'), + migrations.swappable_dependency(settings.ANSIBLE_BASE_ORGANIZATION_MODEL), + ] + + run_before = [ + ('oauth2_provider', '0001_initial'), ] operations = [ - migrations.CreateModel( - name='OAuth2AccessToken', - fields=[ - ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255, unique=True)), - ('expires', models.DateTimeField()), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('description', models.TextField(blank=True, default='')), - ('last_used', models.DateTimeField(default=None, editable=False, null=True)), - ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), - ], - options={ - 'verbose_name': 'access token', - 'ordering': ('id',), - 'abstract': False, - 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', - }, - ), migrations.CreateModel( name='OAuth2Application', fields=[ - ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('post_logout_redirect_uris', models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated')), - ('name', models.CharField(blank=True, max_length=255)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('name', models.CharField(blank=True, help_text='The name of this resource', max_length=255)), ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('description', models.TextField(blank=True, default='')), ('logo_data', models.TextField(default='', editable=False, validators=[django.core.validators.RegexValidator(re.compile('.*'))])), @@ -58,91 +40,91 @@ class Migration(migrations.Migration): ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], help_text='Set to Public or Confidential depending on how secure the client device is.', max_length=32)), ('skip_authorization', models.BooleanField(default=False, help_text='Set True to skip authorization step for completely trusted applications.')), ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('password', 'Resource owner password-based')], help_text='The Grant type the user must use for acquire tokens for this application.', max_length=32)), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='test_app.organization')), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('organization', models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.ANSIBLE_BASE_ORGANIZATION_MODEL)), + ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), + ('post_logout_redirect_uris', models.TextField(blank=True, help_text='Allowed Post Logout URIs list, space separated')), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('updated', models.DateTimeField(auto_now=True)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'application', 'ordering': ('organization', 'name'), - 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL', 'unique_together': {('name', 'organization')}, }, ), migrations.CreateModel( - name='OAuth2RefreshToken', + name='OAuth2IDToken', fields=[ - ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255)), - ('created', models.DateTimeField(auto_now_add=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('expires', models.DateTimeField(default=None)), + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('scope', models.TextField(blank=True)), ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'id token', + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + migrations.CreateModel( + name='OAuth2RefreshToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('revoked', models.DateTimeField(null=True)), - ('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ('token', models.CharField(default='', max_length=255)), + ('updated', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'access token', 'ordering': ('id',), - 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', 'unique_together': {('token', 'revoked')}, }, ), migrations.CreateModel( - name='OAuth2IDToken', + name='OAuth2AccessToken', fields=[ - ('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created')), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), - ('expires', models.DateTimeField()), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, editable=False, help_text='The date/time this resource was created')), + ('modified', models.DateTimeField(default=None, editable=False, help_text='The date/time this resource was created')), + ('description', models.TextField(blank=True, default='')), + ('last_used', models.DateTimeField(default=None, editable=False, null=True)), + ('scope', models.CharField(blank=True, choices=[('read', 'read'), ('write', 'write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32)), + ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('created_by', models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL)), - ('modified_by', models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ('expires', models.DateTimeField(default=None)), + ('token', models.CharField(default='', max_length=255, unique=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), + ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ], options={ - 'verbose_name': 'id token', - 'abstract': False, - 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + 'verbose_name': 'access token', + 'ordering': ('id',), + 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', }, ), migrations.AddField( - model_name='oauth2accesstoken', - name='application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='created_by', - field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='modified_by', - field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='source_refresh_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), - ), - migrations.AddField( - model_name='oauth2accesstoken', - name='user', - field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + model_name='oauth2refreshtoken', + name='access_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), ), ] From a65cc9bd908c883624521a40b107c3e172f6bbd6 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 17:49:16 +0200 Subject: [PATCH 07/46] Try fixing up migrations while not squashing them Signed-off-by: Rick Elrod --- .../migrations/0001_initial.py | 2 +- ...lter_oauth2accesstoken_created_and_more.py | 151 ++++++++++++++++++ .../oauth2_provider/models/application.py | 14 +- 3 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py diff --git a/ansible_base/oauth2_provider/migrations/0001_initial.py b/ansible_base/oauth2_provider/migrations/0001_initial.py index 79acfe36a..5732c858d 100644 --- a/ansible_base/oauth2_provider/migrations/0001_initial.py +++ b/ansible_base/oauth2_provider/migrations/0001_initial.py @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('description', models.TextField(blank=True, default='')), ('logo_data', models.TextField(default='', editable=False, validators=[django.core.validators.RegexValidator(re.compile('.*'))])), - ('client_secret', ansible_base.oauth2_provider.models.application.OAuth2ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Used for more stringent verification of access to an application when creating a token.', max_length=1024)), + ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Used for more stringent verification of access to an application when creating a token.', max_length=1024)), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], help_text='Set to Public or Confidential depending on how secure the client device is.', max_length=32)), ('skip_authorization', models.BooleanField(default=False, help_text='Set True to skip authorization step for completely trusted applications.')), ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('password', 'Resource owner password-based')], help_text='The Grant type the user must use for acquire tokens for this application.', max_length=32)), diff --git a/ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py b/ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py new file mode 100644 index 000000000..64f9829e3 --- /dev/null +++ b/ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py @@ -0,0 +1,151 @@ +# Generated by Django 4.2.11 on 2024-04-19 15:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dab_oauth2_provider', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2accesstoken', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='created_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='expires', + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='modified', + field=models.DateTimeField(auto_now=True, help_text='The date/time this resource was created'), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='modified_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='token', + field=models.CharField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name='oauth2application', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='oauth2application', + name='created_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2application', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2application', + name='modified', + field=models.DateTimeField(auto_now=True, help_text='The date/time this resource was created'), + ), + migrations.AlterField( + model_name='oauth2application', + name='modified_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2application', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='created_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='expires', + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='modified', + field=models.DateTimeField(auto_now=True, help_text='The date/time this resource was created'), + ), + migrations.AlterField( + model_name='oauth2idtoken', + name='modified_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='created_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who created this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='modified', + field=models.DateTimeField(auto_now=True, help_text='The date/time this resource was created'), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='modified_by', + field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='token', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='oauth2refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index 2eb18d875..ca0831a74 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -8,23 +8,13 @@ from oauth2_provider.generators import generate_client_id, generate_client_secret from ansible_base.lib.abstract_models.common import NamedCommonModel -from ansible_base.lib.utils.encryption import ansible_encryption DATA_URI_RE = re.compile(r'.*') # FIXME -class OAuth2ClientSecretField(models.CharField): - def get_db_prep_value(self, value, connection, prepared=False): - return super().get_db_prep_value(ansible_encryption.encrypt_string(value), connection, prepared) - - def from_db_value(self, value, expression, connection): - if value and value.startswith('$encrypted$'): - return ansible_encryption.decrypt_string(value) - return value - - class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): reverse_name_override = 'application' + encrtyped_fields = ['client_secret'] class Meta(oauth2_models.AbstractAccessToken.Meta): verbose_name = _('application') @@ -60,7 +50,7 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): on_delete=models.CASCADE, null=True, ) - client_secret = OAuth2ClientSecretField( + client_secret = models.CharField( max_length=1024, blank=True, default=generate_client_secret, From e1188fbdcae1210298a0248885f5ba47fbb8f953 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 22:42:33 +0200 Subject: [PATCH 08/46] Start on application tests Signed-off-by: Rick Elrod --- .../oauth2_provider/models/application.py | 10 ++- test_app/settings.py | 2 + test_app/tests/oauth2_provider/conftest.py | 14 ++++ .../oauth2_provider/test_authentication.py | 0 .../oauth2_provider/views/test_application.py | 79 +++++++++++++++++++ .../views/test_authoriation_root.py | 9 --- .../views/test_authorization_root.py | 23 ++++++ .../oauth2_provider/views/test_authorize.py | 55 +++++++++++++ test_app/views.py | 13 ++- 9 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 test_app/tests/oauth2_provider/conftest.py delete mode 100644 test_app/tests/oauth2_provider/test_authentication.py create mode 100644 test_app/tests/oauth2_provider/views/test_application.py delete mode 100644 test_app/tests/oauth2_provider/views/test_authoriation_root.py create mode 100644 test_app/tests/oauth2_provider/views/test_authorization_root.py create mode 100644 test_app/tests/oauth2_provider/views/test_authorize.py diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index ca0831a74..ef5cd29e7 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -4,8 +4,9 @@ from django.conf import settings from django.core.validators import RegexValidator from django.db import models +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from oauth2_provider.generators import generate_client_id, generate_client_secret +from oauth2_provider.generators import generate_client_secret from ansible_base.lib.abstract_models.common import NamedCommonModel @@ -32,8 +33,6 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): ("password", _("Resource owner password-based")), ) - # Here we are going to overwrite this from the parent class so that we can change the default - client_id = models.CharField(db_index=True, default=generate_client_id, max_length=100, unique=True) description = models.TextField( default='', blank=True, @@ -64,3 +63,8 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): authorization_grant_type = models.CharField( max_length=32, choices=GRANT_TYPES, help_text=_('The Grant type the user must use for acquire tokens for this application.') ) + + def get_absolute_url(self): + # This is kind of annoying. This method lives on the superclass and we check for it in CommonModel. + # But better would be to not have this method and let the CommonModel logic fall back to the "right" way of finding this. + return reverse('application-detail', kwargs={'pk': self.pk}) diff --git a/test_app/settings.py b/test_app/settings.py index 59c1fd7f5..bcf7096e9 100644 --- a/test_app/settings.py +++ b/test_app/settings.py @@ -151,3 +151,5 @@ ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = True ANSIBLE_BASE_USER_VIEWSET = 'test_app.views.UserViewSet' + +LOGIN_URL = "/login/login" diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py new file mode 100644 index 000000000..9b6831fbf --- /dev/null +++ b/test_app/tests/oauth2_provider/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from ansible_base.oauth2_provider.models import OAuth2Application + + +@pytest.fixture +def oauth2_application(randname): + return OAuth2Application.objects.create( + name=randname("OAuth2 Application"), + description="Test OAuth2 Application", + redirect_uris="http://example.com/callback", + authorization_grant_type="authorization-code", + client_type="confidential", + ) diff --git a/test_app/tests/oauth2_provider/test_authentication.py b/test_app/tests/oauth2_provider/test_authentication.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py new file mode 100644 index 000000000..2612e43ab --- /dev/null +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -0,0 +1,79 @@ +import pytest +from django.urls import reverse + +from ansible_base.oauth2_provider.models import OAuth2Application + + +@pytest.mark.parametrize( + "client_fixture,expected_status", + [ + ("admin_api_client", 200), + ("user_api_client", 200), + ("client", 401), + ], +) +@pytest.mark.django_db +def test_oauth2_provider_application_list(request, client_fixture, expected_status, oauth2_application): + """ + Test that we can view the list of OAuth2 applications iff we are authenticated. + """ + client = request.getfixturevalue(client_fixture) + url = reverse("application-list") + response = client.get(url) + assert response.status_code == expected_status + if expected_status == 200: + assert len(response.data['results']) == OAuth2Application.objects.count() + assert response.data['results'][0]['name'] == oauth2_application.name + + +@pytest.mark.parametrize( + "client_fixture,expected_status", + [ + ("admin_api_client", 200), + ("user_api_client", 200), + ("client", 401), + ], +) +@pytest.mark.django_db +def test_oauth2_provider_application_detail(request, client_fixture, expected_status, oauth2_application): + """ + Test that we can view the detail of an OAuth2 application iff we are authenticated. + """ + client = request.getfixturevalue(client_fixture) + url = reverse("application-detail", args=[oauth2_application.pk]) + response = client.get(url) + assert response.status_code == expected_status + if expected_status == 200: + assert response.data['name'] == oauth2_application.name + + +@pytest.mark.parametrize( + "client_fixture,expected_status", + [ + ("admin_api_client", 201), + ("user_api_client", 201), + ("client", 401), + ], +) +def test_oauth2_provider_application_create(request, client_fixture, expected_status, randname, organization): + """ + As an admin, I should be able to create an OAuth2 application. + """ + client = request.getfixturevalue(client_fixture) + url = reverse("application-list") + name = randname("Test Application") + response = client.post( + url, + data={ + 'name': name, + 'description': 'Test Description', + 'organization': organization.pk, + 'redirect_uris': 'http://example.com/callback', + 'authorization_grant_type': 'authorization-code', + 'client_type': 'confidential', + }, + ) + assert response.status_code == expected_status, response.data + if expected_status == 201: + assert response.data['name'] == name + assert OAuth2Application.objects.get(pk=response.data['id']).organization == organization diff --git a/test_app/tests/oauth2_provider/views/test_authoriation_root.py b/test_app/tests/oauth2_provider/views/test_authoriation_root.py deleted file mode 100644 index 468373790..000000000 --- a/test_app/tests/oauth2_provider/views/test_authoriation_root.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import reverse - - -def test_oauth2_provider_authorization_root_view(admin_api_client): - url = reverse("oauth_authorization_root_view") - response = admin_api_client.get(url) - - assert response.status_code == 200 - assert 'authorize' in response.data diff --git a/test_app/tests/oauth2_provider/views/test_authorization_root.py b/test_app/tests/oauth2_provider/views/test_authorization_root.py new file mode 100644 index 000000000..f618f3a0e --- /dev/null +++ b/test_app/tests/oauth2_provider/views/test_authorization_root.py @@ -0,0 +1,23 @@ +from django.urls import reverse + + +def test_oauth2_provider_authorization_root_view_as_admin(admin_api_client): + """ + As an admin, accessing /o/ gives an index of oauth endpoints. + """ + url = reverse("oauth_authorization_root_view") + response = admin_api_client.get(url) + + assert response.status_code == 200 + assert 'authorize' in response.data + + +def test_oauth2_provider_authorization_root_view_anon(client): + """ + As an anonymous user, accessing /o/ gives an index of oauth endpoints. + """ + url = reverse("oauth_authorization_root_view") + response = client.get(url) + + assert response.status_code == 200 + assert 'authorize' in response.data diff --git a/test_app/tests/oauth2_provider/views/test_authorize.py b/test_app/tests/oauth2_provider/views/test_authorize.py new file mode 100644 index 000000000..19b60f35c --- /dev/null +++ b/test_app/tests/oauth2_provider/views/test_authorize.py @@ -0,0 +1,55 @@ +from django.urls import reverse +from django.utils.http import urlencode + + +def test_oauth2_provider_authorize_view_as_admin(admin_api_client): + """ + As an admin, accessing /o/authorize/ without client_id parameter should return a 400 error. + """ + url = reverse("authorize") + response = admin_api_client.get(url) + + assert response.status_code == 400 + assert 'Missing client_id parameter.' in str(response.content) + + +def test_oauth2_provider_authorize_view_anon(client, settings): + """ + As an anonymous user, accessing /o/authorize/ should redirect to the login page. + """ + url = reverse("authorize") + response = client.get(url) + + assert response.status_code == 302 + assert response.url.startswith(settings.LOGIN_URL) + + +def test_oauth2_provider_authorize_view_flow(user_api_client, oauth2_application): + """ + As a user, I should be able to complete the authorization flow and get an authorization code. + """ + url = reverse("authorize") + query_params = { + 'client_id': oauth2_application.client_id, + 'response_type': 'code', + 'scope': 'read', + # PKCE + 'code_challenge': '4-as-randomly-generated-by-rolling-a-die', + 'code_challenge_method': 'S256', + } + + # Initial request - authorization request, should show a form to authorize the application + response = user_api_client.get(url + '?' + urlencode(query_params)) + assert response.status_code == 200, response.headers + assert f'Authorize {oauth2_application.name}' in str(response.content) + + # But the form mostly just repackages the GET params into a POST request + query_params['redirect_uri'] = oauth2_application.redirect_uris + query_params['allow'] = 'Authorize' + response = user_api_client.post(url, data=query_params) + assert response.status_code == 302 + assert response.url.startswith(query_params['redirect_uri']) + + # On success, it takes us to the redirect_uri with the code + assert 'code=' in response.url, response.url + assert 'error=' not in response.url, response.url diff --git a/test_app/views.py b/test_app/views.py index 8fe529f26..66c43af35 100644 --- a/test_app/views.py +++ b/test_app/views.py @@ -1,3 +1,5 @@ +from itertools import chain + from django.shortcuts import render from rest_framework.decorators import action, api_view from rest_framework.response import Response @@ -125,12 +127,21 @@ class UUIDModelViewSet(TestAppViewSet): def api_root(request, format=None): from ansible_base.activitystream.urls import router as activitystream_router from ansible_base.authentication.urls import router as auth_router + from ansible_base.oauth2_provider.urls import router as oauth2_provider_router from ansible_base.rbac.api.router import router as rbac_router from ansible_base.resource_registry.urls import service_router from test_app.router import router list_endpoints = {} - for url in router.urls + auth_router.urls + service_router.urls + activitystream_router.urls + rbac_router.urls: + urls = [ + activitystream_router.urls, + auth_router.urls, + oauth2_provider_router.urls, + rbac_router.urls, + router.urls, + service_router.urls, + ] + for url in chain(*urls): # only want "root" list views, for example: # want '^users/$' [name='user-list'] # do not want '^users/(?P[^/.]+)/organizations/$' [name='user-organizations-list'], From e7dcb01f306a71aea25b3b1278c92c51f0a9ca2b Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 19 Apr 2024 22:55:39 +0200 Subject: [PATCH 09/46] Port a few tests from AWX Signed-off-by: Rick Elrod --- .../models/test_application.py | 38 ------------------- .../oauth2_provider/views/test_application.py | 24 ++++++++++++ 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/test_app/tests/oauth2_provider/models/test_application.py b/test_app/tests/oauth2_provider/models/test_application.py index 86853f03a..2b94ce7f3 100644 --- a/test_app/tests/oauth2_provider/models/test_application.py +++ b/test_app/tests/oauth2_provider/models/test_application.py @@ -1,42 +1,4 @@ # @pytest.mark.django_db -# def test_oauth2_application_create(admin, organization, post): -# response = post( -# reverse('api:o_auth2_application_list'), -# { -# 'name': 'test app', -# 'organization': organization.pk, -# 'client_type': 'confidential', -# 'authorization_grant_type': 'password', -# }, -# admin, -# expect=201, -# ) -# assert 'modified' in response.data -# assert 'updated' not in response.data -# created_app = Application.objects.get(client_id=response.data['client_id']) -# assert created_app.name == 'test app' -# assert created_app.skip_authorization is False -# assert created_app.redirect_uris == '' -# assert created_app.client_type == 'confidential' -# assert created_app.authorization_grant_type == 'password' -# assert created_app.organization == organization -# -# -# @pytest.mark.django_db -# def test_oauth2_validator(admin, oauth_application, post): -# post( -# reverse('api:o_auth2_application_list'), -# { -# 'name': 'Write App Token', -# 'application': oauth_application.pk, -# 'scope': 'Write', -# }, -# admin, -# expect=400, -# ) -# -# -# @pytest.mark.django_db # def test_oauth_application_update(oauth_application, organization, patch, admin, alice): # patch( # reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index 2612e43ab..6b30139da 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -77,3 +77,27 @@ def test_oauth2_provider_application_create(request, client_fixture, expected_st if expected_status == 201: assert response.data['name'] == name assert OAuth2Application.objects.get(pk=response.data['id']).organization == organization + + created_app = OAuth2Application.objects.get(client_id=response.data['client_id']) + assert created_app.name == name + assert not created_app.skip_authorization + assert created_app.redirect_uris == 'http://example.com/callback' + assert created_app.client_type == 'confidential' + assert created_app.authorization_grant_type == 'authorization-code' + assert created_app.organization == organization + + +def test_oauth2_provider_application_validator(admin_api_client): + """ + If we don't get enough information in the request, we should 400 + """ + url = reverse("application-list") + response = admin_api_client.post( + url, + data={ + 'name': 'test app', + 'authorization_grant_type': 'authorization-code', + 'client_type': 'confidential', + }, + ) + assert response.status_code == 400 From 67f7ffc9727e904a7ba02f3db1f5621b4c957246 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sat, 20 Apr 2024 01:42:55 +0200 Subject: [PATCH 10/46] Port another test Signed-off-by: Rick Elrod --- .../models/test_application.py | 22 ---------- .../oauth2_provider/views/test_application.py | 43 +++++++++++++++++-- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/test_app/tests/oauth2_provider/models/test_application.py b/test_app/tests/oauth2_provider/models/test_application.py index 2b94ce7f3..1656b6e25 100644 --- a/test_app/tests/oauth2_provider/models/test_application.py +++ b/test_app/tests/oauth2_provider/models/test_application.py @@ -1,26 +1,4 @@ # @pytest.mark.django_db -# def test_oauth_application_update(oauth_application, organization, patch, admin, alice): -# patch( -# reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), -# { -# 'name': 'Test app with immutable grant type and user', -# 'organization': organization.pk, -# 'redirect_uris': 'http://localhost/api/', -# 'authorization_grant_type': 'password', -# 'skip_authorization': True, -# }, -# admin, -# expect=200, -# ) -# updated_app = Application.objects.get(client_id=oauth_application.client_id) -# assert updated_app.name == 'Test app with immutable grant type and user' -# assert updated_app.redirect_uris == 'http://localhost/api/' -# assert updated_app.skip_authorization is True -# assert updated_app.authorization_grant_type == 'password' -# assert updated_app.organization == organization -# -# -# @pytest.mark.django_db # def test_oauth_application_encryption(admin, organization, post): # response = post( # reverse('api:o_auth2_application_list'), diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index 6b30139da..cc4591868 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -9,7 +9,7 @@ [ ("admin_api_client", 200), ("user_api_client", 200), - ("client", 401), + ("unauthenticated_api_client", 401), ], ) @pytest.mark.django_db @@ -31,7 +31,7 @@ def test_oauth2_provider_application_list(request, client_fixture, expected_stat [ ("admin_api_client", 200), ("user_api_client", 200), - ("client", 401), + ("unauthenticated_api_client", 401), ], ) @pytest.mark.django_db @@ -52,7 +52,7 @@ def test_oauth2_provider_application_detail(request, client_fixture, expected_st [ ("admin_api_client", 201), ("user_api_client", 201), - ("client", 401), + ("unauthenticated_api_client", 401), ], ) def test_oauth2_provider_application_create(request, client_fixture, expected_status, randname, organization): @@ -101,3 +101,40 @@ def test_oauth2_provider_application_validator(admin_api_client): }, ) assert response.status_code == 400 + + +@pytest.mark.parametrize( + "client_fixture,expected_status", + [ + ("admin_api_client", 200), + ("user_api_client", 200), + ("unauthenticated_api_client", 401), + ], +) +@pytest.mark.django_db +def test_oauth2_provider_application_update(request, client_fixture, expected_status, oauth2_application): + """ + Test that we can update oauth2 applications iff we are authenticated. + """ + client = request.getfixturevalue(client_fixture) + url = reverse("application-detail", args=[oauth2_application.pk]) + response = client.patch( + url, + data={ + 'name': 'Updated Name', + 'description': 'Updated Description', + 'redirect_uris': 'http://example.com/updated', + 'client_type': 'public', + }, + ) + assert response.status_code == expected_status, response.data + if expected_status == 200: + assert response.data['name'] == 'Updated Name' + assert response.data['description'] == 'Updated Description' + assert response.data['redirect_uris'] == 'http://example.com/updated' + assert response.data['client_type'] == 'public' + oauth2_application.refresh_from_db() + assert oauth2_application.name == 'Updated Name' + assert oauth2_application.description == 'Updated Description' + assert oauth2_application.redirect_uris == 'http://example.com/updated' + assert oauth2_application.client_type == 'public' From 72a02d14c3cf3ca23f44cdcf3d4540bf172d3193 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sat, 20 Apr 2024 03:12:40 +0200 Subject: [PATCH 11/46] ... And this is why we test :) Signed-off-by: Rick Elrod --- .../oauth2_provider/models/application.py | 2 +- .../serializers/application.py | 7 ++- .../models/test_application.py | 20 ------ .../oauth2_provider/views/test_application.py | 61 +++++++++++++++++++ 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index ef5cd29e7..0b6eaf4de 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -15,7 +15,7 @@ class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): reverse_name_override = 'application' - encrtyped_fields = ['client_secret'] + encrypted_fields = ['client_secret'] class Meta(oauth2_models.AbstractAccessToken.Meta): verbose_name = _('application') diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py index 57bc059a1..a2a0a6fa6 100644 --- a/ansible_base/oauth2_provider/serializers/application.py +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext_lazy as _ from ansible_base.lib.serializers.common import NamedCommonModelSerializer -from ansible_base.lib.utils.encryption import ENCRYPTED_STRING +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption from ansible_base.oauth2_provider.models import OAuth2Application @@ -31,8 +31,9 @@ class Meta: def to_representation(self, obj): ret = super(OAuth2ApplicationSerializer, self).to_representation(obj) request = self.context.get('request', None) - if not request or (request.method != 'POST' and obj.client_type == 'confidential'): - ret['client_secret'] = ENCRYPTED_STRING + if request and request.method == 'POST': + # Only return the (decrypted) client_secret on the initial create + ret['client_secret'] = ansible_encryption.decrypt_string(obj.client_secret) if obj.client_type == 'public': ret.pop('client_secret', None) return ret diff --git a/test_app/tests/oauth2_provider/models/test_application.py b/test_app/tests/oauth2_provider/models/test_application.py index 1656b6e25..53d507853 100644 --- a/test_app/tests/oauth2_provider/models/test_application.py +++ b/test_app/tests/oauth2_provider/models/test_application.py @@ -1,24 +1,4 @@ # @pytest.mark.django_db -# def test_oauth_application_encryption(admin, organization, post): -# response = post( -# reverse('api:o_auth2_application_list'), -# { -# 'name': 'test app', -# 'organization': organization.pk, -# 'client_type': 'confidential', -# 'authorization_grant_type': 'password', -# }, -# admin, -# expect=201, -# ) -# pk = response.data.get('id') -# secret = response.data.get('client_secret') -# with connection.cursor() as cursor: -# encrypted = cursor.execute('SELECT client_secret FROM main_oauth2application WHERE id={}'.format(pk)).fetchone()[0] -# assert encrypted.startswith('$encrypted$') -# assert decrypt_value(get_encryption_key('value', pk=None), encrypted) == secret -# -# @pytest.mark.django_db # def test_oauth_token_create(oauth_application, get, post, admin): # response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) # assert 'modified' in response.data and response.data['modified'] is not None diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index cc4591868..a6afa9b42 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -1,6 +1,8 @@ import pytest +from django.db import connection from django.urls import reverse +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption from ansible_base.oauth2_provider.models import OAuth2Application @@ -138,3 +140,62 @@ def test_oauth2_provider_application_update(request, client_fixture, expected_st assert oauth2_application.description == 'Updated Description' assert oauth2_application.redirect_uris == 'http://example.com/updated' assert oauth2_application.client_type == 'public' + + +def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, organization): + """ + The client_secret should be encrypted in the database. + We only show it to the user once, on creation. All other requests should show the encrypted value. + """ + url = reverse("application-list") + response = admin_api_client.post( + url, + data={ + 'name': 'Test Application', + 'description': 'Test Description', + 'organization': organization.pk, + 'redirect_uris': 'http://example.com/callback', + 'authorization_grant_type': 'authorization-code', + 'client_type': 'confidential', + }, + ) + assert response.status_code == 201, response.data + application = OAuth2Application.objects.get(pk=response.data['id']) + with connection.cursor() as cursor: + cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk]) + encrypted = cursor.fetchone()[0] + assert encrypted.startswith(ENCRYPTED_STRING), encrypted + assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'] + + # GET + response = admin_api_client.get(reverse("application-detail", args=[application.pk])) + assert response.status_code == 200 + assert response.data['client_secret'] == ENCRYPTED_STRING, response.data + + # PATCH + response = admin_api_client.patch( + reverse("application-detail", args=[application.pk]), + data={'name': 'Updated Name'}, + ) + assert response.status_code == 200 + assert response.data['client_secret'] == ENCRYPTED_STRING, response.data + + # PUT + response = admin_api_client.put( + reverse("application-detail", args=[application.pk]), + data={ + 'name': 'Updated Name', + 'description': 'Updated Description', + 'organization': organization.pk, + 'redirect_uris': 'http://example.com/updated', + 'client_type': 'public', + 'authorization_grant_type': 'password', + }, + ) + assert response.status_code == 200 + assert 'client_secret' not in response.data + + # DELETE + response = admin_api_client.delete(reverse("application-detail", args=[application.pk])) + assert response.status_code == 204 + assert response.data is None, response.data From edc486cc03854ba7010d835d9f0a48f4061b7f56 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sun, 21 Apr 2024 04:01:34 +0200 Subject: [PATCH 12/46] Rework application serializer a bit, client_secret Signed-off-by: Rick Elrod --- .../serializers/application.py | 53 ++++++++----------- .../oauth2_provider/views/test_application.py | 4 +- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py index a2a0a6fa6..e82423989 100644 --- a/ansible_base/oauth2_provider/serializers/application.py +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ from ansible_base.lib.serializers.common import NamedCommonModelSerializer @@ -11,8 +12,6 @@ def has_model_field_prefetched(obj, thing): class OAuth2ApplicationSerializer(NamedCommonModelSerializer): - reverse_url_name = 'application-detail' - class Meta: model = OAuth2Application fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in OAuth2Application._meta.concrete_fields] @@ -28,36 +27,30 @@ class Meta: 'skip_authorization': {'label': _('Skip Authorization')}, } - def to_representation(self, obj): - ret = super(OAuth2ApplicationSerializer, self).to_representation(obj) + def _get_client_secret(self, obj): request = self.context.get('request', None) - if request and request.method == 'POST': - # Only return the (decrypted) client_secret on the initial create - ret['client_secret'] = ansible_encryption.decrypt_string(obj.client_secret) - if obj.client_type == 'public': - ret.pop('client_secret', None) - return ret - - def get_related(self, obj): - res = super(OAuth2ApplicationSerializer, self).get_related(obj) - res.update( - dict( - tokens=self.reverse('api:o_auth2_application_token_list', kwargs={'pk': obj.pk}), - activity_stream=self.reverse('api:o_auth2_application_activity_stream_list', kwargs={'pk': obj.pk}), - ) - ) - if obj.organization_id: - res.update( - dict( - organization=self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id}), - ) - ) - return res + try: + if obj.client_type == 'public': + return None + elif request.method == 'POST': + return ansible_encryption.decrypt_string(obj.client_secret) + else: + return ENCRYPTED_STRING + except ObjectDoesNotExist: + return '' - def get_modified(self, obj): - if obj is None: - return None - return obj.updated + def to_representation(self, instance): + # We have to override this because in AbstractCommonModelSerializer, we'll + # auto-force all encrypted fields to ENCRYPTED_STRING. Usually that's fine, + # but we want to show the client_secret on POST. Ideally we'd just use + # get_client_secret() and a SerializerMethodField. + ret = super().to_representation(instance) + secret = self._get_client_secret(instance) + if secret is None: + del ret['client_secret'] + else: + ret['client_secret'] = self._get_client_secret(instance) + return ret def _summary_field_tokens(self, obj): token_list = [{'id': x.pk, 'token': ENCRYPTED_STRING, 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index a6afa9b42..feccd676f 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -148,6 +148,8 @@ def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, o We only show it to the user once, on creation. All other requests should show the encrypted value. """ url = reverse("application-list") + + # POST response = admin_api_client.post( url, data={ @@ -165,7 +167,7 @@ def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, o cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk]) encrypted = cursor.fetchone()[0] assert encrypted.startswith(ENCRYPTED_STRING), encrypted - assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'] + assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'], response.data # GET response = admin_api_client.get(reverse("application-detail", args=[application.pk])) From d25de4f0efe3dbef3eaa9985d60837343a859e59 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sun, 21 Apr 2024 18:15:59 +0200 Subject: [PATCH 13/46] Get "related" working for application tokens Signed-off-by: Rick Elrod --- ...003_alter_oauth2accesstoken_application.py | 20 +++++++++++ .../oauth2_provider/models/access_token.py | 15 +++++--- .../oauth2_provider/models/application.py | 5 +-- .../oauth2_provider/serializers/token.py | 1 - ansible_base/oauth2_provider/urls.py | 33 ++++++++---------- .../oauth2_provider/views/token_root.py | 29 ---------------- .../oauth2_provider/views/test_application.py | 34 +++++++++++++++++++ 7 files changed, 81 insertions(+), 56 deletions(-) create mode 100644 ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py delete mode 100644 ansible_base/oauth2_provider/views/token_root.py diff --git a/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py b/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py new file mode 100644 index 000000000..0d0f0874f --- /dev/null +++ b/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2024-04-21 15:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dab_oauth2_provider', '0002_alter_oauth2accesstoken_created_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + ] diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index ed0516985..a3e950c58 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -11,11 +11,8 @@ class OAuth2AccessToken(oauth2_models.AbstractAccessToken, CommonModel): - reverse_name_override = 'token' - # There is a special condition where, as the user is logging in we want to update the last_used field. - # However, this happens before the user is set for the request. - # If this is the only field attempting to be saved, don't update the modified on/by fields - not_user_modified_fields = ['last_used'] + router_basename = 'token' + ignore_relations = ['refresh_token'] class Meta(oauth2_models.AbstractAccessToken.Meta): verbose_name = _('access token') @@ -30,6 +27,14 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): related_name="%(app_label)s_%(class)s", help_text=_('The user representing the token owner'), ) + # Overriding to set related_name + application = models.ForeignKey( + settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name='access_tokens', + ) description = models.TextField( default='', blank=True, diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index 0b6eaf4de..ce75c19d8 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -14,7 +14,8 @@ class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): - reverse_name_override = 'application' + router_basename = 'application' + ignore_relations = ['oauth2idtoken', 'grant', 'oauth2refreshtoken'] encrypted_fields = ['client_secret'] class Meta(oauth2_models.AbstractAccessToken.Meta): @@ -67,4 +68,4 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): def get_absolute_url(self): # This is kind of annoying. This method lives on the superclass and we check for it in CommonModel. # But better would be to not have this method and let the CommonModel logic fall back to the "right" way of finding this. - return reverse('application-detail', kwargs={'pk': self.pk}) + return reverse(f'{self.router_basename}-detail', kwargs={'pk': self.pk}) diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index 034e152c6..5bace509b 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -18,7 +18,6 @@ class BaseOAuth2TokenSerializer(CommonModelSerializer): - reverse_url_name = 'token-detail' refresh_token = SerializerMethodField() token = SerializerMethodField() ALLOWED_SCOPES = ['read', 'write'] diff --git a/ansible_base/oauth2_provider/urls.py b/ansible_base/oauth2_provider/urls.py index baa815ed9..24e673610 100644 --- a/ansible_base/oauth2_provider/urls.py +++ b/ansible_base/oauth2_provider/urls.py @@ -9,30 +9,25 @@ router = AssociationResourceRouter() -router.register(r'applications', oauth2_provider_views.OAuth2ApplicationViewSet, basename='application') - -router.register(r'tokens', oauth2_provider_views.OAuth2TokenViewSet, basename='token') +router.register( + r'applications', + oauth2_provider_views.OAuth2ApplicationViewSet, + basename='application', + related_views={ + 'tokens': (oauth2_provider_views.OAuth2TokenViewSet, 'access_tokens'), + }, +) + +router.register( + r'tokens', + oauth2_provider_views.OAuth2TokenViewSet, + basename='token', +) api_version_urls = [ path('', include(router.urls)), ] -# re_path( -# r'^applications/(?P[0-9]+)/tokens/$', -# oauth2_provider_views.ApplicationOAuth2TokenList.as_view(), -# name='o_auth2_application_token_list' -# ), -# re_path( -# r'^applications/(?P[0-9]+)/activity_stream/$', -# oauth2_provider_views.OAuth2ApplicationActivityStreamList.as_view(), -# name='o_auth2_application_activity_stream_list' -# ), -# re_path( -# r'^tokens/(?P[0-9]+)/activity_stream/$', -# oauth2_provider_views.OAuth2TokenActivityStreamList.as_view(), -# name='o_auth2_token_activity_stream_list' -# ), - root_urls = [ re_path(r'^o/$', oauth2_provider_views.ApiOAuthAuthorizationRootView.as_view(), name='oauth_authorization_root_view'), re_path(r"^o/authorize/$", oauth_views.AuthorizationView.as_view(), name="authorize"), diff --git a/ansible_base/oauth2_provider/views/token_root.py b/ansible_base/oauth2_provider/views/token_root.py deleted file mode 100644 index b40b70ed1..000000000 --- a/ansible_base/oauth2_provider/views/token_root.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import timedelta - -from django.conf import settings -from django.utils.timezone import now -from oauth2_provider.views import TokenView -from oauthlib.oauth2 import AccessDeniedError - -from ansible_base.oauth2_provider.models import OAuth2RefreshToken - - -class TokenView(TokenView): - def create_token_response(self, request): - # Django OAuth2 Toolkit has a bug whereby refresh tokens are *never* - # properly expired (ugh): - # - # https://github.com/jazzband/django-oauth-toolkit/issues/746 - # - # This code detects and auto-expires them on refresh grant - # requests. - if request.POST.get('grant_type') == 'refresh_token' and 'refresh_token' in request.POST: - refresh_token = OAuth2RefreshToken.objects.filter(token=request.POST['refresh_token']).first() - if refresh_token: - expire_seconds = settings.OAUTH2_PROVIDER.get('REFRESH_TOKEN_EXPIRE_SECONDS', 0) - if refresh_token.created + timedelta(seconds=expire_seconds) < now(): - return request.build_absolute_uri(), {}, 'The refresh token has expired.', '403' - try: - return super(TokenView, self).create_token_response(request) - except AccessDeniedError as e: - return request.build_absolute_uri(), {}, str(e), '403' diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index feccd676f..248939ee3 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -28,6 +28,40 @@ def test_oauth2_provider_application_list(request, client_fixture, expected_stat assert response.data['results'][0]['name'] == oauth2_application.name +@pytest.mark.parametrize( + "view, path", + [ + ("application-list", lambda data: data['results'][0]), + ("application-detail", lambda data: data), + ], +) +def test_oauth2_provider_application_related(admin_api_client, oauth2_application, organization, view, path): + """ + Test that the related fields are correct. + + Organization should only be shown if the application is associated with an organization. + Associating an application with an organization should not affect other related fields. + """ + if view == "application-list": + url = reverse(view) + else: + url = reverse(view, args=[oauth2_application.pk]) + + oauth2_application.organization = None + oauth2_application.save() + response = admin_api_client.get(url) + assert response.status_code == 200 + assert path(response.data)['related']['access_tokens'] == reverse("application-access_tokens-list", args=[oauth2_application.pk]) + assert 'organization' not in path(response.data)['related'] + + oauth2_application.organization = organization + oauth2_application.save() + response = admin_api_client.get(url) + assert response.status_code == 200 + assert path(response.data)['related']['access_tokens'] == reverse("application-access_tokens-list", args=[oauth2_application.pk]) + assert path(response.data)['related']['organization'] == reverse("organization-detail", args=[organization.pk]) + + @pytest.mark.parametrize( "client_fixture,expected_status", [ From 4304f2ecd76eafe25515c200da62db5f06fcf607 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 23 Apr 2024 14:41:24 +0200 Subject: [PATCH 14/46] Create an OAuth2Application in demo data Signed-off-by: Rick Elrod --- test_app/management/commands/create_demo_data.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test_app/management/commands/create_demo_data.py b/test_app/management/commands/create_demo_data.py index cc771b5c2..f9289bbdf 100644 --- a/test_app/management/commands/create_demo_data.py +++ b/test_app/management/commands/create_demo_data.py @@ -7,6 +7,7 @@ from django.core.management.base import BaseCommand from ansible_base.authentication.models import Authenticator, AuthenticatorUser +from ansible_base.oauth2_provider.models import OAuth2Application from ansible_base.rbac.models import RoleDefinition from ansible_base.rbac.validators import combine_values, permissions_allowed_for_role from test_app.models import EncryptionModel, InstanceGroup, Inventory, Organization, Team, User @@ -122,6 +123,14 @@ def handle(self, *args, **kwargs): team_member.give_permission(spud, awx_devs) + OAuth2Application.objects.get_or_create( + name="Demo OAuth2 Application", + description="Demo OAuth2 Application", + redirect_uris="http://example.com/callback", + authorization_grant_type="authorization-code", + client_type="confidential", + ) + self.stdout.write('Finished creating demo data!') self.stdout.write(f'Admin user password: {admin_password}') From 9898ac0fc72db04137f276ea6f7cccccff777020 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sat, 27 Apr 2024 04:18:26 +0200 Subject: [PATCH 15/46] Add simple tests showing token auth works Signed-off-by: Rick Elrod --- test_app/tests/oauth2_provider/conftest.py | 17 ++- .../oauth2_provider/test_authentication.py | 118 ++++++++++++++++++ test_app/views.py | 6 + 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 test_app/tests/oauth2_provider/test_authentication.py diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index 9b6831fbf..fd751b2fb 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -1,6 +1,9 @@ +from datetime import datetime, timezone + import pytest +from oauthlib.common import generate_token -from ansible_base.oauth2_provider.models import OAuth2Application +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2Application @pytest.fixture @@ -12,3 +15,15 @@ def oauth2_application(randname): authorization_grant_type="authorization-code", client_type="confidential", ) + + +@pytest.fixture +def oauth2_admin_access_token(oauth2_application, admin_user): + return OAuth2AccessToken.objects.get_or_create( + user=admin_user, + application=oauth2_application, + description="Test Access Token", + # This has to be timezone aware + expires=datetime(2088, 1, 1, tzinfo=timezone.utc), + token=generate_token(), + )[0] diff --git a/test_app/tests/oauth2_provider/test_authentication.py b/test_app/tests/oauth2_provider/test_authentication.py new file mode 100644 index 000000000..a99abee90 --- /dev/null +++ b/test_app/tests/oauth2_provider/test_authentication.py @@ -0,0 +1,118 @@ +import pytest +from django.urls import reverse +from oauthlib.common import generate_token + + +def test_oauth2_bearer_get_user_correct(unauthenticated_api_client, oauth2_admin_access_token): + """ + Perform a GET with a bearer token and ensure the authed user is correct. + """ + url = reverse("user-me") + response = unauthenticated_api_client.get( + url, + headers={'Authorization': f'Bearer {oauth2_admin_access_token.token}'}, + ) + assert response.status_code == 200 + assert response.data['username'] == oauth2_admin_access_token.user.username + + +@pytest.mark.parametrize( + 'token, expected', + [ + ('fixture', 200), + ('bad', 401), + ], +) +def test_oauth2_bearer_get(unauthenticated_api_client, oauth2_admin_access_token, animal, token, expected): + """ + GET an animal with a bearer token. + """ + url = reverse("animal-detail", kwargs={"pk": animal.pk}) + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + response = unauthenticated_api_client.get( + url, + headers={'Authorization': f'Bearer {token}'}, + ) + assert response.status_code == expected + if expected != 401: + assert response.data['name'] == animal.name + + +@pytest.mark.parametrize( + 'token, expected', + [ + ('fixture', 201), + ('bad', 401), + ], +) +def test_oauth2_bearer_post(unauthenticated_api_client, oauth2_admin_access_token, admin_user, token, expected): + """ + POST an animal with a bearer token. + """ + url = reverse("animal-list") + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + data = { + "name": "Fido", + "owner": admin_user.pk, + } + response = unauthenticated_api_client.post( + url, + data=data, + headers={'Authorization': f'Bearer {token}'}, + ) + assert response.status_code == expected + if expected != 401: + assert response.data['name'] == 'Fido' + + +@pytest.mark.parametrize( + 'token, expected', + [ + ('fixture', 200), + ('bad', 401), + ], +) +def test_oauth2_bearer_patch(unauthenticated_api_client, oauth2_admin_access_token, animal, admin_user, token, expected): + """ + PATCH an animal with a bearer token. + """ + url = reverse("animal-detail", kwargs={"pk": animal.pk}) + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + data = { + "name": "Fido", + } + response = unauthenticated_api_client.patch( + url, + data=data, + headers={'Authorization': f'Bearer {token}'}, + ) + assert response.status_code == expected + if expected != 401: + assert response.data['name'] == 'Fido' + + +@pytest.mark.parametrize( + 'token, expected', + [ + ('fixture', 200), + ('bad', 401), + ], +) +def test_oauth2_bearer_put(unauthenticated_api_client, oauth2_admin_access_token, animal, admin_user, token, expected): + """ + PUT an animal with a bearer token. + """ + url = reverse("animal-detail", kwargs={"pk": animal.pk}) + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + data = { + "name": "Fido", + "owner": admin_user.pk, + } + response = unauthenticated_api_client.put( + url, + data=data, + headers={'Authorization': f'Bearer {token}'}, + ) + assert response.status_code == expected + if expected != 401: + assert response.data['name'] == 'Fido' diff --git a/test_app/views.py b/test_app/views.py index 66c43af35..3ac5603f2 100644 --- a/test_app/views.py +++ b/test_app/views.py @@ -60,6 +60,12 @@ def filter_queryset(self, qs): qs = self.apply_optimizations(qs) return qs + @action(detail=False, methods=['get']) + def me(self, request, pk=None): + user = request.user + serializer = self.get_serializer(user) + return Response(serializer.data) + class EncryptionModelViewSet(TestAppViewSet): serializer_class = serializers.EncryptionModelSerializer From 586efbc5049028c88424d4964c289ced9fcf606b Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Mon, 29 Apr 2024 03:10:04 +0200 Subject: [PATCH 16/46] Don't treat Application.client_secret as encrypted In newer DOT than what AWX uses, Application.client_secret is hashed automatically with no way to disable that functionality. There's a PR that allows for disabling that functionality ([0]), but that hasn't made it into a release. The DOT hashing is incompatible with our standard encryption - when DOT gets the value it ends up getting our encrypted string and trying to act on that. Ideally we'd like to disable their hashing entirely and use our standard encryption tooling. AWX avoids this problem by pinning to an older DOT. For now in DAB we'll just use the upstream hashing, and not treat the field as an encrypted_fields field to avoid the "double encryption" issue. [0]: https://github.com/jazzband/django-oauth-toolkit/pull/1311 Signed-off-by: Rick Elrod --- ...4_alter_oauth2application_client_secret.py | 20 ++++++++ .../oauth2_provider/models/application.py | 19 ++++---- .../serializers/application.py | 37 ++++++++++++-- test_app/settings.py | 5 ++ test_app/tests/oauth2_provider/conftest.py | 10 +++- .../oauth2_provider/views/test_application.py | 30 ++++++++---- .../tests/oauth2_provider/views/test_token.py | 48 +++++++++++++------ 7 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py diff --git a/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py b/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py new file mode 100644 index 000000000..a1e625186 --- /dev/null +++ b/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2024-04-28 16:14 + +from django.db import migrations +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dab_oauth2_provider', '0003_alter_oauth2accesstoken_application'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2application', + 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), + ), + ] diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index ce75c19d8..0ddac8d35 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -6,7 +6,6 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from oauth2_provider.generators import generate_client_secret from ansible_base.lib.abstract_models.common import NamedCommonModel @@ -16,7 +15,8 @@ class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): router_basename = 'application' ignore_relations = ['oauth2idtoken', 'grant', 'oauth2refreshtoken'] - encrypted_fields = ['client_secret'] + # We do NOT add client_secret to encrypted_fields because it is hashed by Django OAuth Toolkit + # and it would end up hashing the encrypted value. class Meta(oauth2_models.AbstractAccessToken.Meta): verbose_name = _('application') @@ -50,13 +50,14 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): on_delete=models.CASCADE, null=True, ) - client_secret = models.CharField( - max_length=1024, - blank=True, - default=generate_client_secret, - db_index=True, - help_text=_('Used for more stringent verification of access to an application when creating a token.'), - ) + # Not overriding client_secret... Details: + # It would be nice to just use our usual encrypted_fields flow here + # until DOT makes a release with https://github.com/jazzband/django-oauth-toolkit/pull/1311 + # there is no way to disable its expectation of using its own hashing + # (which is Django's make_password/check_password). + # So we use their field here. + # Previous versions of DOT didn't hash the field at all and AWX pins + # to <2.0.0 so AWX used the AWX encryption with no issue. client_type = models.CharField( max_length=32, choices=CLIENT_TYPES, help_text=_('Set to Public or Confidential depending on how secure the client device is.') ) diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py index e82423989..db7b74023 100644 --- a/ansible_base/oauth2_provider/serializers/application.py +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -1,8 +1,9 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ +from oauth2_provider.generators import generate_client_secret from ansible_base.lib.serializers.common import NamedCommonModelSerializer -from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING from ansible_base.oauth2_provider.models import OAuth2Application @@ -12,6 +13,8 @@ def has_model_field_prefetched(obj, thing): class OAuth2ApplicationSerializer(NamedCommonModelSerializer): + oauth2_client_secret = None + class Meta: model = OAuth2Application fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in OAuth2Application._meta.concrete_fields] @@ -33,7 +36,11 @@ def _get_client_secret(self, obj): if obj.client_type == 'public': return None elif request.method == 'POST': - return ansible_encryption.decrypt_string(obj.client_secret) + if self.oauth2_client_secret is None: + # This should be an impossible case, but... + return ENCRYPTED_STRING + # Show the secret, one time, on POST + return self.oauth2_client_secret else: return ENCRYPTED_STRING except ObjectDoesNotExist: @@ -49,7 +56,7 @@ def to_representation(self, instance): if secret is None: del ret['client_secret'] else: - ret['client_secret'] = self._get_client_secret(instance) + ret['client_secret'] = secret return ret def _summary_field_tokens(self, obj): @@ -67,3 +74,27 @@ def get_summary_fields(self, obj): ret = super(OAuth2ApplicationSerializer, self).get_summary_fields(obj) ret['tokens'] = self._summary_field_tokens(obj) return ret + + def create(self, validated_data): + # This is hacky: + # There is a cascading set of issues here. + # 1. The first thing to know is that DOT automatically hashes the client_secret + # in a pre_save method on the client_secret field. + # 2. In current released versions, there is no way to disable (1). It uses + # the built-in Django password hashing stuff to do this. There's a merged + # PR to allow disabling this (DOT #1311), but it's not released yet. + # 3. If we use our own encrypted_field stuff, it conflicts with (1) and (2). + # They end up giving our encrypted field to Django's password check + # and *we* end up showing *their* hashed value to the user on POST, which + # doesn't work, the user needs to see the real (decrypted) value. So + # until upstream #1311 is released, we do NOT treat the field as an + # encrypted_field, we just defer to the upstream hashing. + # 4. But we have no way to see the client_secret on POST, if we let the + # model generate it, because it's hashed by the time we get to the + # serializer... + # + # So to that end, on POST, we'll make the client secret here, and then + # we can access it to show the user the value (once) on POST. + validated_data['client_secret'] = generate_client_secret() + self.oauth2_client_secret = validated_data['client_secret'] + return super().create(validated_data) diff --git a/test_app/settings.py b/test_app/settings.py index bcf7096e9..64a35884f 100644 --- a/test_app/settings.py +++ b/test_app/settings.py @@ -35,6 +35,11 @@ 'handlers': ['console'], 'level': 'DEBUG', }, + '': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': True, + }, }, } for logger in LOGGING["loggers"]: # noqa: F405 diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index fd751b2fb..e0e0081e1 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -8,13 +8,21 @@ @pytest.fixture def oauth2_application(randname): - return OAuth2Application.objects.create( + """ + Creates an OAuth2 application with a random name and returns + both the application and its client secret. + """ + app = OAuth2Application( name=randname("OAuth2 Application"), description="Test OAuth2 Application", redirect_uris="http://example.com/callback", authorization_grant_type="authorization-code", client_type="confidential", ) + # Store this before it gets hashed + secret = app.client_secret + app.save() + return (app, secret) @pytest.fixture diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index 248939ee3..351af75c7 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -1,8 +1,8 @@ import pytest -from django.db import connection +from django.contrib.auth.hashers import check_password from django.urls import reverse -from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING from ansible_base.oauth2_provider.models import OAuth2Application @@ -25,7 +25,7 @@ def test_oauth2_provider_application_list(request, client_fixture, expected_stat assert response.status_code == expected_status if expected_status == 200: assert len(response.data['results']) == OAuth2Application.objects.count() - assert response.data['results'][0]['name'] == oauth2_application.name + assert response.data['results'][0]['name'] == oauth2_application[0].name @pytest.mark.parametrize( @@ -42,6 +42,7 @@ def test_oauth2_provider_application_related(admin_api_client, oauth2_applicatio Organization should only be shown if the application is associated with an organization. Associating an application with an organization should not affect other related fields. """ + oauth2_application = oauth2_application[0] if view == "application-list": url = reverse(view) else: @@ -75,6 +76,7 @@ def test_oauth2_provider_application_detail(request, client_fixture, expected_st """ Test that we can view the detail of an OAuth2 application iff we are authenticated. """ + oauth2_application = oauth2_application[0] client = request.getfixturevalue(client_fixture) url = reverse("application-detail", args=[oauth2_application.pk]) response = client.get(url) @@ -152,6 +154,7 @@ def test_oauth2_provider_application_update(request, client_fixture, expected_st """ Test that we can update oauth2 applications iff we are authenticated. """ + oauth2_application = oauth2_application[0] client = request.getfixturevalue(client_fixture) url = reverse("application-detail", args=[oauth2_application.pk]) response = client.patch( @@ -197,11 +200,22 @@ def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, o ) assert response.status_code == 201, response.data application = OAuth2Application.objects.get(pk=response.data['id']) - with connection.cursor() as cursor: - cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk]) - encrypted = cursor.fetchone()[0] - assert encrypted.startswith(ENCRYPTED_STRING), encrypted - assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'], response.data + + # If we ever switch to using *our* encryption, this is a good test. + # But until a release with jazzband/django-oauth-toolkit#1311 hits pypi, + # we have no way to disable their built-in hashing (which conflicts with our + # own encryption). + # with connection.cursor() as cursor: + # cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk]) + # encrypted = cursor.fetchone()[0] + # assert encrypted.startswith(ENCRYPTED_STRING), encrypted + # assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'], response.data + # assert response.data['client_secret'] == application.client_secret + + # For now we just make sure it shows the real client secret on POST + # and never on any other method. + assert 'client_secret' in response.data + assert check_password(response.data['client_secret'], application.client_secret) # GET response = admin_api_client.get(reverse("application-detail", args=[application.pk])) diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index 00e4d198f..7c697ba23 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -1,20 +1,40 @@ +import base64 + import pytest from django.urls import reverse +from django.utils.http import urlencode + + +@pytest.mark.django_db +def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthenticated_api_client): + app = oauth2_application[0] + app.authorization_grant_type = 'password' + app.save() + + secret = oauth2_application[1] + url = reverse('token') + data = { + "grant_type": "password", + "username": "user", + "password": "password", + "scope": "read", + } + resp = unauthenticated_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + + assert resp.status_code == 200, resp.content + resp_json = resp.json() + assert 'access_token' in resp_json + assert len(resp_json['access_token']) > 0 + assert 'scope' in resp_json + assert resp_json['scope'] == 'read' + assert 'refresh_token' in resp_json + -# @pytest.mark.django_db -# def test_personal_access_token_creation(oauth_application, post, alice): -# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' -# resp = post( -# url, -# data='grant_type=password&username=alice&password=alice&scope=read', -# content_type='application/x-www-form-urlencoded', -# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), -# ) -# resp_json = smart_str(resp._container[0]) -# assert 'access_token' in resp_json -# assert 'scope' in resp_json -# assert 'refresh_token' in resp_json -# # # @pytest.mark.django_db # @pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)]) From c437d9bee6f2461d45a485650a209bfaa94d4b98 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Mon, 29 Apr 2024 20:47:01 +0200 Subject: [PATCH 17/46] Make is_external_account return the authenticator Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/utils.py | 20 ++++++++++++++------ test_app/tests/oauth2_provider/test_utils.py | 6 ++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ansible_base/oauth2_provider/utils.py b/ansible_base/oauth2_provider/utils.py index a8ba95006..51d3d341d 100644 --- a/ansible_base/oauth2_provider/utils.py +++ b/ansible_base/oauth2_provider/utils.py @@ -1,17 +1,25 @@ +from typing import Optional + from django.contrib.auth import get_user_model +from ansible_base.authentication.models import Authenticator + User = get_user_model() -def is_external_account(user: User) -> bool: +def is_external_account(user: User) -> Optional[Authenticator]: """ - Predicate which tests whether the user is associated with any external - login source. + Determines whether the user is associated with any external + login source. If they are, return the source. Otherwise, None. :param user: The user to test - :return: True if the user is associated with any external login source - False if the user is associated only with the local + :return: If the user is associated with any external login source, return it (the first, if multiple) + Otherwise, return None """ authenticator_users = user.authenticator_users.all() local = 'ansible_base.authentication.authenticator_plugins.local' - return any(auth_user.provider.type != local for auth_user in authenticator_users) + for auth_user in authenticator_users: + if auth_user.provider.type != local: + return auth_user.provider + + return None diff --git a/test_app/tests/oauth2_provider/test_utils.py b/test_app/tests/oauth2_provider/test_utils.py index a023367ed..fbe40a5a7 100644 --- a/test_app/tests/oauth2_provider/test_utils.py +++ b/test_app/tests/oauth2_provider/test_utils.py @@ -4,7 +4,7 @@ from ansible_base.oauth2_provider.utils import is_external_account -@pytest.mark.parametrize("link_local, link_ldap, expected", [(False, False, False), (True, False, False), (False, True, True), (True, True, True)]) +@pytest.mark.parametrize("link_local, link_ldap, expected", [(False, False, None), (True, False, None), (False, True, "ldap"), (True, True, "ldap")]) def test_oauth2_provider_is_external_account_with_user(user, local_authenticator, ldap_authenticator, link_local, link_ldap, expected): if link_local: # Link the user to the local authenticator @@ -15,7 +15,9 @@ def test_oauth2_provider_is_external_account_with_user(user, local_authenticator ldap_au = AuthenticatorUser(provider=ldap_authenticator, user=user) ldap_au.save() - assert is_external_account(user) is expected + if expected == "ldap": + expected = ldap_authenticator + assert is_external_account(user) == expected def test_oauth2_provider_is_external_account_import_error(user, local_authenticator): From b0e2e511cda1ab88a255b196dbf41dfebcd05e94 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Mon, 29 Apr 2024 20:49:24 +0200 Subject: [PATCH 18/46] Show the proper authenticator type in error Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/models/access_token.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index a3e950c58..4c5b81a75 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -69,7 +69,8 @@ def validate_external_users(self): external_account = is_external_account(self.user) if external_account: raise oauth2.AccessDeniedError( - _('OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})').format(external_account) + _('OAuth2 Tokens cannot be created by users associated with an external authentication provider (%(authenticator)s)') + % {'authenticator': external_account.type} ) def save(self, *args, **kwargs): From c68aa76169b67202048398a5661f8632f4444a03 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 30 Apr 2024 01:42:54 +0200 Subject: [PATCH 19/46] Welp, that took a while. Signed-off-by: Rick Elrod --- .../lib/dynamic_config/dynamic_settings.py | 2 + .../oauth2_provider/serializers/token.py | 4 +- ansible_base/oauth2_provider/views/token.py | 21 +++- test_app/tests/oauth2_provider/conftest.py | 19 ++++ .../oauth2_provider/views/test_authorize.py | 1 + .../tests/oauth2_provider/views/test_token.py | 104 +++++++++++------- 6 files changed, 109 insertions(+), 42 deletions(-) diff --git a/ansible_base/lib/dynamic_config/dynamic_settings.py b/ansible_base/lib/dynamic_config/dynamic_settings.py index a709244c2..b5c5ea896 100644 --- a/ansible_base/lib/dynamic_config/dynamic_settings.py +++ b/ansible_base/lib/dynamic_config/dynamic_settings.py @@ -200,3 +200,5 @@ OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'dab_oauth2_provider.OAuth2AccessToken' OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "dab_oauth2_provider.OAuth2RefreshToken" OAUTH2_PROVIDER_ID_TOKEN_MODEL = "dab_oauth2_provider.OAuth2IDToken" + + ALLOW_OAUTH2_FOR_EXTERNAL_USERS = False diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index 5bace509b..a2f883470 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -88,7 +88,7 @@ def validate_scope(self, value): def create(self, validated_data): validated_data['user'] = self.context['request'].user try: - return super(BaseOAuth2TokenSerializer, self).create(validated_data) + return super().create(validated_data) except AccessDeniedError as e: raise PermissionDenied(str(e)) @@ -101,7 +101,7 @@ def create(self, validated_data): if expires_delta == 0: logger.warning("OAUTH2_PROVIDER.ACCESS_TOKEN_EXPIRE_SECONDS was set to 0, creating token that has already expired") validated_data['expires'] = now() + timedelta(seconds=expires_delta) - obj = super(OAuth2TokenSerializer, self).create(validated_data) + obj = super().create(validated_data) if obj.application and obj.application.user: obj.user = obj.application.user obj.save() diff --git a/ansible_base/oauth2_provider/views/token.py b/ansible_base/oauth2_provider/views/token.py index 730d04c65..676351fc3 100644 --- a/ansible_base/oauth2_provider/views/token.py +++ b/ansible_base/oauth2_provider/views/token.py @@ -13,6 +13,12 @@ class TokenView(oauth_views.TokenView, AnsibleBaseDjangoAppApiView): + # There is a big flow of logic that happens around this behind the scenes. + # + # oauth2_provider.views.TokenView inherits from oauth2_provider.views.mixins.OAuthLibMixin + # That's where this method comes from originally. + # Then *that* method ends up calling oauth2_provider.oauth2_backends.OAuthLibCore.create_token_response + # Then *that* method ends up (ultimately) calling oauthlib.oauth2.rfc6749.... def create_token_response(self, request): # Django OAuth2 Toolkit has a bug whereby refresh tokens are *never* # properly expired (ugh): @@ -27,10 +33,21 @@ def create_token_response(self, request): expire_seconds = get_setting('OAUTH2_PROVIDER', {}).get('REFRESH_TOKEN_EXPIRE_SECONDS', 0) if refresh_token.created + timedelta(seconds=expire_seconds) < now(): return request.build_absolute_uri(), {}, 'The refresh token has expired.', '403' + + core = self.get_oauthlib_core() # oauth2_provider.views.mixins.OAuthLibMixin.create_token_response + + # oauth2_provider.oauth2_backends.OAuthLibCore.create_token_response + # (we override this so we can implement our own error handling to be compatible with AWX) + uri, http_method, body, headers = core._extract_params(request) + extra_credentials = core._get_extra_credentials(request) try: - return super(TokenView, self).create_token_response(request) + headers, body, status = core.server.create_token_response(uri, http_method, body, headers, extra_credentials) + uri = headers.get("Location", None) + return uri, headers, body, 201 except oauth2.AccessDeniedError as e: - return request.build_absolute_uri(), {}, str(e), '403' + return request.build_absolute_uri(), {}, str(e), 403 # Compat with AWX + except oauth2.OAuth2Error as e: + return request.build_absolute_uri(), {}, str(e), e.status_code class OAuth2TokenViewSet(ModelViewSet, AnsibleBaseDjangoAppApiView): diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index e0e0081e1..311c48089 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -25,6 +25,25 @@ def oauth2_application(randname): return (app, secret) +@pytest.fixture +def oauth2_application_password(randname): + """ + Creates an OAuth2 application with a random name and returns + both the application and its client secret. + """ + app = OAuth2Application( + name=randname("OAuth2 Application"), + description="Test OAuth2 Application", + redirect_uris="http://example.com/callback", + authorization_grant_type="password", + client_type="confidential", + ) + # Store this before it gets hashed + secret = app.client_secret + app.save() + return (app, secret) + + @pytest.fixture def oauth2_admin_access_token(oauth2_application, admin_user): return OAuth2AccessToken.objects.get_or_create( diff --git a/test_app/tests/oauth2_provider/views/test_authorize.py b/test_app/tests/oauth2_provider/views/test_authorize.py index 19b60f35c..135f3065e 100644 --- a/test_app/tests/oauth2_provider/views/test_authorize.py +++ b/test_app/tests/oauth2_provider/views/test_authorize.py @@ -28,6 +28,7 @@ def test_oauth2_provider_authorize_view_flow(user_api_client, oauth2_application """ As a user, I should be able to complete the authorization flow and get an authorization code. """ + oauth2_application = oauth2_application[0] url = reverse("authorize") query_params = { 'client_id': oauth2_application.client_id, diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index 7c697ba23..c2648a3fa 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -4,14 +4,14 @@ from django.urls import reverse from django.utils.http import urlencode +from ansible_base.authentication.models import AuthenticatorUser +from ansible_base.oauth2_provider.models import OAuth2AccessToken -@pytest.mark.django_db -def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthenticated_api_client): - app = oauth2_application[0] - app.authorization_grant_type = 'password' - app.save() - secret = oauth2_application[1] +@pytest.mark.django_db +def test_oauth2_personal_access_token_creation(oauth2_application_password, user, unauthenticated_api_client): + app = oauth2_application_password[0] + secret = oauth2_application_password[1] url = reverse('token') data = { "grant_type": "password", @@ -26,7 +26,7 @@ def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthe headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, ) - assert resp.status_code == 200, resp.content + assert resp.status_code == 201, resp.content resp_json = resp.json() assert 'access_token' in resp_json assert len(resp_json['access_token']) > 0 @@ -35,27 +35,65 @@ def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthe assert 'refresh_token' in resp_json -# -# @pytest.mark.django_db -# @pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)]) -# def test_token_creation_disabled_for_external_accounts(oauth_application, post, alice, allow_oauth, status): -# UserEnterpriseAuth(user=alice, provider='radius').save() -# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' -# -# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=allow_oauth): -# resp = post( -# url, -# data='grant_type=password&username=alice&password=alice&scope=read', -# content_type='application/x-www-form-urlencoded', -# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), -# status=status, -# ) -# if allow_oauth: -# assert AccessToken.objects.count() == 1 -# else: -# assert 'OAuth2 Tokens cannot be created by users associated with an external authentication provider' in smart_str(resp.content) # noqa -# assert AccessToken.objects.count() == 0 -# +@pytest.mark.django_db +@pytest.mark.parametrize( + 'client_fixture, user_fixture', + [ + pytest.param('user_api_client', 'user', id='user'), + pytest.param('admin_api_client', 'admin_user', id='admin'), + ], +) +def test_oauth2_provider_list_user_tokens(request, client_fixture, user_fixture): + client = request.getfixturevalue(client_fixture) + user = request.getfixturevalue(user_fixture) + url = reverse('token-list') + response = client.post(url, data={'scope': 'read'}) + assert response.status_code == 201 + assert response.data['scope'] == 'read' + assert response.data['user'] == user.pk + + get_response = client.get(url) + assert get_response.status_code == 200 + assert len(get_response.data['results']) == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)]) +def test_oauth2_token_creation_disabled_for_external_accounts( + oauth2_application_password, + user, + ldap_authenticator, + settings, + user_api_client, + allow_oauth, + status, +): + AuthenticatorUser.objects.create(uid=user.username, user=user, provider=ldap_authenticator) + app = oauth2_application_password[0] + secret = oauth2_application_password[1] + url = reverse('token') + settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS = allow_oauth + data = { + 'grant_type': 'password', + 'username': 'user', + 'password': 'password', + 'scope': 'read', + } + resp = user_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + + assert resp.status_code == status + if allow_oauth: + assert OAuth2AccessToken.objects.count() == 1 + else: + assert 'OAuth2 Tokens cannot be created by users associated with an external authentication provider' in resp.content.decode() + assert OAuth2AccessToken.objects.count() == 0 + + # @pytest.mark.django_db # def test_existing_token_enabled_for_external_accounts(oauth_application, get, post, admin): # UserEnterpriseAuth(user=admin, provider='radius').save() @@ -108,13 +146,3 @@ def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthe # ) # assert response.data['scope'] == 'write' # - - -@pytest.mark.django_db -def test_oauth2_provider_list_user_tokens(unauthenticated_api_client, admin_user, random_user): - for user in (admin_user, random_user): - unauthenticated_api_client.login(username=user.username, password=user.password) - url = reverse('api:o_auth2_token_list', kwargs={'pk': user.pk}) - response = unauthenticated_api_client.post(url, data={'scope': 'read'}) - assert response.status_code == 201 - assert response.json()['count'] == 1 From 41c5e57cc92d61b231d3617d6d40f988e147cb57 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 30 Apr 2024 21:57:03 +0200 Subject: [PATCH 20/46] Finish test_token tests Signed-off-by: Rick Elrod --- .../authentication/utils/authentication.py | 2 +- ansible_base/oauth2_provider/views/token.py | 3 +- .../tests/oauth2_provider/views/test_token.py | 135 +++++++++++------- 3 files changed, 83 insertions(+), 57 deletions(-) diff --git a/ansible_base/authentication/utils/authentication.py b/ansible_base/authentication/utils/authentication.py index 4992b7580..ccf2a6acd 100644 --- a/ansible_base/authentication/utils/authentication.py +++ b/ansible_base/authentication/utils/authentication.py @@ -61,7 +61,7 @@ def determine_username_from_uid(uid: str = None, authenticator: Authenticator = new_username = get_local_username({'username': uid}) logger.info( f'Authenticator {authenticator.name} wants to authenticate {uid} but that' - f'username is already in use by another authenticator,' + f' username is already in use by another authenticator,' f' the user from this authenticator will be {new_username}' ) return new_username diff --git a/ansible_base/oauth2_provider/views/token.py b/ansible_base/oauth2_provider/views/token.py index 676351fc3..4eb733a90 100644 --- a/ansible_base/oauth2_provider/views/token.py +++ b/ansible_base/oauth2_provider/views/token.py @@ -43,7 +43,8 @@ def create_token_response(self, request): try: headers, body, status = core.server.create_token_response(uri, http_method, body, headers, extra_credentials) uri = headers.get("Location", None) - return uri, headers, body, 201 + status = 201 if request.method == 'POST' and status == 200 else status + return uri, headers, body, status except oauth2.AccessDeniedError as e: return request.build_absolute_uri(), {}, str(e), 403 # Compat with AWX except oauth2.OAuth2Error as e: diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index c2648a3fa..3006f6fd1 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -63,12 +63,18 @@ def test_oauth2_token_creation_disabled_for_external_accounts( oauth2_application_password, user, ldap_authenticator, + local_authenticator, settings, - user_api_client, + unauthenticated_api_client, allow_oauth, status, ): - AuthenticatorUser.objects.create(uid=user.username, user=user, provider=ldap_authenticator) + """ + If ALLOW_OAUTH2_FOR_EXTERNAL_USERS is enabled, users associated with an external authentication provider + can create OAuth2 tokens. Otherwise, they cannot. + """ + AuthenticatorUser.objects.get_or_create(uid=user.username, user=user, provider=ldap_authenticator) + AuthenticatorUser.objects.get_or_create(uid=user.username, user=user, provider=local_authenticator) app = oauth2_application_password[0] secret = oauth2_application_password[1] url = reverse('token') @@ -79,7 +85,7 @@ def test_oauth2_token_creation_disabled_for_external_accounts( 'password': 'password', 'scope': 'read', } - resp = user_api_client.post( + resp = unauthenticated_api_client.post( url, data=urlencode(data), content_type='application/x-www-form-urlencoded', @@ -94,55 +100,74 @@ def test_oauth2_token_creation_disabled_for_external_accounts( assert OAuth2AccessToken.objects.count() == 0 -# @pytest.mark.django_db -# def test_existing_token_enabled_for_external_accounts(oauth_application, get, post, admin): -# UserEnterpriseAuth(user=admin, provider='radius').save() -# url = drf_reverse('api:oauth_authorization_root_view') + 'token/' -# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True): -# resp = post( -# url, -# data='grant_type=password&username=admin&password=admin&scope=read', -# content_type='application/x-www-form-urlencoded', -# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), -# status=201, -# ) -# token = json.loads(resp.content)['access_token'] -# assert AccessToken.objects.count() == 1 -# -# with immediate_on_commit(): -# resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) -# assert json.loads(resp.content)['results'][0]['username'] == 'admin' -# -# with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False): -# with immediate_on_commit(): -# resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) -# assert json.loads(resp.content)['results'][0]['username'] == 'admin' -# -# @pytest.mark.django_db -# def test_pat_creation_no_default_scope(oauth_application, post, admin): -# # tests that the default scope is overriden -# url = reverse('api:o_auth2_token_list') -# response = post( -# url, -# { -# 'description': 'test token', -# 'scope': 'read', -# 'application': oauth_application.pk, -# }, -# admin, -# ) -# assert response.data['scope'] == 'read' -# -# @pytest.mark.django_db -# def test_pat_creation_no_scope(oauth_application, post, admin): -# url = reverse('api:o_auth2_token_list') -# response = post( -# url, -# { -# 'description': 'test token', -# 'application': oauth_application.pk, -# }, -# admin, -# ) -# assert response.data['scope'] == 'write' -# +@pytest.mark.django_db +def test_oauth2_existing_token_enabled_for_external_accounts( + oauth2_application_password, user, unauthenticated_api_client, settings, ldap_authenticator, local_authenticator +): + """ + If a token already exists but then ALLOW_OAUTH2_FOR_EXTERNAL_USERS becomes False + the token should still be usable. + """ + AuthenticatorUser.objects.get_or_create(uid=user.username, user=user, provider=ldap_authenticator) + AuthenticatorUser.objects.get_or_create(uid=user.username, user=user, provider=local_authenticator) + app = oauth2_application_password[0] + secret = oauth2_application_password[1] + url = reverse('token') + settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS = True + data = { + 'grant_type': 'password', + 'username': 'user', + 'password': 'password', + 'scope': 'read', + } + resp = unauthenticated_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + assert resp.status_code == 201 + token = resp.json()['access_token'] + assert OAuth2AccessToken.objects.count() == 1 + + for val in (True, False): + settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS = val + url = reverse('user-me') + resp = unauthenticated_api_client.get( + url, + headers={'Authorization': f'Bearer {token}'}, + ) + assert resp.json()['username'] == user.username + + +@pytest.mark.django_db +def test_oauth2_pat_creation_no_default_scope(oauth2_application, admin_api_client): + """ + Tests that the default scope is overriden + """ + url = reverse('token-list') + response = admin_api_client.post( + url, + { + 'description': 'test token', + 'scope': 'read', + 'application': oauth2_application[0].pk, + }, + ) + assert response.data['scope'] == 'read' + + +@pytest.mark.django_db +def test_oauth2_pat_creation_no_scope(oauth2_application, admin_api_client): + """ + Tests that the default scope is as expected + """ + url = reverse('token-list') + response = admin_api_client.post( + url, + { + 'description': 'test token', + 'application': oauth2_application[0].pk, + }, + ) + assert response.data['scope'] == 'write' From 7c51b36615a6dced070d02cdbc2e601b7729012d Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 30 Apr 2024 21:58:30 +0200 Subject: [PATCH 21/46] Tidy up another test file Signed-off-by: Rick Elrod --- .../views/test_authorization_root.py | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/test_app/tests/oauth2_provider/views/test_authorization_root.py b/test_app/tests/oauth2_provider/views/test_authorization_root.py index f618f3a0e..eeb76baaa 100644 --- a/test_app/tests/oauth2_provider/views/test_authorization_root.py +++ b/test_app/tests/oauth2_provider/views/test_authorization_root.py @@ -1,23 +1,12 @@ from django.urls import reverse -def test_oauth2_provider_authorization_root_view_as_admin(admin_api_client): +def test_oauth2_provider_authorization_root_view(admin_api_client, unauthenticated_api_client, user_api_client): """ As an admin, accessing /o/ gives an index of oauth endpoints. """ url = reverse("oauth_authorization_root_view") - response = admin_api_client.get(url) - - assert response.status_code == 200 - assert 'authorize' in response.data - - -def test_oauth2_provider_authorization_root_view_anon(client): - """ - As an anonymous user, accessing /o/ gives an index of oauth endpoints. - """ - url = reverse("oauth_authorization_root_view") - response = client.get(url) - - assert response.status_code == 200 - assert 'authorize' in response.data + for client in (admin_api_client, unauthenticated_api_client, user_api_client): + response = admin_api_client.get(url) + assert response.status_code == 200 + assert 'authorize' in response.data From a2f742029ee3fdb11bf426c4e8bb2a60eef8c18f Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 30 Apr 2024 21:59:50 +0200 Subject: [PATCH 22/46] Update fixture tuple application fixtures return Signed-off-by: Rick Elrod --- test_app/tests/oauth2_provider/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index 311c48089..c6e2e1575 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -48,7 +48,7 @@ def oauth2_application_password(randname): def oauth2_admin_access_token(oauth2_application, admin_user): return OAuth2AccessToken.objects.get_or_create( user=admin_user, - application=oauth2_application, + application=oauth2_application[0], description="Test Access Token", # This has to be timezone aware expires=datetime(2088, 1, 1, tzinfo=timezone.utc), From adb478f6ca95566db8ff3653b6f5ddaa91a221d1 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 04:17:35 +0200 Subject: [PATCH 23/46] Track oauth models in activity stream and sanitize Signed-off-by: Rick Elrod --- .../oauth2_provider/models/access_token.py | 17 +++++++++++++++-- .../oauth2_provider/models/application.py | 17 ++++++++++++++++- ansible_base/oauth2_provider/models/id_token.py | 9 ++++++++- .../oauth2_provider/models/refresh_token.py | 13 ++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index 4c5b81a75..d197e3303 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -6,11 +6,18 @@ from oauthlib import oauth2 from ansible_base.lib.abstract_models.common import CommonModel +from ansible_base.lib.utils.models import prevent_search from ansible_base.lib.utils.settings import get_setting from ansible_base.oauth2_provider.utils import is_external_account +activitystream = object +if 'ansible_base.activitystream' in settings.INSTALLED_APPS: + from ansible_base.activitystream.models import AuditableModel -class OAuth2AccessToken(oauth2_models.AbstractAccessToken, CommonModel): + activitystream = AuditableModel + + +class OAuth2AccessToken(oauth2_models.AbstractAccessToken, CommonModel, activitystream): router_basename = 'token' ignore_relations = ['refresh_token'] @@ -24,7 +31,7 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): on_delete=models.CASCADE, blank=True, null=True, - related_name="%(app_label)s_%(class)s", + related_name="access_tokens", help_text=_('The user representing the token owner'), ) # Overriding to set related_name @@ -51,6 +58,12 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): choices=[('read', 'read'), ('write', 'write')], help_text=_("Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."), ) + token = prevent_search( + models.CharField( + max_length=255, + unique=True, + ) + ) def is_valid(self, scopes=None): valid = super(OAuth2AccessToken, self).is_valid(scopes) diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index 0ddac8d35..2aa3c969f 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -9,10 +9,17 @@ from ansible_base.lib.abstract_models.common import NamedCommonModel +activitystream = object +if 'ansible_base.activitystream' in settings.INSTALLED_APPS: + from ansible_base.activitystream.models import AuditableModel + + activitystream = AuditableModel + + DATA_URI_RE = re.compile(r'.*') # FIXME -class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel): +class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel, activitystream): router_basename = 'application' ignore_relations = ['oauth2idtoken', 'grant', 'oauth2refreshtoken'] # We do NOT add client_secret to encrypted_fields because it is hashed by Django OAuth Toolkit @@ -34,6 +41,14 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): ("password", _("Resource owner password-based")), ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="applications", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + description = models.TextField( default='', blank=True, diff --git a/ansible_base/oauth2_provider/models/id_token.py b/ansible_base/oauth2_provider/models/id_token.py index 80a3c7704..9beb932ed 100644 --- a/ansible_base/oauth2_provider/models/id_token.py +++ b/ansible_base/oauth2_provider/models/id_token.py @@ -1,10 +1,17 @@ import oauth2_provider.models as oauth2_models +from django.conf import settings from django.utils.translation import gettext_lazy as _ from ansible_base.lib.abstract_models.common import CommonModel +activitystream = object +if 'ansible_base.activitystream' in settings.INSTALLED_APPS: + from ansible_base.activitystream.models import AuditableModel -class OAuth2IDToken(oauth2_models.AbstractIDToken, CommonModel): + activitystream = AuditableModel + + +class OAuth2IDToken(oauth2_models.AbstractIDToken, CommonModel, activitystream): class Meta(oauth2_models.AbstractIDToken.Meta): verbose_name = _('id token') swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" diff --git a/ansible_base/oauth2_provider/models/refresh_token.py b/ansible_base/oauth2_provider/models/refresh_token.py index 64c37202b..bd83a1d11 100644 --- a/ansible_base/oauth2_provider/models/refresh_token.py +++ b/ansible_base/oauth2_provider/models/refresh_token.py @@ -1,11 +1,22 @@ import oauth2_provider.models as oauth2_models +from django.conf import settings +from django.db import models from django.utils.translation import gettext_lazy as _ from ansible_base.lib.abstract_models.common import CommonModel +from ansible_base.lib.utils.models import prevent_search +activitystream = object +if 'ansible_base.activitystream' in settings.INSTALLED_APPS: + from ansible_base.activitystream.models import AuditableModel -class OAuth2RefreshToken(oauth2_models.AbstractRefreshToken, CommonModel): + activitystream = AuditableModel + + +class OAuth2RefreshToken(oauth2_models.AbstractRefreshToken, CommonModel, activitystream): class Meta(oauth2_models.AbstractRefreshToken.Meta): verbose_name = _('access token') ordering = ('id',) swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" + + token = prevent_search(models.CharField(max_length=255)) From 84ca3020e0fce64c377d17530882bee99d7ca7b4 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 04:52:17 +0200 Subject: [PATCH 24/46] Provide view-level hook for extra related fields Signed-off-by: Rick Elrod --- ansible_base/lib/serializers/common.py | 10 ++++++++-- ansible_base/lib/utils/views/ansible_base.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ansible_base/lib/serializers/common.py b/ansible_base/lib/serializers/common.py index 2409b5860..8d9ef0972 100644 --- a/ansible_base/lib/serializers/common.py +++ b/ansible_base/lib/serializers/common.py @@ -47,10 +47,16 @@ def is_list_view(self) -> bool: def _get_related(self, obj) -> dict[str, str]: if obj is None: return {} + related_fields = {} + view = self.context.get('view') + if view is not None and hasattr(view, 'extra_related_fields'): + related_fields.update(view.extra_related_fields(obj)) if not hasattr(obj, 'related_fields'): logger.warning(f"Object {obj.__class__} has no related_fields method") - return {} - return obj.related_fields(self.context.get('request')) + else: + related_fields.update(obj.related_fields(self.context.get('request'))) + + return related_fields def _get_summary_fields(self, obj) -> dict[str, dict]: if obj is None: diff --git a/ansible_base/lib/utils/views/ansible_base.py b/ansible_base/lib/utils/views/ansible_base.py index a7b27a3ca..683a4381b 100644 --- a/ansible_base/lib/utils/views/ansible_base.py +++ b/ansible_base/lib/utils/views/ansible_base.py @@ -51,3 +51,13 @@ def finalize_response(self, request, response, *args, **kwargs): response['Warning'] = _('This resource has been deprecated and will be removed in a future release.') return response + + def extra_related_fields(self, obj): + """ + A hook for adding extra related fields to serializers which + make use of this view/viewset. + + This is particularly useful for mixins which want to extend a viewset + with additional actions and provide those actions as related fields. + """ + return {} From 46551bedd7327c0e035e3a7ccc3891d686b774f0 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 04:55:38 +0200 Subject: [PATCH 25/46] Start on /users/PK// endpoints Particularly, start on /users/PK/personal_tokens/ No tests, yet. Signed-off-by: Rick Elrod --- .../oauth2_provider/serializers/token.py | 15 ++-------- .../oauth2_provider/views/__init__.py | 1 + .../oauth2_provider/views/user_mixin.py | 28 +++++++++++++++++++ test_app/router.py | 3 ++ test_app/views.py | 3 +- 5 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 ansible_base/oauth2_provider/views/user_mixin.py diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index a2f883470..4a195b676 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -39,9 +39,9 @@ class Meta: extra_kwargs = {'scope': {'allow_null': False, 'required': False}, 'user': {'allow_null': False, 'required': True}} def get_token(self, obj): - request = self.context.get('request', None) + request = self.context.get('request') try: - if request.method == 'POST': + if request and request.method == 'POST': return obj.token else: return ENCRYPTED_STRING @@ -49,7 +49,7 @@ def get_token(self, obj): return '' def get_refresh_token(self, obj): - request = self.context.get('request', None) + request = self.context.get('request') try: if not obj.refresh_token: return None @@ -60,15 +60,6 @@ def get_refresh_token(self, obj): except ObjectDoesNotExist: return None - def get_related(self, obj): - ret = super(BaseOAuth2TokenSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - if obj.application: - ret['application'] = self.reverse('api:o_auth2_application_detail', kwargs={'pk': obj.application.pk}) - ret['activity_stream'] = self.reverse('api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk}) - return ret - def _is_valid_scope(self, value): if not value or (not isinstance(value, str)): return False diff --git a/ansible_base/oauth2_provider/views/__init__.py b/ansible_base/oauth2_provider/views/__init__.py index 65b261cac..5fce180e5 100644 --- a/ansible_base/oauth2_provider/views/__init__.py +++ b/ansible_base/oauth2_provider/views/__init__.py @@ -1,3 +1,4 @@ from .application import OAuth2ApplicationViewSet # noqa: F401 from .authorization_root import ApiOAuthAuthorizationRootView # noqa: F401 from .token import OAuth2TokenViewSet, TokenView # noqa: F401 +from .user_mixin import DABOAuth2UserViewsetMixin # noqa: F401 diff --git a/ansible_base/oauth2_provider/views/user_mixin.py b/ansible_base/oauth2_provider/views/user_mixin.py new file mode 100644 index 000000000..428048290 --- /dev/null +++ b/ansible_base/oauth2_provider/views/user_mixin.py @@ -0,0 +1,28 @@ +from django.urls import reverse +from rest_framework.decorators import action +from rest_framework.response import Response + +from ansible_base.oauth2_provider.models import OAuth2AccessToken +from ansible_base.oauth2_provider.serializers import OAuth2TokenSerializer + + +class DABOAuth2UserViewsetMixin: + """ + This mixin provides several actions to expose as sub-urls for a given user PK. + """ + + def extra_related_fields(self, obj) -> dict[str, str]: + fields = super().extra_related_fields(obj) + fields['personal_tokens'] = reverse(f'{self.basename}-personal-tokens-list', kwargs={"pk": obj.pk}) + return fields + + @action(detail=True, methods=["get"], url_name="personal-tokens-list") + def personal_tokens(self, request, pk=None): + tokens = OAuth2AccessToken.objects.filter(application__isnull=True, user=pk) + page = self.paginate_queryset(tokens) + if page is not None: + serializer = OAuth2TokenSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = OAuth2TokenSerializer(tokens, many=True) + return Response(serializer.data) diff --git a/test_app/router.py b/test_app/router.py index b3d54cced..160a4451a 100644 --- a/test_app/router.py +++ b/test_app/router.py @@ -1,4 +1,5 @@ from ansible_base.lib.routers import AssociationResourceRouter +from ansible_base.oauth2_provider import views as oauth2_provider_views from ansible_base.rbac.api import views as rbac_views from test_app import views @@ -61,6 +62,8 @@ def filter_queryset(self, qs): related_views={ 'organizations': (views.OrganizationViewSet, 'organizations'), 'teams': (views.TeamViewSet, 'teams'), + 'tokens': (oauth2_provider_views.OAuth2TokenViewSet, 'access_tokens'), + 'applications': (oauth2_provider_views.OAuth2ApplicationViewSet, 'applications'), }, basename='user', ) diff --git a/test_app/views.py b/test_app/views.py index 3ac5603f2..50acecdee 100644 --- a/test_app/views.py +++ b/test_app/views.py @@ -7,6 +7,7 @@ from rest_framework.viewsets import ModelViewSet from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView +from ansible_base.oauth2_provider.views import DABOAuth2UserViewsetMixin from ansible_base.rbac import permission_registry from ansible_base.rbac.api.permissions import AnsibleBaseObjectPermissions, AnsibleBaseUserPermissions from ansible_base.rbac.policies import visible_users @@ -49,7 +50,7 @@ class TeamViewSet(TestAppViewSet): select_related = ('resource__content_type',) -class UserViewSet(TestAppViewSet): +class UserViewSet(DABOAuth2UserViewsetMixin, TestAppViewSet): queryset = models.User.objects.all() permission_classes = [AnsibleBaseUserPermissions] serializer_class = serializers.UserSerializer From 87dc7d0bad29f146f16173f27e3ef8a6a240ab55 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 16:57:19 +0200 Subject: [PATCH 26/46] Some coverage for the PAT mixin hack Signed-off-by: Rick Elrod --- test_app/tests/oauth2_provider/conftest.py | 13 +++ .../tests/oauth2_provider/views/test_token.py | 87 +++++++++++++------ 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index c6e2e1575..3d2ff4312 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -3,6 +3,7 @@ import pytest from oauthlib.common import generate_token +from ansible_base.lib.testing.fixtures import copy_fixture from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2Application @@ -54,3 +55,15 @@ def oauth2_admin_access_token(oauth2_application, admin_user): expires=datetime(2088, 1, 1, tzinfo=timezone.utc), token=generate_token(), )[0] + + +@copy_fixture(copies=3) +@pytest.fixture +def oauth2_user_pat(user, randname): + return OAuth2AccessToken.objects.get_or_create( + user=user, + description=randname("Personal Access Token for 'user'"), + # This has to be timezone aware + expires=datetime(2088, 1, 1, tzinfo=timezone.utc), + token=generate_token(), + )[0] diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index 3006f6fd1..dd1af287f 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -8,33 +8,6 @@ from ansible_base.oauth2_provider.models import OAuth2AccessToken -@pytest.mark.django_db -def test_oauth2_personal_access_token_creation(oauth2_application_password, user, unauthenticated_api_client): - app = oauth2_application_password[0] - secret = oauth2_application_password[1] - url = reverse('token') - data = { - "grant_type": "password", - "username": "user", - "password": "password", - "scope": "read", - } - resp = unauthenticated_api_client.post( - url, - data=urlencode(data), - content_type='application/x-www-form-urlencoded', - headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, - ) - - assert resp.status_code == 201, resp.content - resp_json = resp.json() - assert 'access_token' in resp_json - assert len(resp_json['access_token']) > 0 - assert 'scope' in resp_json - assert resp_json['scope'] == 'read' - assert 'refresh_token' in resp_json - - @pytest.mark.django_db @pytest.mark.parametrize( 'client_fixture, user_fixture', @@ -140,6 +113,33 @@ def test_oauth2_existing_token_enabled_for_external_accounts( assert resp.json()['username'] == user.username +@pytest.mark.django_db +def test_oauth2_pat_creation(oauth2_application_password, user, unauthenticated_api_client): + app = oauth2_application_password[0] + secret = oauth2_application_password[1] + url = reverse('token') + data = { + "grant_type": "password", + "username": "user", + "password": "password", + "scope": "read", + } + resp = unauthenticated_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + + assert resp.status_code == 201, resp.content + resp_json = resp.json() + assert 'access_token' in resp_json + assert len(resp_json['access_token']) > 0 + assert 'scope' in resp_json + assert resp_json['scope'] == 'read' + assert 'refresh_token' in resp_json + + @pytest.mark.django_db def test_oauth2_pat_creation_no_default_scope(oauth2_application, admin_api_client): """ @@ -171,3 +171,36 @@ def test_oauth2_pat_creation_no_scope(oauth2_application, admin_api_client): }, ) assert response.data['scope'] == 'write' + + +def test_oauth2_pat_list_for_user(oauth2_user_pat, oauth2_user_pat_1, user, admin_api_client): + """ + Tests that we can list a user's PATs via API. + """ + url = reverse('user-personal-tokens-list', kwargs={"pk": user.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert len(response.data['results']) == 2 + + +def test_oauth2_pat_list_for_invalid_user(oauth2_user_pat, oauth2_user_pat_1, user, admin_api_client): + """ + Ensure we don't fatal if we give a bad user PK. + + We return an empty list. + """ + url = reverse('user-personal-tokens-list', kwargs={"pk": 1000}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['results'] == [] + + +def test_oauth2_pat_list_is_user_related_field(user, admin_api_client): + """ + Ensure 'personal_tokens' shows up in the user's related fields. + """ + url = reverse('user-detail', kwargs={"pk": user.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert 'personal_tokens' in response.data['related'] + assert response.data['delated']['personal_tokens'] == reverse('user-personal-tokens-list', kwargs={"pk": user.pk}) From d3269afd51459120e41934e2b2457f9c66fdc5c4 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 19:28:14 +0200 Subject: [PATCH 27/46] Nix updated/created from token model Signed-off-by: Rick Elrod --- ...move_oauth2accesstoken_created_and_more.py | 34 +++++++++++++++++++ .../oauth2_provider/models/access_token.py | 2 ++ 2 files changed, 36 insertions(+) create mode 100644 ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py diff --git a/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py b/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py new file mode 100644 index 000000000..3eafd05f2 --- /dev/null +++ b/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2024-05-01 16:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dab_oauth2_provider', '0004_alter_oauth2application_client_secret'), + ] + + operations = [ + migrations.RemoveField( + model_name='oauth2accesstoken', + name='created', + ), + migrations.RemoveField( + model_name='oauth2accesstoken', + name='updated', + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='user', + field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index d197e3303..34836a941 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -64,6 +64,8 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): unique=True, ) ) + created = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel, no need for this def is_valid(self, scopes=None): valid = super(OAuth2AccessToken, self).is_valid(scopes) From 6e70130ae863ffe7de6ea4964cb824fc4cd05c69 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 19:30:03 +0200 Subject: [PATCH 28/46] Nix DOT updated/created fields from some models Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/models/application.py | 2 ++ ansible_base/oauth2_provider/models/id_token.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index 2aa3c969f..5a4b74ed1 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -80,6 +80,8 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): authorization_grant_type = models.CharField( max_length=32, choices=GRANT_TYPES, help_text=_('The Grant type the user must use for acquire tokens for this application.') ) + created = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel, no need for this def get_absolute_url(self): # This is kind of annoying. This method lives on the superclass and we check for it in CommonModel. diff --git a/ansible_base/oauth2_provider/models/id_token.py b/ansible_base/oauth2_provider/models/id_token.py index 9beb932ed..2127cca03 100644 --- a/ansible_base/oauth2_provider/models/id_token.py +++ b/ansible_base/oauth2_provider/models/id_token.py @@ -15,3 +15,6 @@ class OAuth2IDToken(oauth2_models.AbstractIDToken, CommonModel, activitystream): class Meta(oauth2_models.AbstractIDToken.Meta): verbose_name = _('id token') swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" + + created = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel, no need for this From 7bc557cfb8f52198422f1f15842632b478d5fa67 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 19:30:39 +0200 Subject: [PATCH 29/46] Use the API for the token fixture Signed-off-by: Rick Elrod --- test_app/tests/oauth2_provider/conftest.py | 15 ++++++--------- .../tests/oauth2_provider/test_authentication.py | 12 ++++++------ 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index 3d2ff4312..15dcd248c 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone import pytest +from django.urls import reverse from oauthlib.common import generate_token from ansible_base.lib.testing.fixtures import copy_fixture @@ -46,15 +47,11 @@ def oauth2_application_password(randname): @pytest.fixture -def oauth2_admin_access_token(oauth2_application, admin_user): - return OAuth2AccessToken.objects.get_or_create( - user=admin_user, - application=oauth2_application[0], - description="Test Access Token", - # This has to be timezone aware - expires=datetime(2088, 1, 1, tzinfo=timezone.utc), - token=generate_token(), - )[0] +def oauth2_admin_access_token(oauth2_application, admin_api_client, admin_user): + url = reverse('token-list') + response = admin_api_client.post(url, {'application': oauth2_application[0].pk}) + assert response.status_code == 201 + return response.data['token'] @copy_fixture(copies=3) diff --git a/test_app/tests/oauth2_provider/test_authentication.py b/test_app/tests/oauth2_provider/test_authentication.py index a99abee90..85e79260a 100644 --- a/test_app/tests/oauth2_provider/test_authentication.py +++ b/test_app/tests/oauth2_provider/test_authentication.py @@ -10,10 +10,10 @@ def test_oauth2_bearer_get_user_correct(unauthenticated_api_client, oauth2_admin url = reverse("user-me") response = unauthenticated_api_client.get( url, - headers={'Authorization': f'Bearer {oauth2_admin_access_token.token}'}, + headers={'Authorization': f'Bearer {oauth2_admin_access_token}'}, ) assert response.status_code == 200 - assert response.data['username'] == oauth2_admin_access_token.user.username + assert response.data['username'] == 'admin' @pytest.mark.parametrize( @@ -28,7 +28,7 @@ def test_oauth2_bearer_get(unauthenticated_api_client, oauth2_admin_access_token GET an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token if token == 'fixture' else generate_token() response = unauthenticated_api_client.get( url, headers={'Authorization': f'Bearer {token}'}, @@ -50,7 +50,7 @@ def test_oauth2_bearer_post(unauthenticated_api_client, oauth2_admin_access_toke POST an animal with a bearer token. """ url = reverse("animal-list") - token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token if token == 'fixture' else generate_token() data = { "name": "Fido", "owner": admin_user.pk, @@ -77,7 +77,7 @@ def test_oauth2_bearer_patch(unauthenticated_api_client, oauth2_admin_access_tok PATCH an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token if token == 'fixture' else generate_token() data = { "name": "Fido", } @@ -103,7 +103,7 @@ def test_oauth2_bearer_put(unauthenticated_api_client, oauth2_admin_access_token PUT an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token if token == 'fixture' else generate_token() data = { "name": "Fido", "owner": admin_user.pk, From 25cef53c4cd37beecc8d03ee0c379b58273b9bc3 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 19:44:03 +0200 Subject: [PATCH 30/46] Clean up migrations Signed-off-by: Rick Elrod --- ...ove_oauth2accesstoken_created_and_more.py} | 47 +++++++++++++++---- ...003_alter_oauth2accesstoken_application.py | 20 -------- ...4_alter_oauth2application_client_secret.py | 20 -------- ...move_oauth2accesstoken_created_and_more.py | 34 -------------- 4 files changed, 39 insertions(+), 82 deletions(-) rename ansible_base/oauth2_provider/migrations/{0002_alter_oauth2accesstoken_created_and_more.py => 0002_remove_oauth2accesstoken_created_and_more.py} (79%) delete mode 100644 ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py delete mode 100644 ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py delete mode 100644 ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py diff --git a/ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py b/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py similarity index 79% rename from ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py rename to ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py index 64f9829e3..722ed0d23 100644 --- a/ansible_base/oauth2_provider/migrations/0002_alter_oauth2accesstoken_created_and_more.py +++ b/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py @@ -1,8 +1,10 @@ -# Generated by Django 4.2.11 on 2024-04-19 15:47 +# Generated by Django 4.2.11 on 2024-05-01 17:42 from django.conf import settings from django.db import migrations, models import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.models class Migration(migrations.Migration): @@ -13,10 +15,34 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterField( + migrations.RemoveField( model_name='oauth2accesstoken', name='created', - field=models.DateTimeField(auto_now_add=True), + ), + migrations.RemoveField( + model_name='oauth2accesstoken', + name='updated', + ), + migrations.RemoveField( + model_name='oauth2application', + name='created', + ), + migrations.RemoveField( + model_name='oauth2application', + name='updated', + ), + migrations.RemoveField( + model_name='oauth2idtoken', + name='created', + ), + migrations.RemoveField( + model_name='oauth2idtoken', + name='updated', + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), ), migrations.AlterField( model_name='oauth2accesstoken', @@ -48,10 +74,15 @@ class Migration(migrations.Migration): name='token', field=models.CharField(max_length=255, unique=True), ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='user', + field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.AUTH_USER_MODEL), + ), migrations.AlterField( model_name='oauth2application', - name='created', - field=models.DateTimeField(auto_now_add=True), + 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.AlterField( model_name='oauth2application', @@ -79,9 +110,9 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, max_length=255), ), migrations.AlterField( - model_name='oauth2idtoken', - name='created', - field=models.DateTimeField(auto_now_add=True), + model_name='oauth2application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), ), migrations.AlterField( model_name='oauth2idtoken', diff --git a/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py b/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py deleted file mode 100644 index 0d0f0874f..000000000 --- a/ansible_base/oauth2_provider/migrations/0003_alter_oauth2accesstoken_application.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.11 on 2024-04-21 15:48 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dab_oauth2_provider', '0002_alter_oauth2accesstoken_created_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='oauth2accesstoken', - name='application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ), - ] diff --git a/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py b/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py deleted file mode 100644 index a1e625186..000000000 --- a/ansible_base/oauth2_provider/migrations/0004_alter_oauth2application_client_secret.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.11 on 2024-04-28 16:14 - -from django.db import migrations -import oauth2_provider.generators -import oauth2_provider.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dab_oauth2_provider', '0003_alter_oauth2accesstoken_application'), - ] - - operations = [ - migrations.AlterField( - model_name='oauth2application', - 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), - ), - ] diff --git a/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py b/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py deleted file mode 100644 index 3eafd05f2..000000000 --- a/ansible_base/oauth2_provider/migrations/0005_remove_oauth2accesstoken_created_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.2.11 on 2024-05-01 16:53 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('dab_oauth2_provider', '0004_alter_oauth2application_client_secret'), - ] - - operations = [ - migrations.RemoveField( - model_name='oauth2accesstoken', - name='created', - ), - migrations.RemoveField( - model_name='oauth2accesstoken', - name='updated', - ), - migrations.AlterField( - model_name='oauth2accesstoken', - name='user', - field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='oauth2application', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), - ), - ] From 4dc5d8248c35c296e72f8295fed10d0f10d3a4cd Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 21:52:49 +0200 Subject: [PATCH 31/46] Start a doc for differences from AWX Signed-off-by: Rick Elrod --- docs/apps/oauth2_provider.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/apps/oauth2_provider.md diff --git a/docs/apps/oauth2_provider.md b/docs/apps/oauth2_provider.md new file mode 100644 index 000000000..3875c7ec2 --- /dev/null +++ b/docs/apps/oauth2_provider.md @@ -0,0 +1,6 @@ +# Differences from AWX + +* Because of how DAB's router works, we don't allow for POSTing to (for example) + `/applications/PK/tokens/` to create a token which belongs to an application. + The workaround is to just use /tokens/ and in the body specify + `{"application": PK}`. From 97501deddb2e5243aa2982705a8b70452d5e2644 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 22:05:32 +0200 Subject: [PATCH 32/46] Make summary fields useful for Application Signed-off-by: Rick Elrod --- .../serializers/application.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py index db7b74023..8858c50a4 100644 --- a/ansible_base/oauth2_provider/serializers/application.py +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -7,11 +7,6 @@ from ansible_base.oauth2_provider.models import OAuth2Application -def has_model_field_prefetched(obj, thing): - # from awx.main.utils import has_model_field_prefetched - pass - - class OAuth2ApplicationSerializer(NamedCommonModelSerializer): oauth2_client_secret = None @@ -60,18 +55,15 @@ def to_representation(self, instance): return ret def _summary_field_tokens(self, obj): - token_list = [{'id': x.pk, 'token': ENCRYPTED_STRING, 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] - if has_model_field_prefetched(obj, 'oauth2accesstoken_set'): - token_count = len(obj.oauth2accesstoken_set.all()) + token_list = [{'id': x.pk, 'token': ENCRYPTED_STRING, 'scope': x.scope} for x in obj.access_tokens.all()[:10]] + if len(token_list) < 10: + token_count = len(token_list) else: - if len(token_list) < 10: - token_count = len(token_list) - else: - token_count = obj.oauth2accesstoken_set.count() + token_count = obj.access_tokens.count() return {'count': token_count, 'results': token_list} - def get_summary_fields(self, obj): - ret = super(OAuth2ApplicationSerializer, self).get_summary_fields(obj) + def _get_summary_fields(self, obj): + ret = super()._get_summary_fields(obj) ret['tokens'] = self._summary_field_tokens(obj) return ret From 96d616a46267f9160bb5680b49b7b2793b630280 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 22:06:52 +0200 Subject: [PATCH 33/46] Get /users/N/authorized_tokens/ working Signed-off-by: Rick Elrod --- .../oauth2_provider/serializers/token.py | 2 +- .../oauth2_provider/views/user_mixin.py | 16 ++- test_app/tests/oauth2_provider/conftest.py | 2 +- .../oauth2_provider/test_authentication.py | 12 +- .../tests/oauth2_provider/views/test_token.py | 126 ++++++++++++++---- 5 files changed, 122 insertions(+), 36 deletions(-) diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index 4a195b676..37a715735 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -53,7 +53,7 @@ def get_refresh_token(self, obj): try: if not obj.refresh_token: return None - elif request.method == 'POST': + elif request and request.method == 'POST': return getattr(obj.refresh_token, 'token', '') else: return ENCRYPTED_STRING diff --git a/ansible_base/oauth2_provider/views/user_mixin.py b/ansible_base/oauth2_provider/views/user_mixin.py index 428048290..3e0d16170 100644 --- a/ansible_base/oauth2_provider/views/user_mixin.py +++ b/ansible_base/oauth2_provider/views/user_mixin.py @@ -14,15 +14,23 @@ class DABOAuth2UserViewsetMixin: def extra_related_fields(self, obj) -> dict[str, str]: fields = super().extra_related_fields(obj) fields['personal_tokens'] = reverse(f'{self.basename}-personal-tokens-list', kwargs={"pk": obj.pk}) + fields['authorized_tokens'] = reverse(f'{self.basename}-authorized-tokens-list', kwargs={"pk": obj.pk}) return fields - @action(detail=True, methods=["get"], url_name="personal-tokens-list") - def personal_tokens(self, request, pk=None): - tokens = OAuth2AccessToken.objects.filter(application__isnull=True, user=pk) + def _user_token_response(self, request, application_isnull, pk): + tokens = OAuth2AccessToken.objects.filter(application__isnull=application_isnull, user=pk) page = self.paginate_queryset(tokens) if page is not None: - serializer = OAuth2TokenSerializer(page, many=True) + serializer = OAuth2TokenSerializer(page, many=True, context={"request": request}) return self.get_paginated_response(serializer.data) serializer = OAuth2TokenSerializer(tokens, many=True) return Response(serializer.data) + + @action(detail=True, methods=["get"], url_name="personal-tokens-list") + def personal_tokens(self, request, pk=None): + return self._user_token_response(request, True, pk) + + @action(detail=True, methods=["get"], url_name="authorized-tokens-list") + def authorized_tokens(self, request, pk=None): + return self._user_token_response(request, False, pk) diff --git a/test_app/tests/oauth2_provider/conftest.py b/test_app/tests/oauth2_provider/conftest.py index 15dcd248c..076248cb3 100644 --- a/test_app/tests/oauth2_provider/conftest.py +++ b/test_app/tests/oauth2_provider/conftest.py @@ -51,7 +51,7 @@ def oauth2_admin_access_token(oauth2_application, admin_api_client, admin_user): url = reverse('token-list') response = admin_api_client.post(url, {'application': oauth2_application[0].pk}) assert response.status_code == 201 - return response.data['token'] + return OAuth2AccessToken.objects.get(token=response.data['token']) @copy_fixture(copies=3) diff --git a/test_app/tests/oauth2_provider/test_authentication.py b/test_app/tests/oauth2_provider/test_authentication.py index 85e79260a..a99abee90 100644 --- a/test_app/tests/oauth2_provider/test_authentication.py +++ b/test_app/tests/oauth2_provider/test_authentication.py @@ -10,10 +10,10 @@ def test_oauth2_bearer_get_user_correct(unauthenticated_api_client, oauth2_admin url = reverse("user-me") response = unauthenticated_api_client.get( url, - headers={'Authorization': f'Bearer {oauth2_admin_access_token}'}, + headers={'Authorization': f'Bearer {oauth2_admin_access_token.token}'}, ) assert response.status_code == 200 - assert response.data['username'] == 'admin' + assert response.data['username'] == oauth2_admin_access_token.user.username @pytest.mark.parametrize( @@ -28,7 +28,7 @@ def test_oauth2_bearer_get(unauthenticated_api_client, oauth2_admin_access_token GET an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() response = unauthenticated_api_client.get( url, headers={'Authorization': f'Bearer {token}'}, @@ -50,7 +50,7 @@ def test_oauth2_bearer_post(unauthenticated_api_client, oauth2_admin_access_toke POST an animal with a bearer token. """ url = reverse("animal-list") - token = oauth2_admin_access_token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() data = { "name": "Fido", "owner": admin_user.pk, @@ -77,7 +77,7 @@ def test_oauth2_bearer_patch(unauthenticated_api_client, oauth2_admin_access_tok PATCH an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() data = { "name": "Fido", } @@ -103,7 +103,7 @@ def test_oauth2_bearer_put(unauthenticated_api_client, oauth2_admin_access_token PUT an animal with a bearer token. """ url = reverse("animal-detail", kwargs={"pk": animal.pk}) - token = oauth2_admin_access_token if token == 'fixture' else generate_token() + token = oauth2_admin_access_token.token if token == 'fixture' else generate_token() data = { "name": "Fido", "owner": admin_user.pk, diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index dd1af287f..408941d93 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -5,29 +5,8 @@ from django.utils.http import urlencode from ansible_base.authentication.models import AuthenticatorUser -from ansible_base.oauth2_provider.models import OAuth2AccessToken - - -@pytest.mark.django_db -@pytest.mark.parametrize( - 'client_fixture, user_fixture', - [ - pytest.param('user_api_client', 'user', id='user'), - pytest.param('admin_api_client', 'admin_user', id='admin'), - ], -) -def test_oauth2_provider_list_user_tokens(request, client_fixture, user_fixture): - client = request.getfixturevalue(client_fixture) - user = request.getfixturevalue(user_fixture) - url = reverse('token-list') - response = client.post(url, data={'scope': 'read'}) - assert response.status_code == 201 - assert response.data['scope'] == 'read' - assert response.data['user'] == user.pk - - get_response = client.get(url) - assert get_response.status_code == 200 - assert len(get_response.data['results']) == 1 +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken @pytest.mark.django_db @@ -113,6 +92,31 @@ def test_oauth2_existing_token_enabled_for_external_accounts( assert resp.json()['username'] == user.username +@pytest.mark.django_db +@pytest.mark.parametrize( + 'client_fixture, user_fixture', + [ + pytest.param('user_api_client', 'user', id='user'), + pytest.param('admin_api_client', 'admin_user', id='admin'), + ], +) +def test_oauth2_pat_create_and_list(request, client_fixture, user_fixture): + """ + A user can create and list personal access tokens. + """ + client = request.getfixturevalue(client_fixture) + user = request.getfixturevalue(user_fixture) + url = reverse('token-list') + response = client.post(url, data={'scope': 'read'}) + assert response.status_code == 201 + assert response.data['scope'] == 'read' + assert response.data['user'] == user.pk + + get_response = client.get(url) + assert get_response.status_code == 200 + assert len(get_response.data['results']) == 1 + + @pytest.mark.django_db def test_oauth2_pat_creation(oauth2_application_password, user, unauthenticated_api_client): app = oauth2_application_password[0] @@ -203,4 +207,78 @@ def test_oauth2_pat_list_is_user_related_field(user, admin_api_client): response = admin_api_client.get(url) assert response.status_code == 200 assert 'personal_tokens' in response.data['related'] - assert response.data['delated']['personal_tokens'] == reverse('user-personal-tokens-list', kwargs={"pk": user.pk}) + assert response.data['related']['personal_tokens'] == reverse('user-personal-tokens-list', kwargs={"pk": user.pk}) + + +@pytest.mark.django_db +def test_oauth2_token_create(oauth2_application, admin_api_client, admin_user): + oauth2_application = oauth2_application[0] + url = reverse('token-list') + response = admin_api_client.post(url, {'scope': 'read', 'application': oauth2_application.pk}) + assert response.status_code == 201 + assert 'modified' in response.data and response.data['modified'] is not None + assert 'updated' not in response.data + token = OAuth2AccessToken.objects.get(token=response.data['token']) + refresh_token = OAuth2RefreshToken.objects.get(token=response.data['refresh_token']) + assert token.application == oauth2_application + assert refresh_token.application == oauth2_application + assert token.user == admin_user + assert refresh_token.user == admin_user + assert refresh_token.access_token == token + assert token.scope == 'read' + + url = reverse('application-access_tokens-list', kwargs={'pk': oauth2_application.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == token.pk + assert response.data['results'][0]['scope'] == token.scope + + +def test_oauth2_application_token_summary_fields(admin_api_client, oauth2_admin_access_token, oauth2_application): + url = reverse('application-detail', kwargs={'pk': oauth2_application[0].pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['summary_fields']['tokens']['count'] == 1 + assert response.data['summary_fields']['tokens']['results'][0] == {'id': oauth2_admin_access_token.pk, 'scope': 'write', 'token': ENCRYPTED_STRING} + + +@pytest.mark.django_db +def test_oauth2_authorized_list_for_user(oauth2_application, oauth2_user_pat, oauth2_user_pat_1, user, admin_api_client): + """ + Tests that we can list a user's authorized tokens via API. + """ + # Turn the PATs into authorized tokens by attaching an application + oauth2_application = oauth2_application[0] + oauth2_user_pat.application = oauth2_application + oauth2_user_pat.save() + oauth2_user_pat_1.application = oauth2_application + oauth2_user_pat_1.save() + + url = reverse('user-authorized-tokens-list', kwargs={"pk": user.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert len(response.data['results']) == 2 + + +def test_oauth2_authorized_list_for_invalid_user(oauth2_user_pat, oauth2_user_pat_1, user, admin_api_client): + """ + Ensure we don't fatal if we give a bad user PK. + + We return an empty list. + """ + url = reverse('user-authorized-tokens-list', kwargs={"pk": 1000}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['results'] == [] + + +def test_oauth2_authorized_list_is_user_related_field(user, admin_api_client): + """ + Ensure 'authorized_tokens' shows up in the user's related fields. + """ + url = reverse('user-detail', kwargs={"pk": user.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert 'authorized_tokens' in response.data['related'] + assert response.data['related']['authorized_tokens'] == reverse('user-authorized-tokens-list', kwargs={"pk": user.pk}) From 5c179d4febdfbffa45d98c2d7597edf15ba720ec Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 23:05:09 +0200 Subject: [PATCH 34/46] Just hardcode the user model basename in the mixin Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/views/user_mixin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ansible_base/oauth2_provider/views/user_mixin.py b/ansible_base/oauth2_provider/views/user_mixin.py index 3e0d16170..7233d6adc 100644 --- a/ansible_base/oauth2_provider/views/user_mixin.py +++ b/ansible_base/oauth2_provider/views/user_mixin.py @@ -1,7 +1,9 @@ +from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework.decorators import action from rest_framework.response import Response +from ansible_base.lib.abstract_models.common import get_cls_view_basename from ansible_base.oauth2_provider.models import OAuth2AccessToken from ansible_base.oauth2_provider.serializers import OAuth2TokenSerializer @@ -13,8 +15,9 @@ class DABOAuth2UserViewsetMixin: def extra_related_fields(self, obj) -> dict[str, str]: fields = super().extra_related_fields(obj) - fields['personal_tokens'] = reverse(f'{self.basename}-personal-tokens-list', kwargs={"pk": obj.pk}) - fields['authorized_tokens'] = reverse(f'{self.basename}-authorized-tokens-list', kwargs={"pk": obj.pk}) + user_basename = get_cls_view_basename(get_user_model()) + fields['personal_tokens'] = reverse(f'{user_basename}-personal-tokens-list', kwargs={"pk": obj.pk}) + fields['authorized_tokens'] = reverse(f'{user_basename}-authorized-tokens-list', kwargs={"pk": obj.pk}) return fields def _user_token_response(self, request, application_isnull, pk): From 0370e765b5549f74d65a4931be2280781ff3c496 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 1 May 2024 23:15:18 +0200 Subject: [PATCH 35/46] We can't check user actions when we add more Signed-off-by: Rick Elrod --- .../test_association_resoure_router.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/test_app/tests/lib/routers/test_association_resoure_router.py b/test_app/tests/lib/routers/test_association_resoure_router.py index ea5c24060..9646b696a 100644 --- a/test_app/tests/lib/routers/test_association_resoure_router.py +++ b/test_app/tests/lib/routers/test_association_resoure_router.py @@ -3,7 +3,7 @@ from ansible_base.lib.routers import AssociationResourceRouter from test_app import views -from test_app.models import Inventory, User +from test_app.models import Inventory, Organization, User def validate_expected_url_pattern_names(router, expected_url_pattern_names): @@ -19,20 +19,20 @@ def validate_expected_url_pattern_names(router, expected_url_pattern_names): def test_association_router_basic_viewset(): router = AssociationResourceRouter() router.register( - r'user', - views.UserViewSet, - basename='user', + r'organizations', + views.OrganizationViewSet, + basename='organization', ) - validate_expected_url_pattern_names(router, ['user-list', 'user-detail']) + validate_expected_url_pattern_names(router, ['organization-list', 'organization-detail']) def test_association_router_basic_viewset_no_basename(): - class UserViewSetWithQueryset(views.UserViewSet): - queryset = User.objects.all() + class OrganizationViewSetWithQueryset(views.OrganizationViewSet): + queryset = Organization.objects.all() router = AssociationResourceRouter() - router.register(r'user', UserViewSetWithQueryset) - validate_expected_url_pattern_names(router, ['user-list', 'user-detail']) + router.register(r'organizations', OrganizationViewSetWithQueryset) + validate_expected_url_pattern_names(router, ['organization-list', 'organization-detail']) def test_association_router_associate_viewset_all_mapings(): @@ -106,9 +106,6 @@ def test_association_router_associate_existing_item(db, admin_api_client, random related_model = RelatedFieldsTestModel.objects.create() related_model.users.add(random_user) assert related_model.users.count() == 1 - - from test_app.models import User - assert User.objects.get(pk=random_user.pk) is not None url = reverse('related_fields_test_model-users-associate', kwargs={'pk': related_model.pk}) From 243060ae6db556fec69c67723f3fda50c43fa028 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Sun, 5 May 2024 17:09:16 +0200 Subject: [PATCH 36/46] Finish porting AWX tests over Signed-off-by: Rick Elrod --- .../tests/oauth2_provider/models/__init__.py | 0 .../models/test_access_token.py | 0 .../models/test_application.py | 142 -------------- .../oauth2_provider/serializers/__init__.py | 0 .../serializers/test_application.py | 0 .../oauth2_provider/serializers/test_token.py | 0 test_app/tests/oauth2_provider/test_models.py | 34 ++++ .../oauth2_provider/views/test_application.py | 16 +- .../tests/oauth2_provider/views/test_token.py | 174 +++++++++++++++--- 9 files changed, 198 insertions(+), 168 deletions(-) delete mode 100644 test_app/tests/oauth2_provider/models/__init__.py delete mode 100644 test_app/tests/oauth2_provider/models/test_access_token.py delete mode 100644 test_app/tests/oauth2_provider/models/test_application.py delete mode 100644 test_app/tests/oauth2_provider/serializers/__init__.py delete mode 100644 test_app/tests/oauth2_provider/serializers/test_application.py delete mode 100644 test_app/tests/oauth2_provider/serializers/test_token.py create mode 100644 test_app/tests/oauth2_provider/test_models.py diff --git a/test_app/tests/oauth2_provider/models/__init__.py b/test_app/tests/oauth2_provider/models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/models/test_access_token.py b/test_app/tests/oauth2_provider/models/test_access_token.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/models/test_application.py b/test_app/tests/oauth2_provider/models/test_application.py deleted file mode 100644 index 53d507853..000000000 --- a/test_app/tests/oauth2_provider/models/test_application.py +++ /dev/null @@ -1,142 +0,0 @@ -# @pytest.mark.django_db -# def test_oauth_token_create(oauth_application, get, post, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# assert 'modified' in response.data and response.data['modified'] is not None -# assert 'updated' not in response.data -# token = AccessToken.objects.get(token=response.data['token']) -# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) -# assert token.application == oauth_application -# assert refresh_token.application == oauth_application -# assert token.user == admin -# assert refresh_token.user == admin -# assert refresh_token.access_token == token -# assert token.scope == 'read' -# response = get(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), admin, expect=200) -# assert response.data['count'] == 1 -# response = get(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=200) -# assert response.data['summary_fields']['tokens']['count'] == 1 -# assert response.data['summary_fields']['tokens']['results'][0] == {'id': token.pk, 'scope': token.scope, 'token': '************'} -# -# response = post(reverse('api:o_auth2_token_list'), {'scope': 'read', 'application': oauth_application.pk}, admin, expect=201) -# assert response.data['refresh_token'] -# response = post( -# reverse('api:user_authorized_token_list', kwargs={'pk': admin.pk}), {'scope': 'read', 'application': oauth_application.pk}, admin, expect=201 -# ) -# assert response.data['refresh_token'] -# response = post(reverse('api:application_o_auth2_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# assert response.data['refresh_token'] -# -# -# @pytest.mark.django_db -# def test_oauth_token_update(oauth_application, post, patch, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# token = AccessToken.objects.get(token=response.data['token']) -# patch(reverse('api:o_auth2_token_detail', kwargs={'pk': token.pk}), {'scope': 'write'}, admin, expect=200) -# token = AccessToken.objects.get(token=token.token) -# assert token.scope == 'write' -# -# -# @pytest.mark.django_db -# def test_oauth_token_delete(oauth_application, post, delete, get, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# token = AccessToken.objects.get(token=response.data['token']) -# delete(reverse('api:o_auth2_token_detail', kwargs={'pk': token.pk}), admin, expect=204) -# assert AccessToken.objects.count() == 0 -# assert RefreshToken.objects.count() == 1 -# response = get(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), admin, expect=200) -# assert response.data['count'] == 0 -# response = get(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=200) -# assert response.data['summary_fields']['tokens']['count'] == 0 -# -# -# @pytest.mark.django_db -# def test_oauth_application_delete(oauth_application, post, delete, admin): -# post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# delete(reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), admin, expect=204) -# assert Application.objects.filter(client_id=oauth_application.client_id).count() == 0 -# assert RefreshToken.objects.filter(application=oauth_application).count() == 0 -# assert AccessToken.objects.filter(application=oauth_application).count() == 0 -# -# @pytest.mark.django_db -# def test_refresh_accesstoken(oauth_application, post, get, delete, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# assert AccessToken.objects.count() == 1 -# assert RefreshToken.objects.count() == 1 -# token = AccessToken.objects.get(token=response.data['token']) -# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) -# -# refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/' -# response = post( -# refresh_url, -# data='grant_type=refresh_token&refresh_token=' + refresh_token.token, -# content_type='application/x-www-form-urlencoded', -# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), -# ) -# assert RefreshToken.objects.filter(token=refresh_token).exists() -# original_refresh_token = RefreshToken.objects.get(token=refresh_token) -# assert token not in AccessToken.objects.all() -# assert AccessToken.objects.count() == 1 -# # the same RefreshToken remains but is marked revoked -# assert RefreshToken.objects.count() == 2 -# new_token = json.loads(response._container[0])['access_token'] -# new_refresh_token = json.loads(response._container[0])['refresh_token'] -# assert AccessToken.objects.filter(token=new_token).count() == 1 -# # checks that RefreshTokens are rotated (new RefreshToken issued) -# assert RefreshToken.objects.filter(token=new_refresh_token).count() == 1 -# assert original_refresh_token.revoked # is not None -# -# -# @pytest.mark.django_db -# def test_refresh_token_expiration_is_respected(oauth_application, post, get, delete, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# assert AccessToken.objects.count() == 1 -# assert RefreshToken.objects.count() == 1 -# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) -# refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/' -# short_lived = {'ACCESS_TOKEN_EXPIRE_SECONDS': 1, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 1, 'REFRESH_TOKEN_EXPIRE_SECONDS': 1} -# time.sleep(1) -# with override_settings(OAUTH2_PROVIDER=short_lived): -# response = post( -# refresh_url, -# data='grant_type=refresh_token&refresh_token=' + refresh_token.token, -# content_type='application/x-www-form-urlencoded', -# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), -# ) -# assert response.status_code == 403 -# assert b'The refresh token has expired.' in response.content -# assert RefreshToken.objects.filter(token=refresh_token).exists() -# assert AccessToken.objects.count() == 1 -# assert RefreshToken.objects.count() == 1 -# -# -# @pytest.mark.django_db -# def test_revoke_access_then_refreshtoken(oauth_application, post, get, delete, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# token = AccessToken.objects.get(token=response.data['token']) -# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) -# assert AccessToken.objects.count() == 1 -# assert RefreshToken.objects.count() == 1 -# -# token.revoke() -# assert AccessToken.objects.count() == 0 -# assert RefreshToken.objects.count() == 1 -# assert not refresh_token.revoked -# -# refresh_token.revoke() -# assert AccessToken.objects.count() == 0 -# assert RefreshToken.objects.count() == 1 -# -# -# @pytest.mark.django_db -# def test_revoke_refreshtoken(oauth_application, post, get, delete, admin): -# response = post(reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}), {'scope': 'read'}, admin, expect=201) -# refresh_token = RefreshToken.objects.get(token=response.data['refresh_token']) -# assert AccessToken.objects.count() == 1 -# assert RefreshToken.objects.count() == 1 -# -# refresh_token.revoke() -# assert AccessToken.objects.count() == 0 -# # the same RefreshToken is recycled -# new_refresh_token = RefreshToken.objects.all().first() -# assert refresh_token == new_refresh_token -# assert new_refresh_token.revoked diff --git a/test_app/tests/oauth2_provider/serializers/__init__.py b/test_app/tests/oauth2_provider/serializers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/serializers/test_application.py b/test_app/tests/oauth2_provider/serializers/test_application.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/serializers/test_token.py b/test_app/tests/oauth2_provider/serializers/test_token.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_app/tests/oauth2_provider/test_models.py b/test_app/tests/oauth2_provider/test_models.py new file mode 100644 index 000000000..aaccfc2e0 --- /dev/null +++ b/test_app/tests/oauth2_provider/test_models.py @@ -0,0 +1,34 @@ +import pytest + +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken + + +@pytest.mark.django_db +def test_oauth2_revoke_access_then_refresh_token(oauth2_admin_access_token): + token = oauth2_admin_access_token + refresh_token = oauth2_admin_access_token.refresh_token + assert OAuth2AccessToken.objects.count() == 1 + assert OAuth2RefreshToken.objects.count() == 1 + + token.revoke() + assert OAuth2AccessToken.objects.count() == 0 + assert OAuth2RefreshToken.objects.count() == 1 + assert not refresh_token.revoked + + refresh_token.revoke() + assert OAuth2AccessToken.objects.count() == 0 + assert OAuth2RefreshToken.objects.count() == 1 + + +@pytest.mark.django_db +def test_oauth2_revoke_refresh_token(oauth2_admin_access_token): + refresh_token = oauth2_admin_access_token.refresh_token + assert OAuth2AccessToken.objects.count() == 1 + assert OAuth2RefreshToken.objects.count() == 1 + + refresh_token.revoke() + assert OAuth2AccessToken.objects.count() == 0 + # the same OAuth2RefreshToken is recycled + new_refresh_token = OAuth2RefreshToken.objects.all().first() + assert refresh_token == new_refresh_token + assert new_refresh_token.revoked diff --git a/test_app/tests/oauth2_provider/views/test_application.py b/test_app/tests/oauth2_provider/views/test_application.py index 351af75c7..d132139f8 100644 --- a/test_app/tests/oauth2_provider/views/test_application.py +++ b/test_app/tests/oauth2_provider/views/test_application.py @@ -3,7 +3,7 @@ from django.urls import reverse from ansible_base.lib.utils.encryption import ENCRYPTED_STRING -from ansible_base.oauth2_provider.models import OAuth2Application +from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2Application, OAuth2RefreshToken @pytest.mark.parametrize( @@ -249,3 +249,17 @@ def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, o response = admin_api_client.delete(reverse("application-detail", args=[application.pk])) assert response.status_code == 204 assert response.data is None, response.data + + +@pytest.mark.django_db +def test_oauth2_application_delete(oauth2_application, admin_api_client): + """ + Test that we can delete an OAuth2 application. + """ + oauth2_application = oauth2_application[0] + url = reverse("application-detail", args=[oauth2_application.pk]) + response = admin_api_client.delete(url) + assert response.status_code == 204 + assert OAuth2Application.objects.filter(client_id=oauth2_application.client_id).count() == 0 + assert OAuth2RefreshToken.objects.filter(application=oauth2_application).count() == 0 + assert OAuth2AccessToken.objects.filter(application=oauth2_application).count() == 0 diff --git a/test_app/tests/oauth2_provider/views/test_token.py b/test_app/tests/oauth2_provider/views/test_token.py index 408941d93..de14292a6 100644 --- a/test_app/tests/oauth2_provider/views/test_token.py +++ b/test_app/tests/oauth2_provider/views/test_token.py @@ -1,4 +1,6 @@ import base64 +import json +import time import pytest from django.urls import reverse @@ -210,31 +212,6 @@ def test_oauth2_pat_list_is_user_related_field(user, admin_api_client): assert response.data['related']['personal_tokens'] == reverse('user-personal-tokens-list', kwargs={"pk": user.pk}) -@pytest.mark.django_db -def test_oauth2_token_create(oauth2_application, admin_api_client, admin_user): - oauth2_application = oauth2_application[0] - url = reverse('token-list') - response = admin_api_client.post(url, {'scope': 'read', 'application': oauth2_application.pk}) - assert response.status_code == 201 - assert 'modified' in response.data and response.data['modified'] is not None - assert 'updated' not in response.data - token = OAuth2AccessToken.objects.get(token=response.data['token']) - refresh_token = OAuth2RefreshToken.objects.get(token=response.data['refresh_token']) - assert token.application == oauth2_application - assert refresh_token.application == oauth2_application - assert token.user == admin_user - assert refresh_token.user == admin_user - assert refresh_token.access_token == token - assert token.scope == 'read' - - url = reverse('application-access_tokens-list', kwargs={'pk': oauth2_application.pk}) - response = admin_api_client.get(url) - assert response.status_code == 200 - assert response.data['count'] == 1 - assert response.data['results'][0]['id'] == token.pk - assert response.data['results'][0]['scope'] == token.scope - - def test_oauth2_application_token_summary_fields(admin_api_client, oauth2_admin_access_token, oauth2_application): url = reverse('application-detail', kwargs={'pk': oauth2_application[0].pk}) response = admin_api_client.get(url) @@ -282,3 +259,150 @@ def test_oauth2_authorized_list_is_user_related_field(user, admin_api_client): assert response.status_code == 200 assert 'authorized_tokens' in response.data['related'] assert response.data['related']['authorized_tokens'] == reverse('user-authorized-tokens-list', kwargs={"pk": user.pk}) + + +@pytest.mark.django_db +def test_oauth2_token_createn(oauth2_application, admin_api_client, admin_user): + oauth2_application = oauth2_application[0] + url = reverse('token-list') + response = admin_api_client.post(url, {'scope': 'read', 'application': oauth2_application.pk}) + assert response.status_code == 201 + assert 'modified' in response.data and response.data['modified'] is not None + assert 'updated' not in response.data + token = OAuth2AccessToken.objects.get(token=response.data['token']) + refresh_token = OAuth2RefreshToken.objects.get(token=response.data['refresh_token']) + assert token.application == oauth2_application + assert refresh_token.application == oauth2_application + assert token.user == admin_user + assert refresh_token.user == admin_user + assert refresh_token.access_token == token + assert token.scope == 'read' + + url = reverse('application-access_tokens-list', kwargs={'pk': oauth2_application.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == token.pk + + url = reverse('application-detail', kwargs={'pk': oauth2_application.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['summary_fields']['tokens']['count'] == 1 + assert response.data['summary_fields']['tokens']['results'][0] == {'id': token.pk, 'scope': token.scope, 'token': ENCRYPTED_STRING} + + url = reverse('token-list') + response = admin_api_client.post(url, {'scope': 'write', 'application': oauth2_application.pk}) + assert response.status_code == 201 + assert response.data['refresh_token'] + + url = reverse('token-list') + response = admin_api_client.post(url, {'scope': 'read', 'application': oauth2_application.pk, 'user': admin_user.pk}) + assert response.status_code == 201 + assert response.data['refresh_token'] + + url = reverse('token-list') + response = admin_api_client.post(url, {'scope': 'read', 'application': oauth2_application.pk}) + assert response.status_code == 201 + assert response.data['refresh_token'] + + +@pytest.mark.django_db +def test_oauth2_token_update(oauth2_admin_access_token, admin_api_client): + assert oauth2_admin_access_token.scope == 'write' + url = reverse('token-detail', kwargs={'pk': oauth2_admin_access_token.pk}) + response = admin_api_client.patch(url, {'scope': 'read'}) + assert response.status_code == 200 + oauth2_admin_access_token.refresh_from_db() + assert oauth2_admin_access_token.scope == 'read' + + +@pytest.mark.django_db +def test_oauth2_token_delete(oauth2_admin_access_token, admin_api_client): + url = reverse('token-detail', kwargs={'pk': oauth2_admin_access_token.pk}) + response = admin_api_client.delete(url) + assert response.status_code == 204 + assert OAuth2AccessToken.objects.count() == 0 + assert OAuth2RefreshToken.objects.count() == 1 + + url = reverse('application-access_tokens-list', kwargs={'pk': oauth2_admin_access_token.application.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['count'] == 0 + + url = reverse('application-detail', kwargs={'pk': oauth2_admin_access_token.application.pk}) + response = admin_api_client.get(url) + assert response.status_code == 200 + assert response.data['summary_fields']['tokens']['count'] == 0 + + +@pytest.mark.django_db +def test_oauth2_refresh_access_token(oauth2_application, oauth2_admin_access_token, unauthenticated_api_client): + """ + Test that we can refresh an access token. + """ + app = oauth2_application[0] + secret = oauth2_application[1] + refresh_token = oauth2_admin_access_token.refresh_token + + url = reverse('token') + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + } + resp = unauthenticated_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + assert resp.status_code == 201 + assert OAuth2RefreshToken.objects.filter(token=refresh_token).exists() + original_refresh_token = OAuth2RefreshToken.objects.get(token=refresh_token) + assert oauth2_admin_access_token not in OAuth2AccessToken.objects.all() + assert OAuth2AccessToken.objects.count() == 1 + + # the same RefreshToken remains but is marked revoked + assert OAuth2RefreshToken.objects.count() == 2 + assert original_refresh_token.revoked + + json_resp = json.loads(resp.content) + new_token = json_resp['access_token'] + new_refresh_token = json_resp['refresh_token'] + + assert OAuth2AccessToken.objects.filter(token=new_token).count() == 1 + # checks that RefreshTokens are rotated (new RefreshToken issued) + assert OAuth2RefreshToken.objects.filter(token=new_refresh_token).count() == 1 + new_refresh_obj = OAuth2RefreshToken.objects.get(token=new_refresh_token) + assert not new_refresh_obj.revoked + + +@pytest.mark.django_db +def test_oauth2_refresh_token_expiration_is_respected(oauth2_application, oauth2_admin_access_token, admin_api_client, settings): + """ + Test that a refresh token that has expired cannot be used to refresh an access token. + """ + app = oauth2_application[0] + secret = oauth2_application[1] + refresh_token = oauth2_admin_access_token.refresh_token + + settings.OAUTH2_PROVIDER['REFRESH_TOKEN_EXPIRE_SECONDS'] = 1 + settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] = 1 + settings.OAUTH2_PROVIDER['AUTHORIZATION_CODE_EXPIRE_SECONDS'] = 1 + time.sleep(1) + + url = reverse('token') + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + } + response = admin_api_client.post( + url, + data=urlencode(data), + content_type='application/x-www-form-urlencoded', + headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()}, + ) + assert response.status_code == 403 + assert b'The refresh token has expired.' in response.content + assert OAuth2RefreshToken.objects.filter(token=refresh_token).exists() + assert OAuth2AccessToken.objects.count() == 1 + assert OAuth2RefreshToken.objects.count() == 1 From 33754a27441325a02acbc8a724c2d3f02a254bae Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 7 May 2024 13:25:21 +0200 Subject: [PATCH 37/46] Use CommonModel fields for created and modified We have to use the right order of inheritance for this to work, and not null out fields that we actually want :) Signed-off-by: Rick Elrod --- ...ove_oauth2accesstoken_updated_and_more.py} | 33 +++++++++++-------- .../oauth2_provider/models/access_token.py | 5 ++- .../oauth2_provider/models/application.py | 5 ++- .../oauth2_provider/models/id_token.py | 5 ++- .../oauth2_provider/models/refresh_token.py | 3 +- 5 files changed, 28 insertions(+), 23 deletions(-) rename ansible_base/oauth2_provider/migrations/{0002_remove_oauth2accesstoken_created_and_more.py => 0002_remove_oauth2accesstoken_updated_and_more.py} (91%) diff --git a/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py b/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py similarity index 91% rename from ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py rename to ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py index 722ed0d23..2e34c8e68 100644 --- a/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_created_and_more.py +++ b/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-05-01 17:42 +# Generated by Django 4.2.11 on 2024-05-07 11:23 from django.conf import settings from django.db import migrations, models @@ -15,28 +15,20 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( - model_name='oauth2accesstoken', - name='created', - ), migrations.RemoveField( model_name='oauth2accesstoken', name='updated', ), - migrations.RemoveField( - model_name='oauth2application', - name='created', - ), migrations.RemoveField( model_name='oauth2application', name='updated', ), migrations.RemoveField( model_name='oauth2idtoken', - name='created', + name='updated', ), migrations.RemoveField( - model_name='oauth2idtoken', + model_name='oauth2refreshtoken', name='updated', ), migrations.AlterField( @@ -44,6 +36,11 @@ class Migration(migrations.Migration): name='application', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='created', + field=models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created'), + ), migrations.AlterField( model_name='oauth2accesstoken', name='created_by', @@ -84,6 +81,11 @@ class Migration(migrations.Migration): 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.AlterField( + model_name='oauth2application', + name='created', + field=models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created'), + ), migrations.AlterField( model_name='oauth2application', name='created_by', @@ -107,13 +109,18 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='oauth2application', name='name', - field=models.CharField(blank=True, max_length=255), + field=models.CharField(help_text='The name of this resource', max_length=512), ), migrations.AlterField( model_name='oauth2application', name='user', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to=settings.AUTH_USER_MODEL), ), + migrations.AlterField( + model_name='oauth2idtoken', + name='created', + field=models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created'), + ), migrations.AlterField( model_name='oauth2idtoken', name='created_by', @@ -147,7 +154,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='oauth2refreshtoken', name='created', - field=models.DateTimeField(auto_now_add=True), + field=models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created'), ), migrations.AlterField( model_name='oauth2refreshtoken', diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index 34836a941..87fb58f2f 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -17,7 +17,7 @@ activitystream = AuditableModel -class OAuth2AccessToken(oauth2_models.AbstractAccessToken, CommonModel, activitystream): +class OAuth2AccessToken(CommonModel, oauth2_models.AbstractAccessToken, activitystream): router_basename = 'token' ignore_relations = ['refresh_token'] @@ -64,8 +64,7 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): unique=True, ) ) - created = None # Tracked in CommonModel, no need for this - updated = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel with 'modified', no need for this def is_valid(self, scopes=None): valid = super(OAuth2AccessToken, self).is_valid(scopes) diff --git a/ansible_base/oauth2_provider/models/application.py b/ansible_base/oauth2_provider/models/application.py index 5a4b74ed1..0a0c981cb 100644 --- a/ansible_base/oauth2_provider/models/application.py +++ b/ansible_base/oauth2_provider/models/application.py @@ -19,7 +19,7 @@ DATA_URI_RE = re.compile(r'.*') # FIXME -class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel, activitystream): +class OAuth2Application(NamedCommonModel, oauth2_models.AbstractApplication, activitystream): router_basename = 'application' ignore_relations = ['oauth2idtoken', 'grant', 'oauth2refreshtoken'] # We do NOT add client_secret to encrypted_fields because it is hashed by Django OAuth Toolkit @@ -80,8 +80,7 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): authorization_grant_type = models.CharField( max_length=32, choices=GRANT_TYPES, help_text=_('The Grant type the user must use for acquire tokens for this application.') ) - created = None # Tracked in CommonModel, no need for this - updated = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel with 'modified', no need for this def get_absolute_url(self): # This is kind of annoying. This method lives on the superclass and we check for it in CommonModel. diff --git a/ansible_base/oauth2_provider/models/id_token.py b/ansible_base/oauth2_provider/models/id_token.py index 2127cca03..67f641231 100644 --- a/ansible_base/oauth2_provider/models/id_token.py +++ b/ansible_base/oauth2_provider/models/id_token.py @@ -11,10 +11,9 @@ activitystream = AuditableModel -class OAuth2IDToken(oauth2_models.AbstractIDToken, CommonModel, activitystream): +class OAuth2IDToken(CommonModel, oauth2_models.AbstractIDToken, activitystream): class Meta(oauth2_models.AbstractIDToken.Meta): verbose_name = _('id token') swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" - created = None # Tracked in CommonModel, no need for this - updated = None # Tracked in CommonModel, no need for this + updated = None # Tracked in CommonModel with 'modified', no need for this diff --git a/ansible_base/oauth2_provider/models/refresh_token.py b/ansible_base/oauth2_provider/models/refresh_token.py index bd83a1d11..a29cd1ceb 100644 --- a/ansible_base/oauth2_provider/models/refresh_token.py +++ b/ansible_base/oauth2_provider/models/refresh_token.py @@ -13,10 +13,11 @@ activitystream = AuditableModel -class OAuth2RefreshToken(oauth2_models.AbstractRefreshToken, CommonModel, activitystream): +class OAuth2RefreshToken(CommonModel, oauth2_models.AbstractRefreshToken, activitystream): class Meta(oauth2_models.AbstractRefreshToken.Meta): verbose_name = _('access token') ordering = ('id',) swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" token = prevent_search(models.CharField(max_length=255)) + updated = None # Tracked in CommonModel with 'modified', no need for this From 3e685c3469e3058adc1b934f8cf61167de601269 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:10:04 +0200 Subject: [PATCH 38/46] Nix dependency LB Signed-off-by: Rick Elrod --- requirements/requirements_oauth2_provider.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements_oauth2_provider.in b/requirements/requirements_oauth2_provider.in index ac2de91e4..c6686727d 100644 --- a/requirements/requirements_oauth2_provider.in +++ b/requirements/requirements_oauth2_provider.in @@ -1 +1 @@ -django-oauth-toolkit>=1.7.1 # This is pinned this way so that DAB is compatible with AWX \ No newline at end of file +django-oauth-toolkit \ No newline at end of file From f43a778fe408bd9a2206fc719292f62f520e1127 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:14:15 +0200 Subject: [PATCH 39/46] Use wildcard Signed-off-by: Rick Elrod --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1200e0d9a..ebec43e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,8 @@ force-exclude = ''' [tool.isort] profile = "black" line_length = 160 -extend_skip = [ "ansible_base/authentication/migrations", "ansible_base/activitystream/migrations", "test_app/migrations", "ansible_base/oauth2_provider/migrations" ] +extend_skip = [ "test_app/migrations" ] +skip_glob = [ "ansible_base/*/migrations" ] [tool.flake8] From f825464616226b21a4d2e526d4f7eb071b7de5b9 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:21:08 +0200 Subject: [PATCH 40/46] s/access/refresh/ Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/models/refresh_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/models/refresh_token.py b/ansible_base/oauth2_provider/models/refresh_token.py index a29cd1ceb..078a87cf9 100644 --- a/ansible_base/oauth2_provider/models/refresh_token.py +++ b/ansible_base/oauth2_provider/models/refresh_token.py @@ -15,7 +15,7 @@ class OAuth2RefreshToken(CommonModel, oauth2_models.AbstractRefreshToken, activitystream): class Meta(oauth2_models.AbstractRefreshToken.Meta): - verbose_name = _('access token') + verbose_name = _('refresh token') ordering = ('id',) swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" From ed14d404cc14e55ceca120f213818fc16e15c177 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:23:11 +0200 Subject: [PATCH 41/46] Fix logger path Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/serializers/token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index 37a715735..cdaff4bdd 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -14,7 +14,7 @@ from ansible_base.lib.utils.settings import get_setting from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken -logger = logging.getLogger("ansible_base.serializers.oauth2_provider") +logger = logging.getLogger("ansible_base.oauth2_provider.serializers.token") class BaseOAuth2TokenSerializer(CommonModelSerializer): From 92c88c2da9abfeebd580ff0d9c2691bb6daceabb Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:27:48 +0200 Subject: [PATCH 42/46] DRY `ALLOWED_SCOPES` in token serializer Signed-off-by: Rick Elrod --- ...0002_alter_oauth2refreshtoken_options_and_more.py} | 11 ++++++++++- ansible_base/oauth2_provider/models/access_token.py | 7 ++++++- ansible_base/oauth2_provider/serializers/token.py | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) rename ansible_base/oauth2_provider/migrations/{0002_remove_oauth2accesstoken_updated_and_more.py => 0002_alter_oauth2refreshtoken_options_and_more.py} (93%) diff --git a/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py b/ansible_base/oauth2_provider/migrations/0002_alter_oauth2refreshtoken_options_and_more.py similarity index 93% rename from ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py rename to ansible_base/oauth2_provider/migrations/0002_alter_oauth2refreshtoken_options_and_more.py index 2e34c8e68..36547007b 100644 --- a/ansible_base/oauth2_provider/migrations/0002_remove_oauth2accesstoken_updated_and_more.py +++ b/ansible_base/oauth2_provider/migrations/0002_alter_oauth2refreshtoken_options_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-05-07 11:23 +# Generated by Django 4.2.11 on 2024-05-08 14:27 from django.conf import settings from django.db import migrations, models @@ -15,6 +15,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterModelOptions( + name='oauth2refreshtoken', + options={'ordering': ('id',), 'verbose_name': 'refresh token'}, + ), migrations.RemoveField( model_name='oauth2accesstoken', name='updated', @@ -66,6 +70,11 @@ class Migration(migrations.Migration): name='modified_by', field=models.ForeignKey(default=None, editable=False, help_text='The user who last modified this resource', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_modified+', to=settings.AUTH_USER_MODEL), ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='scope', + field=models.CharField(blank=True, choices=[('read', 'Read'), ('write', 'Write')], default='write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write'].", max_length=32), + ), migrations.AlterField( model_name='oauth2accesstoken', name='token', diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index 87fb58f2f..510690ccd 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -26,6 +26,11 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): ordering = ('id',) swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL" + SCOPE_CHOICES = [ + ('read', _('Read')), + ('write', _('Write')), + ] + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -55,7 +60,7 @@ class Meta(oauth2_models.AbstractAccessToken.Meta): blank=True, default='write', max_length=32, - choices=[('read', 'read'), ('write', 'write')], + choices=SCOPE_CHOICES, help_text=_("Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."), ) token = prevent_search( diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index cdaff4bdd..687487042 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -20,7 +20,7 @@ class BaseOAuth2TokenSerializer(CommonModelSerializer): refresh_token = SerializerMethodField() token = SerializerMethodField() - ALLOWED_SCOPES = ['read', 'write'] + ALLOWED_SCOPES = [x[0] for x in OAuth2AccessToken.SCOPE_CHOICES] class Meta: model = OAuth2AccessToken From 67036fdad4cf73fdea9647757aedcd6682592dd2 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:30:31 +0200 Subject: [PATCH 43/46] Use crum's get_current_user() Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/serializers/token.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/serializers/token.py b/ansible_base/oauth2_provider/serializers/token.py index 687487042..319a15230 100644 --- a/ansible_base/oauth2_provider/serializers/token.py +++ b/ansible_base/oauth2_provider/serializers/token.py @@ -1,6 +1,7 @@ import logging from datetime import timedelta +from crum import get_current_user from django.core.exceptions import ObjectDoesNotExist from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -86,7 +87,7 @@ def create(self, validated_data): class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): def create(self, validated_data): - current_user = self.context['request'].user + current_user = get_current_user() validated_data['token'] = generate_token() expires_delta = get_setting('OAUTH2_PROVIDER', {}).get('ACCESS_TOKEN_EXPIRE_SECONDS', 0) if expires_delta == 0: From d8b99a2cb0b4658e96694a1d164a9bd044a44cb0 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:31:32 +0200 Subject: [PATCH 44/46] Be specific because people aren't mind-readers Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/views/token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/views/token.py b/ansible_base/oauth2_provider/views/token.py index 4eb733a90..021823797 100644 --- a/ansible_base/oauth2_provider/views/token.py +++ b/ansible_base/oauth2_provider/views/token.py @@ -13,7 +13,7 @@ class TokenView(oauth_views.TokenView, AnsibleBaseDjangoAppApiView): - # There is a big flow of logic that happens around this behind the scenes. + # There is a big flow of logic that happens around this (create_token_response) behind the scenes. # # oauth2_provider.views.TokenView inherits from oauth2_provider.views.mixins.OAuthLibMixin # That's where this method comes from originally. From c03cda6b7cbfffa62c1b7c1b5225e482f0156256 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:33:20 +0200 Subject: [PATCH 45/46] Use authenticator name instead of its type Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/models/access_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/oauth2_provider/models/access_token.py b/ansible_base/oauth2_provider/models/access_token.py index 510690ccd..175cc2baa 100644 --- a/ansible_base/oauth2_provider/models/access_token.py +++ b/ansible_base/oauth2_provider/models/access_token.py @@ -89,7 +89,7 @@ def validate_external_users(self): if external_account: raise oauth2.AccessDeniedError( _('OAuth2 Tokens cannot be created by users associated with an external authentication provider (%(authenticator)s)') - % {'authenticator': external_account.type} + % {'authenticator': external_account.name} ) def save(self, *args, **kwargs): From 077def8bf0ed8effc82ec85ffe1b479c765f8aec Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Wed, 8 May 2024 16:35:35 +0200 Subject: [PATCH 46/46] Just show the None if it's None Signed-off-by: Rick Elrod --- ansible_base/oauth2_provider/serializers/application.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ansible_base/oauth2_provider/serializers/application.py b/ansible_base/oauth2_provider/serializers/application.py index 8858c50a4..ee1f0058b 100644 --- a/ansible_base/oauth2_provider/serializers/application.py +++ b/ansible_base/oauth2_provider/serializers/application.py @@ -31,9 +31,6 @@ def _get_client_secret(self, obj): if obj.client_type == 'public': return None elif request.method == 'POST': - if self.oauth2_client_secret is None: - # This should be an impossible case, but... - return ENCRYPTED_STRING # Show the secret, one time, on POST return self.oauth2_client_secret else: