Skip to content

Commit e0d9c41

Browse files
committed
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]: django-oauth/django-oauth-toolkit#1311 Signed-off-by: Rick Elrod <[email protected]>
1 parent 85dd3cc commit e0d9c41

File tree

7 files changed

+134
-35
lines changed

7 files changed

+134
-35
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 4.2.11 on 2024-04-28 16:14
2+
3+
from django.db import migrations
4+
import oauth2_provider.generators
5+
import oauth2_provider.models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('dab_oauth2_provider', '0003_alter_oauth2accesstoken_application'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='oauth2application',
17+
name='client_secret',
18+
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),
19+
),
20+
]

ansible_base/oauth2_provider/models/application.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from django.db import models
77
from django.urls import reverse
88
from django.utils.translation import gettext_lazy as _
9-
from oauth2_provider.generators import generate_client_secret
109

1110
from ansible_base.lib.abstract_models.common import NamedCommonModel
1211

@@ -16,7 +15,8 @@
1615
class OAuth2Application(oauth2_models.AbstractApplication, NamedCommonModel):
1716
router_basename = 'application'
1817
ignore_relations = ['oauth2idtoken', 'grant', 'oauth2refreshtoken']
19-
encrypted_fields = ['client_secret']
18+
# We do NOT add client_secret to encrypted_fields because it is hashed by Django OAuth Toolkit
19+
# and it would end up hashing the encrypted value.
2020

2121
class Meta(oauth2_models.AbstractAccessToken.Meta):
2222
verbose_name = _('application')
@@ -50,13 +50,14 @@ class Meta(oauth2_models.AbstractAccessToken.Meta):
5050
on_delete=models.CASCADE,
5151
null=True,
5252
)
53-
client_secret = models.CharField(
54-
max_length=1024,
55-
blank=True,
56-
default=generate_client_secret,
57-
db_index=True,
58-
help_text=_('Used for more stringent verification of access to an application when creating a token.'),
59-
)
53+
# Not overriding client_secret... Details:
54+
# It would be nice to just use our usual encrypted_fields flow here
55+
# until DOT makes a release with https://github.com/jazzband/django-oauth-toolkit/pull/1311
56+
# there is no way to disable its expectation of using its own hashing
57+
# (which is Django's make_password/check_password).
58+
# So we use their field here.
59+
# Previous versions of DOT didn't hash the field at all and AWX pins
60+
# to <2.0.0 so AWX used the AWX encryption with no issue.
6061
client_type = models.CharField(
6162
max_length=32, choices=CLIENT_TYPES, help_text=_('Set to Public or Confidential depending on how secure the client device is.')
6263
)

ansible_base/oauth2_provider/serializers/application.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.core.exceptions import ObjectDoesNotExist
22
from django.utils.translation import gettext_lazy as _
3+
from oauth2_provider.generators import generate_client_secret
34

45
from ansible_base.lib.serializers.common import NamedCommonModelSerializer
5-
from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption
6+
from ansible_base.lib.utils.encryption import ENCRYPTED_STRING
67
from ansible_base.oauth2_provider.models import OAuth2Application
78

89

@@ -12,6 +13,8 @@ def has_model_field_prefetched(obj, thing):
1213

1314

1415
class OAuth2ApplicationSerializer(NamedCommonModelSerializer):
16+
oauth2_client_secret = None
17+
1518
class Meta:
1619
model = OAuth2Application
1720
fields = NamedCommonModelSerializer.Meta.fields + [x.name for x in OAuth2Application._meta.concrete_fields]
@@ -33,7 +36,11 @@ def _get_client_secret(self, obj):
3336
if obj.client_type == 'public':
3437
return None
3538
elif request.method == 'POST':
36-
return ansible_encryption.decrypt_string(obj.client_secret)
39+
if self.oauth2_client_secret is None:
40+
# This should be an impossible case, but...
41+
return ENCRYPTED_STRING
42+
# Show the secret, one time, on POST
43+
return self.oauth2_client_secret
3744
else:
3845
return ENCRYPTED_STRING
3946
except ObjectDoesNotExist:
@@ -49,7 +56,7 @@ def to_representation(self, instance):
4956
if secret is None:
5057
del ret['client_secret']
5158
else:
52-
ret['client_secret'] = self._get_client_secret(instance)
59+
ret['client_secret'] = secret
5360
return ret
5461

