Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Oauth2 provider #133

Merged
merged 46 commits into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
206ef80
Add oauth2 provider application
john-westcott-iv Feb 11, 2024
6e33c5a
Start unit tests for oauth2 provider
john-westcott-iv Feb 28, 2024
d3605ab
Regen migrations for common changes
relrod Apr 18, 2024
60b4cd1
Tighten up is_external_account() and fix tests
relrod Apr 19, 2024
90c821b
fix import alias
relrod Apr 19, 2024
e8f8329
I regret everything.
relrod Apr 19, 2024
a65cc9b
Try fixing up migrations while not squashing them
relrod Apr 19, 2024
e1188fb
Start on application tests
relrod Apr 19, 2024
e7dcb01
Port a few tests from AWX
relrod Apr 19, 2024
67f7ffc
Port another test
relrod Apr 19, 2024
72a02d1
... And this is why we test :)
relrod Apr 20, 2024
edc486c
Rework application serializer a bit, client_secret
relrod Apr 21, 2024
d25de4f
Get "related" working for application tokens
relrod Apr 21, 2024
4304f2e
Create an OAuth2Application in demo data
relrod Apr 23, 2024
9898ac0
Add simple tests showing token auth works
relrod Apr 27, 2024
586efbc
Don't treat Application.client_secret as encrypted
relrod Apr 29, 2024
c437d9b
Make is_external_account return the authenticator
relrod Apr 29, 2024
b0e2e51
Show the proper authenticator type in error
relrod Apr 29, 2024
c68aa76
Welp, that took a while.
relrod Apr 29, 2024
41c5e57
Finish test_token tests
relrod Apr 30, 2024
7c51b36
Tidy up another test file
relrod Apr 30, 2024
a2f7420
Update fixture tuple application fixtures return
relrod Apr 30, 2024
adb478f
Track oauth models in activity stream and sanitize
relrod May 1, 2024
84ca302
Provide view-level hook for extra related fields
relrod May 1, 2024
46551be
Start on /users/PK/<oauth stuff>/ endpoints
relrod May 1, 2024
87dc7d0
Some coverage for the PAT mixin hack
relrod May 1, 2024
d3269af
Nix updated/created from token model
relrod May 1, 2024
6e70130
Nix DOT updated/created fields from some models
relrod May 1, 2024
7bc557c
Use the API for the token fixture
relrod May 1, 2024
25cef53
Clean up migrations
relrod May 1, 2024
4dc5d82
Start a doc for differences from AWX
relrod May 1, 2024
97501de
Make summary fields useful for Application
relrod May 1, 2024
96d616a
Get /users/N/authorized_tokens/ working
relrod May 1, 2024
5c179d4
Just hardcode the user model basename in the mixin
relrod May 1, 2024
0370e76
We can't check user actions when we add more
relrod May 1, 2024
243060a
Finish porting AWX tests over
relrod May 5, 2024
33754a2
Use CommonModel fields for created and modified
relrod May 7, 2024
3e685c3
Nix dependency LB
relrod May 8, 2024
f43a778
Use wildcard
relrod May 8, 2024
f825464
s/access/refresh/
relrod May 8, 2024
ed14d40
Fix logger path
relrod May 8, 2024
92c88c2
DRY `ALLOWED_SCOPES` in token serializer
relrod May 8, 2024
67036fd
Use crum's get_current_user()
relrod May 8, 2024
d8b99a2
Be specific because people aren't mind-readers
relrod May 8, 2024
c03cda6
Use authenticator name instead of its type
relrod May 8, 2024
077def8
Just show the None if it's None
relrod May 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ansible_base/authentication/utils/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions ansible_base/lib/dynamic_config/dynamic_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,37 @@
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"

ALLOW_OAUTH2_FOR_EXTERNAL_USERS = False
10 changes: 8 additions & 2 deletions ansible_base/lib/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
relrod marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand Down
10 changes: 10 additions & 0 deletions ansible_base/lib/utils/views/ansible_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
relrod marked this conversation as resolved.
Show resolved Hide resolved
Empty file.
3 changes: 3 additions & 0 deletions ansible_base/oauth2_provider/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin # noqa: F401

# Register your models here.
7 changes: 7 additions & 0 deletions ansible_base/oauth2_provider/apps.py
Original file line number Diff line number Diff line change
@@ -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'
20 changes: 20 additions & 0 deletions ansible_base/oauth2_provider/authentication.py
Original file line number Diff line number Diff line change
@@ -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 '<none>'
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
130 changes: 130 additions & 0 deletions ansible_base/oauth2_provider/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 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', 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('.*'))])),
('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)),
('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'),
'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)),
('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', 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',
'ordering': ('id',),
'swappable': 'OAUTH2_PROVIDER_ACCESS_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),
),
]
Loading
Loading