5562
def _summary_field_tokens(self, obj):
@@ -67,3 +74,27 @@ def get_summary_fields(self, obj):
6774
ret = super(OAuth2ApplicationSerializer, self).get_summary_fields(obj)
6875
ret['tokens'] = self._summary_field_tokens(obj)
6976
return ret
77+
78+
def create(self, validated_data):
79+
# This is hacky:
80+
# There is a cascading set of issues here.
81+
# 1. The first thing to know is that DOT automatically hashes the client_secret
82+
# in a pre_save method on the client_secret field.
83+
# 2. In current released versions, there is no way to disable (1). It uses
84+
# the built-in Django password hashing stuff to do this. There's a merged
85+
# PR to allow disabling this (DOT #1311), but it's not released yet.
86+
# 3. If we use our own encrypted_field stuff, it conflicts with (1) and (2).
87+
# They end up giving our encrypted field to Django's password check
88+
# and *we* end up showing *their* hashed value to the user on POST, which
89+
# doesn't work, the user needs to see the real (decrypted) value. So
90+
# until upstream #1311 is released, we do NOT treat the field as an
91+
# encrypted_field, we just defer to the upstream hashing.
92+
# 4. But we have no way to see the client_secret on POST, if we let the
93+
# model generate it, because it's hashed by the time we get to the
94+
# serializer...
95+
#
96+
# So to that end, on POST, we'll make the client secret here, and then
97+
# we can access it to show the user the value (once) on POST.
98+
validated_data['client_secret'] = generate_client_secret()
99+
self.oauth2_client_secret = validated_data['client_secret']
100+
return super().create(validated_data)

test_app/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
'handlers': ['console'],
3636
'level': 'DEBUG',
3737
},
38+
'': {
39+
'handlers': ['console'],
40+
'level': 'DEBUG',
41+
'propagate': True,
42+
},
3843
},
3944
}
4045
for logger in LOGGING["loggers"]: # noqa: F405

test_app/tests/oauth2_provider/conftest.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@
88

99
@pytest.fixture
1010
def oauth2_application(randname):
11-
return OAuth2Application.objects.create(
11+
"""
12+
Creates an OAuth2 application with a random name and returns
13+
both the application and its client secret.
14+
"""
15+
app = OAuth2Application(
1216
name=randname("OAuth2 Application"),
1317
description="Test OAuth2 Application",
1418
redirect_uris="http://example.com/callback",
1519
authorization_grant_type="authorization-code",
1620
client_type="confidential",
1721
)
22+
# Store this before it gets hashed
23+
secret = app.client_secret
24+
app.save()
25+
return (app, secret)
1826

1927

2028
@pytest.fixture

test_app/tests/oauth2_provider/views/test_application.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import pytest
2-
from django.db import connection
2+
from django.contrib.auth.hashers import check_password
33
from django.urls import reverse
44

5-
from ansible_base.lib.utils.encryption import ENCRYPTED_STRING, ansible_encryption
5+
from ansible_base.lib.utils.encryption import ENCRYPTED_STRING
66
from ansible_base.oauth2_provider.models import OAuth2Application
77

88

@@ -25,7 +25,7 @@ def test_oauth2_provider_application_list(request, client_fixture, expected_stat
2525
assert response.status_code == expected_status
2626
if expected_status == 200:
2727
assert len(response.data['results']) == OAuth2Application.objects.count()
28-
assert response.data['results'][0]['name'] == oauth2_application.name
28+
assert response.data['results'][0]['name'] == oauth2_application[0].name
2929

3030

3131
@pytest.mark.parametrize(
@@ -42,6 +42,7 @@ def test_oauth2_provider_application_related(admin_api_client, oauth2_applicatio
4242
Organization should only be shown if the application is associated with an organization.
4343
Associating an application with an organization should not affect other related fields.
4444
"""
45+
oauth2_application = oauth2_application[0]
4546
if view == "application-list":
4647
url = reverse(view)
4748
else:
@@ -75,6 +76,7 @@ def test_oauth2_provider_application_detail(request, client_fixture, expected_st
7576
"""
7677
Test that we can view the detail of an OAuth2 application iff we are authenticated.
7778
"""
79+
oauth2_application = oauth2_application[0]
7880
client = request.getfixturevalue(client_fixture)
7981
url = reverse("application-detail", args=[oauth2_application.pk])
8082
response = client.get(url)
@@ -152,6 +154,7 @@ def test_oauth2_provider_application_update(request, client_fixture, expected_st
152154
"""
153155
Test that we can update oauth2 applications iff we are authenticated.
154156
"""
157+
oauth2_application = oauth2_application[0]
155158
client = request.getfixturevalue(client_fixture)
156159
url = reverse("application-detail", args=[oauth2_application.pk])
157160
response = client.patch(
@@ -197,11 +200,22 @@ def test_oauth2_provider_application_client_secret_encrypted(admin_api_client, o
197200
)
198201
assert response.status_code == 201, response.data
199202
application = OAuth2Application.objects.get(pk=response.data['id'])
200-
with connection.cursor() as cursor:
201-
cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk])
202-
encrypted = cursor.fetchone()[0]
203-
assert encrypted.startswith(ENCRYPTED_STRING), encrypted
204-
assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'], response.data
203+
204+
# If we ever switch to using *our* encryption, this is a good test.
205+
# But until a release with jazzband/django-oauth-toolkit#1311 hits pypi,
206+
# we have no way to disable their built-in hashing (which conflicts with our
207+
# own encryption).
208+
# with connection.cursor() as cursor:
209+
# cursor.execute("SELECT client_secret FROM dab_oauth2_provider_oauth2application WHERE id = %s", [application.pk])
210+
# encrypted = cursor.fetchone()[0]
211+
# assert encrypted.startswith(ENCRYPTED_STRING), encrypted
212+
# assert ansible_encryption.decrypt_string(encrypted) == response.data['client_secret'], response.data
213+
# assert response.data['client_secret'] == application.client_secret
214+
215+
# For now we just make sure it shows the real client secret on POST
216+
# and never on any other method.
217+
assert 'client_secret' in response.data
218+
assert check_password(response.data['client_secret'], application.client_secret)
205219

206220
# GET
207221
response = admin_api_client.get(reverse("application-detail", args=[application.pk]))

test_app/tests/oauth2_provider/views/test_token.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
1+
import base64
2+
13
import pytest
24
from django.urls import reverse
5+
from django.utils.http import urlencode
6+
7+
8+
@pytest.mark.django_db
9+
def test_oauth2_personal_access_token_creation(oauth2_application, user, unauthenticated_api_client):
10+
app = oauth2_application[0]
11+
app.authorization_grant_type = 'password'
12+
app.save()
13+
14+
secret = oauth2_application[1]
15+
url = reverse('token')
16+
data = {
17+
"grant_type": "password",
18+
"username": "user",
19+
"password": "password",
20+
"scope": "read",
21+
}
22+
resp = unauthenticated_api_client.post(
23+
url,
24+
data=urlencode(data),
25+
content_type='application/x-www-form-urlencoded',
26+
headers={'Authorization': 'Basic ' + base64.b64encode(f"{app.client_id}:{secret}".encode()).decode()},
27+
)
28+
29+
assert resp.status_code == 200, resp.content
30+
resp_json = resp.json()
31+
assert 'access_token' in resp_json
32+
assert len(resp_json['access_token']) > 0
33+
assert 'scope' in resp_json
34+
assert resp_json['scope'] == 'read'
35+
assert 'refresh_token' in resp_json
36+
337

4-
# @pytest.mark.django_db
5-
# def test_personal_access_token_creation(oauth_application, post, alice):
6-
# url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
7-
# resp = post(
8-
# url,
9-
# data='grant_type=password&username=alice&password=alice&scope=read',
10-
# content_type='application/x-www-form-urlencoded',
11-
# HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))),
12-
# )
13-
# resp_json = smart_str(resp._container[0])
14-
# assert 'access_token' in resp_json
15-
# assert 'scope' in resp_json
16-
# assert 'refresh_token' in resp_json
17-
#
1838
#
1939
# @pytest.mark.django_db
2040
# @pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)])

0 commit comments

Comments
 (0)